feat: implement ADR-297 phase-3 — RF twin, placement, spatial memory, counterfactual, info-gain, active sensing

The higher-ceiling primitives on the fused world state. Six crates, all
deterministic SYNTHETIC/L0 model scaffolds (a twin predicts, it never measures);
70 tests + 6 doctests, verified green independently.

ruview-twin (ADR-312): per-deployment RF twin — radio geometry, a documented
synthetic log-distance + wall-attenuation propagation model, per-link expected
distributions, and the load-bearing delta(observed,expected) that localizes a
physical change (moved node / new reflector) to specific links. 8 tests.

ruview-infogain (ADR-311): Value(sensor) = expected uncertainty reduction /
weighted cost; pure bounded-greedy selection under a multi-dimension budget;
unknown-value candidates handled explicitly (defer/probe, never silent zero). 15.

ruview-active (ADR-306): closed-loop control vocabulary (channel/bandwidth/
cadence/antenna as validated ranges); step() proposes the next measurement to
reduce uncertainty, widening exploration when the last response is UNKNOWN;
emits a plan, never RF. 13.

ruview-placement (ADR-305): floorplan + inventory -> ranked placement via the
twin's propagation model; blind-spot flags; predicted-vs-observed adjustment. 11.

ruview-memory (ADR-309): learns per-zone normal physics; anomalies are
significant deltas vs baseline emitted as evidence records; UNKNOWN before a
baseline exists (no false positives). 14.

ruview-counterfactual (ADR-310): scores hypotheses under the twin — empty-room
vs occupied, one person vs two; UNKNOWN when indistinguishable. 8.

Flips ADR-305/306/309/310/311/312 to implemented. Completes all three phases of
the ADR-297 perception-substrate program. No hardware/MEASURED claims.

Co-Authored-By: claude-flow <ruv@ruv.net>
Claude-Session: https://claude.ai/code/session_015TcKegTS7QqhWPC2L2SzaS
This commit is contained in:
Claude
2026-08-11 13:13:04 +00:00
parent 49c594822f
commit e46fcc6862
40 changed files with 8138 additions and 6 deletions
@@ -1,6 +1,6 @@
# ADR-305: Sensor placement optimizer — floorplan + inventory → recommended positions
- **Status**: Proposed (ADR-297 phase 3)
- **Status**: Accepted — initial implementation (ADR-297 phase 3)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: placement, planning, rf-twin, coverage, worldgraph, phase-3
+1 -1
View File
@@ -1,6 +1,6 @@
# ADR-306: Active sensing — closed-loop RF experiment control
- **Status**: Proposed (ADR-297 phase 3)
- **Status**: Accepted — initial implementation (ADR-297 phase 3)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: active-sensing, control-plane, closed-loop, information-gain, actuation, phase-3
+1 -1
View File
@@ -1,6 +1,6 @@
# ADR-309: Long-term spatial memory — learn the normal physics of a location
- **Status**: Proposed (ADR-297 phase 3)
- **Status**: Accepted — initial implementation (ADR-297 phase 3)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: spatial-memory, ruvector, anomaly-detection, temporal, world-state, phase-3
+1 -1
View File
@@ -1,6 +1,6 @@
# ADR-310: Counterfactual inference — generative spatial reasoning
- **Status**: Proposed (ADR-297 phase 3)
- **Status**: Accepted — initial implementation (ADR-297 phase 3)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: inference, generative, counterfactual, rf-twin, fusion, uncertainty, phase-3
@@ -1,6 +1,6 @@
# ADR-311: Information-gain scheduler — sample the most informative radios
- **Status**: Proposed (ADR-297 phase 3)
- **Status**: Accepted — initial implementation (ADR-297 phase 3)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: scheduling, active-sensing, information-gain, edge, energy, fusion, phase-3
+1 -1
View File
@@ -1,6 +1,6 @@
# ADR-312: Digital RF twin — persistent per-deployment RF model
- **Status**: Proposed (ADR-297 phase 3)
- **Status**: Accepted — initial implementation (ADR-297 phase 3)
- **Date**: 2026-08-11
- **Deciders**: ruv
- **Tags**: rf-twin, digital-twin, propagation, calibration, spatial-memory, worldgraph, phase-3
Generated
+67
View File
@@ -7869,6 +7869,17 @@ version = "2.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "753a07254fa68db183949ec6c7575d890da4d42404afabc11d610a720fcf570c"
[[package]]
name = "ruview-active"
version = "0.3.1"
dependencies = [
"ruview-hal",
"ruview-ontology",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "ruview-attest"
version = "0.3.1"
@@ -7913,6 +7924,18 @@ dependencies = [
"wifi-densepose-calibration",
]
[[package]]
name = "ruview-counterfactual"
version = "0.3.1"
dependencies = [
"ruview-fusion",
"ruview-ontology",
"ruview-twin",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "ruview-evidence"
version = "0.3.1"
@@ -7954,6 +7977,29 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "ruview-infogain"
version = "0.3.1"
dependencies = [
"ruview-hal",
"ruview-ontology",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "ruview-memory"
version = "0.3.1"
dependencies = [
"ruview-evidence",
"ruview-ontology",
"ruview-twin",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "ruview-ontology"
version = "0.3.1"
@@ -7973,6 +8019,17 @@ dependencies = [
"wifi-densepose-calibration",
]
[[package]]
name = "ruview-placement"
version = "0.3.1"
dependencies = [
"ruview-ontology",
"ruview-twin",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "ruview-policy"
version = "0.3.1"
@@ -8027,6 +8084,16 @@ dependencies = [
"thiserror 2.0.18",
]
[[package]]
name = "ruview-twin"
version = "0.3.1"
dependencies = [
"ruview-ontology",
"serde",
"serde_json",
"thiserror 2.0.18",
]
[[package]]
name = "ruview-unified"
version = "0.3.1"
+7
View File
@@ -109,6 +109,13 @@ members = [
"crates/ruview-groundtruth",# ADR-300 ground-truth synchronization / validation plane
"crates/ruview-track", # ADR-304 persistent privacy-preserving tracking
"crates/ruview-fusion", # ADR-308 uncertainty-aware fusion -> one world state
# ADR-297 phase 3 — higher-ceiling primitives (on the fused world state):
"crates/ruview-twin", # ADR-312 digital RF twin (per-deployment model)
"crates/ruview-placement", # ADR-305 sensor placement optimizer
"crates/ruview-memory", # ADR-309 long-term spatial memory / anomaly
"crates/ruview-counterfactual",# ADR-310 counterfactual spatial inference
"crates/ruview-infogain", # ADR-311 information-gain scheduler
"crates/ruview-active", # ADR-306 active sensing control
]
# ADR-040: WASM edge crate targets wasm32-unknown-unknown (no_std),
# excluded from workspace to avoid breaking `cargo test --workspace`.
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "ruview-active"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
thiserror.workspace = true
serde = { workspace = true, features = ["derive"] }
ruview-ontology = { path = "../ruview-ontology" }
ruview-hal = { path = "../ruview-hal" }
[dev-dependencies]
serde_json.workspace = true
+424
View File
@@ -0,0 +1,424 @@
//! Controllable degrees of freedom of an RF measurement (ADR-306 §1).
//!
//! **SYNTHETIC / L0 model scaffold.** These types describe *what a controller
//! could ask hardware to configure*; constructing one drives **no** radio and
//! emits **no** RF. Every axis is a typed enum / validated range so a malformed
//! configuration is rejected at the boundary rather than reaching an actuator.
//!
//! Each axis is optional and capability-gated: a deployment advertises the
//! values it can actually set through [`ControlCapability`]. A commodity ESP32
//! that can only vary its sounding cadence exposes a capability whose only
//! non-empty axis is [`ControlCapability::cadences`]; an all-empty capability
//! means nothing is controllable and the controller degrades to the passive
//! planner (ADR-306 §2, ADR-280).
use serde::{Deserialize, Serialize};
use thiserror::Error;
/// Maximum number of distinct values accepted per control axis. Bounds
/// allocation when a capability set is built from untrusted input.
pub const MAX_AXIS_VALUES: usize = 64;
/// Maximum number of antenna chains a synthetic aperture may model.
pub const MAX_CHAINS: u8 = 16;
/// Smallest modelled sounding interval, in milliseconds (fastest cadence).
pub const MIN_CADENCE_MS: u32 = 1;
/// Largest modelled sounding interval, in milliseconds (slowest cadence).
pub const MAX_CADENCE_MS: u32 = 60_000;
/// Reasons a control value or capability set is rejected at the boundary.
#[derive(Clone, Debug, PartialEq, Eq, Error)]
pub enum ControlError {
/// A channel number is not a valid channel for its band.
#[error("channel {number} is not valid in band {band:?}")]
InvalidChannel {
/// The rejected band.
band: Band,
/// The rejected channel number.
number: u16,
},
/// A bandwidth value (in MHz) is not a recognised channel width.
#[error("bandwidth {mhz} MHz is not a recognised channel width")]
InvalidBandwidth {
/// The rejected width in MHz.
mhz: u16,
},
/// A sounding interval is outside the modelled `[MIN, MAX]` cadence range.
#[error("cadence interval {interval_ms} ms is outside [{min}, {max}] ms")]
InvalidCadence {
/// The rejected interval in milliseconds.
interval_ms: u32,
/// The accepted minimum.
min: u32,
/// The accepted maximum.
max: u32,
},
/// An antenna selection is empty (no chains active).
#[error("antenna selection must activate at least one chain")]
EmptyAntennaSelection,
/// An antenna chain index is out of range for the declared aperture.
#[error("antenna chain index {index} is out of range for {num_chains} chains (max {max})")]
AntennaChainOutOfRange {
/// The offending chain index.
index: u8,
/// The declared number of chains.
num_chains: u8,
/// The largest permitted chain count.
max: u8,
},
/// A capability axis listed more than [`MAX_AXIS_VALUES`] values.
#[error("control axis lists {len} values, exceeding the maximum {max}")]
AxisTooLarge {
/// Actual number of values supplied.
len: usize,
/// The enforced maximum.
max: usize,
},
}
/// The RF band a channel belongs to. Determines which channel numbers are
/// valid.
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Band {
/// 2.4 GHz band.
Ghz24,
/// 5 GHz band.
Ghz5,
/// 6 GHz band (Wi-Fi 6E).
Ghz6,
}
/// The standard 5 GHz channel numbers RuView may model probing.
const GHZ5_CHANNELS: &[u16] = &[
36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144,
149, 153, 157, 161, 165,
];
/// A validated Wi-Fi channel: a band plus a channel number known to that band.
///
/// This is a *choice of which spectrum to probe*, not an instruction to any
/// radio. Construction validates the number against its band so an invalid
/// channel can never enter a [`ControlAction`].
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Channel {
band: Band,
number: u16,
}
impl Channel {
/// Construct a validated channel, rejecting a number that is not valid in
/// its band.
pub fn new(band: Band, number: u16) -> Result<Self, ControlError> {
let valid = match band {
Band::Ghz24 => (1..=14).contains(&number),
Band::Ghz5 => GHZ5_CHANNELS.contains(&number),
// Wi-Fi 6E channels are the odd numbers 1..=233.
Band::Ghz6 => (1..=233).contains(&number) && number % 2 == 1,
};
if valid {
Ok(Self { band, number })
} else {
Err(ControlError::InvalidChannel { band, number })
}
}
/// The band this channel is in.
#[must_use]
pub fn band(&self) -> Band {
self.band
}
/// The channel number.
#[must_use]
pub fn number(&self) -> u16 {
self.number
}
}
/// A validated channel width. Wider widths probe more spectrum per sounding and
/// are treated as *more exploratory* by the controller.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Bandwidth {
/// 20 MHz.
Bw20,
/// 40 MHz.
Bw40,
/// 80 MHz.
Bw80,
/// 160 MHz.
Bw160,
/// 320 MHz (Wi-Fi 7).
Bw320,
}
impl Bandwidth {
/// Construct a bandwidth from a width in MHz, rejecting unrecognised widths.
pub fn from_mhz(mhz: u16) -> Result<Self, ControlError> {
Ok(match mhz {
20 => Self::Bw20,
40 => Self::Bw40,
80 => Self::Bw80,
160 => Self::Bw160,
320 => Self::Bw320,
other => return Err(ControlError::InvalidBandwidth { mhz: other }),
})
}
/// The width in MHz. Also the exploration-ordering key (wider = more
/// exploratory).
#[must_use]
pub fn mhz(&self) -> u16 {
match self {
Self::Bw20 => 20,
Self::Bw40 => 40,
Self::Bw80 => 80,
Self::Bw160 => 160,
Self::Bw320 => 320,
}
}
}
impl PartialOrd for Bandwidth {
fn partial_cmp(&self, other: &Self) -> Option<core::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl Ord for Bandwidth {
fn cmp(&self, other: &Self) -> core::cmp::Ordering {
self.mhz().cmp(&other.mhz())
}
}
/// A validated sounding cadence: the interval between solicited soundings, in
/// milliseconds. A *shorter* interval is a faster cadence and is treated as
/// *more exploratory* (more measurements per unit time).
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct Cadence {
interval_ms: u32,
}
impl Cadence {
/// Construct a cadence from a sounding interval, rejecting an interval
/// outside `[MIN_CADENCE_MS, MAX_CADENCE_MS]`.
pub fn from_interval_ms(interval_ms: u32) -> Result<Self, ControlError> {
if (MIN_CADENCE_MS..=MAX_CADENCE_MS).contains(&interval_ms) {
Ok(Self { interval_ms })
} else {
Err(ControlError::InvalidCadence {
interval_ms,
min: MIN_CADENCE_MS,
max: MAX_CADENCE_MS,
})
}
}
/// The sounding interval in milliseconds.
#[must_use]
pub fn interval_ms(&self) -> u32 {
self.interval_ms
}
}
/// A validated subset of a distributed aperture's antenna chains. Activating
/// *more* chains widens the aperture and is treated as *more exploratory*.
///
/// The selection is bounded by the ADR-280 `CoherentSensorGroup` compatibility
/// proof in a fielded system; here it is a validated, deterministic set of
/// chain indices with no coherence claim.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct AntennaSelection {
num_chains: u8,
/// Active chain indices, sorted ascending and deduplicated.
active: Vec<u8>,
}
impl AntennaSelection {
/// Construct a validated antenna selection over an aperture of
/// `num_chains` chains, rejecting an empty selection or any index at or
/// beyond `num_chains` / [`MAX_CHAINS`]. Indices are sorted and
/// deduplicated so the selection is canonical.
pub fn new(active: impl IntoIterator<Item = u8>, num_chains: u8) -> Result<Self, ControlError> {
if num_chains == 0 || num_chains > MAX_CHAINS {
return Err(ControlError::AntennaChainOutOfRange {
index: 0,
num_chains,
max: MAX_CHAINS,
});
}
let mut chains: Vec<u8> = active.into_iter().collect();
chains.sort_unstable();
chains.dedup();
if chains.is_empty() {
return Err(ControlError::EmptyAntennaSelection);
}
if let Some(&idx) = chains.iter().find(|&&i| i >= num_chains) {
return Err(ControlError::AntennaChainOutOfRange {
index: idx,
num_chains,
max: MAX_CHAINS,
});
}
Ok(Self {
num_chains,
active: chains,
})
}
/// The declared aperture size (total chains).
#[must_use]
pub fn num_chains(&self) -> u8 {
self.num_chains
}
/// The active chain indices (sorted, deduplicated).
#[must_use]
pub fn active(&self) -> &[u8] {
&self.active
}
/// The number of active chains. Also the exploration-ordering key (more
/// active chains = wider aperture = more exploratory).
#[must_use]
pub fn chain_count(&self) -> usize {
self.active.len()
}
}
/// A proposed measurement configuration: the controllable axes a controller
/// asks to set for the next sounding. Each axis is `None` when the deployment
/// cannot control it (it is left at the hardware default); a `Some` value is a
/// validated choice.
///
/// **This is a plan, never an emission.** Nothing here drives a radio, changes
/// pairing state, or transmits — an [`ControlAction`] is data describing what a
/// governed actuation *would* request through the ADR-280 fail-closed surface.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ControlAction {
/// Which channel to probe, if channel is controllable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub channel: Option<Channel>,
/// Which channel width to probe, if bandwidth is controllable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub bandwidth: Option<Bandwidth>,
/// How often to solicit a sounding, if cadence is controllable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cadence: Option<Cadence>,
/// Which antenna chains to activate, if antenna selection is controllable.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub antenna: Option<AntennaSelection>,
}
impl ControlAction {
/// True when no axis is set — the action configures nothing.
#[must_use]
pub fn is_noop(&self) -> bool {
self.channel.is_none()
&& self.bandwidth.is_none()
&& self.cadence.is_none()
&& self.antenna.is_none()
}
}
/// The set of control values a deployment can actually set, per axis
/// (capability-gated by the ADR-317 HAL in a fielded system). An axis with no
/// values is not controllable on this deployment; an all-empty capability is
/// the ESP32-style passive fallback trigger.
///
/// Values are validated, deduplicated, and sorted into
/// *least-exploratory-first* order at construction, so the controller can map a
/// scalar exploration level onto a value deterministically.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ControlCapability {
/// Controllable channels (probe order preserved as supplied, deduplicated).
pub channels: Vec<Channel>,
/// Controllable bandwidths, sorted ascending (narrowest first).
pub bandwidths: Vec<Bandwidth>,
/// Controllable cadences, sorted least-exploratory-first (slowest first).
pub cadences: Vec<Cadence>,
/// Controllable antenna selections, sorted least-exploratory-first
/// (fewest chains first).
pub antennas: Vec<AntennaSelection>,
}
impl ControlCapability {
/// An empty capability: nothing is controllable. The controller degrades to
/// the passive planner for any zone under this capability.
#[must_use]
pub fn none() -> Self {
Self::default()
}
/// Build a validated, canonicalised capability set. Each axis is
/// deduplicated, bounded to [`MAX_AXIS_VALUES`], and sorted into
/// least-exploratory-first order so exploration mapping is deterministic.
///
/// Channels keep caller order (deduplicated) because band/number has no
/// intrinsic exploration ranking — the controller sweeps them by cycle.
pub fn new(
channels: Vec<Channel>,
mut bandwidths: Vec<Bandwidth>,
mut cadences: Vec<Cadence>,
mut antennas: Vec<AntennaSelection>,
) -> Result<Self, ControlError> {
// Dedup channels while preserving first-seen order.
let mut seen = Vec::new();
let mut channels_dedup = Vec::new();
for c in channels {
if !seen.contains(&c) {
seen.push(c);
channels_dedup.push(c);
}
}
check_len(channels_dedup.len())?;
bandwidths.sort_unstable();
bandwidths.dedup();
check_len(bandwidths.len())?;
// Least exploratory first = slowest (largest interval) first.
cadences.sort_unstable_by(|a, b| b.interval_ms().cmp(&a.interval_ms()));
cadences.dedup();
check_len(cadences.len())?;
// Least exploratory first = fewest chains first; tie-break by indices.
antennas.sort_by(|a, b| {
a.chain_count()
.cmp(&b.chain_count())
.then_with(|| a.active().cmp(b.active()))
});
antennas.dedup();
check_len(antennas.len())?;
Ok(Self {
channels: channels_dedup,
bandwidths,
cadences,
antennas,
})
}
/// True when no axis has any controllable value.
#[must_use]
pub fn is_empty(&self) -> bool {
self.channels.is_empty()
&& self.bandwidths.is_empty()
&& self.cadences.is_empty()
&& self.antennas.is_empty()
}
}
fn check_len(len: usize) -> Result<(), ControlError> {
if len > MAX_AXIS_VALUES {
Err(ControlError::AxisTooLarge {
len,
max: MAX_AXIS_VALUES,
})
} else {
Ok(())
}
}
+404
View File
@@ -0,0 +1,404 @@
//! # `ruview-active` — closed-loop RF experiment control (ADR-306, ADR-297 primitive 9)
//!
//! **SYNTHETIC / L0 research-forward model scaffold (ADR-282, ADR-297 phase 3).**
//! This crate turns sensing from *RF-happens → observe* into
//! *RuView-controls-RF → observe the response → optimize the next measurement*.
//! It models the **control loop** ADR-280 deferred (ADR-280 built the governed
//! actuation surface but left information-gain-driven closed-loop control as a
//! roadmap item): read the current per-zone uncertainty and the last response,
//! and propose the controllable measurement configuration expected to reduce
//! that uncertainty most.
//!
//! It is a **simulation / planning model**, not a driver. Constructing a
//! [`ControlAction`] or a [`MeasurementPlan`] drives **no** radio, changes no
//! pairing state, and emits **no** RF — the loop *emits a plan*, and a fielded
//! caller submits every proposal through the ADR-280 fail-closed
//! admission/actuation surface. A twin predicts; it does not measure: no
//! `MEASURED`, accuracy, or traffic-reduction claim is made or implied. Every
//! exploration figure this crate produces is `SYNTHETIC` (CLAUDE.md honesty
//! discipline; ADR-306 §3).
//!
//! ## The four ADR-297 non-negotiable rules, as they bind this crate
//!
//! 1. **UNKNOWN is first-class, never an error.** A [`LastResponse::Unknown`]
//! is not zero uncertainty and not an error: the policy *widens* exploration
//! ([`ControllerConfig::unknown_widen`]) rather than committing to a narrow
//! configuration on a target it cannot currently resolve. A non-finite
//! uncertainty becomes maximal uncertainty, never a silent zero.
//! [`ClosedLoopController::step`] is total — no input panics.
//! 2. **Certificates bind cryptographically.** Out of scope here; a proposal
//! names an already-authenticated ADR-303
//! [`ZoneId`](ruview_ontology::ZoneId), and a fielded loop step is admitted
//! through the ADR-280 governed path before any actuation.
//! 3. **One canonical semantics downstream.** The loop reuses the canonical
//! [`ZoneId`](ruview_ontology::ZoneId),
//! [`EvidenceLevel`](ruview_ontology::EvidenceLevel), and
//! [`SemanticProvenance`](ruview_ontology::SemanticProvenance) rather than
//! reinventing per-crate identity/evidence shapes. It shares the *notion* of
//! expected gain with ADR-311 but defines its own [`ControlAction`]
//! vocabulary and does **not** depend on `ruview-infogain`, so the two crates
//! build in parallel.
//! 4. **Honest evidence.** Every proposal is stamped [`EvidenceLevel::L1`]
//! (heuristic/synthetic) with an explicit synthetic provenance; the
//! exploration scalar is a modelled magnitude, not a measured gain. When no
//! axis is controllable the controller returns [`PassiveReason::NoControllableAxes`]
//! rather than fabricating a gain estimate.
//!
//! ## The loop, in one call
//!
//! ```
//! use ruview_active::*;
//! use ruview_ontology::ZoneId;
//!
//! // A deployment that can vary channel width and antenna aperture.
//! let cap = ControlCapability::new(
//! vec![Channel::new(Band::Ghz5, 36).unwrap()],
//! vec![Bandwidth::Bw20, Bandwidth::Bw160],
//! vec![],
//! vec![
//! AntennaSelection::new([0], 4).unwrap(),
//! AntennaSelection::new([0, 1, 2, 3], 4).unwrap(),
//! ],
//! )
//! .unwrap();
//! let ctrl = ClosedLoopController::new(cap, ControllerConfig::default());
//!
//! // A poorly-known zone drives an exploratory (widest) measurement.
//! let uncertain = ZoneBelief::new(
//! ZoneId::new("kitchen").unwrap(),
//! Uncertainty::new(0.95),
//! LastResponse::None,
//! );
//! let decision = ctrl.step(&uncertain);
//! let p = decision.proposal().unwrap();
//! assert_eq!(p.intent, ControlIntent::Explore);
//! assert_eq!(p.action.bandwidth, Some(Bandwidth::Bw160)); // widest
//! ```
#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod control;
mod policy;
pub use control::{
AntennaSelection, Band, Bandwidth, Cadence, Channel, ControlAction, ControlCapability,
ControlError, MAX_AXIS_VALUES, MAX_CADENCE_MS, MAX_CHAINS, MIN_CADENCE_MS,
};
pub use policy::{
ClosedLoopController, ControlDecision, ControlIntent, ControlProposal, ControllerConfig,
LastResponse, MeasurementPlan, PassiveReason, Uncertainty, ZoneBelief, MODEL_VERSION,
};
#[cfg(test)]
mod tests {
use super::*;
use ruview_ontology::{EvidenceLevel, ZoneId};
fn zid(s: &str) -> ZoneId {
ZoneId::new(s).unwrap()
}
/// A deployment controlling channel (sweep set), bandwidth, cadence, and
/// antenna aperture — a full controllable surface for the loop tests.
fn full_capability() -> ControlCapability {
ControlCapability::new(
vec![
Channel::new(Band::Ghz5, 36).unwrap(),
Channel::new(Band::Ghz5, 40).unwrap(),
Channel::new(Band::Ghz5, 44).unwrap(),
],
vec![Bandwidth::Bw20, Bandwidth::Bw80, Bandwidth::Bw160],
vec![
Cadence::from_interval_ms(1000).unwrap(), // slow
Cadence::from_interval_ms(100).unwrap(), // fast
],
vec![
AntennaSelection::new([0], 4).unwrap(),
AntennaSelection::new([0, 1], 4).unwrap(),
AntennaSelection::new([0, 1, 2, 3], 4).unwrap(),
],
)
.unwrap()
}
fn controller() -> ClosedLoopController {
ClosedLoopController::new(full_capability(), ControllerConfig::default())
}
// ADR-306 §2: a high-uncertainty zone drives an exploratory control action
// — widest bandwidth, fastest cadence, widest aperture, Explore intent.
#[test]
fn high_uncertainty_drives_exploratory_action() {
let belief = ZoneBelief::new(zid("kitchen"), Uncertainty::new(1.0), LastResponse::None);
let p = controller().step(&belief).proposal().cloned().unwrap();
assert_eq!(p.intent, ControlIntent::Explore);
assert_eq!(p.action.bandwidth, Some(Bandwidth::Bw160)); // widest available
assert_eq!(p.action.cadence.unwrap().interval_ms(), 100); // fastest
assert_eq!(p.action.antenna.as_ref().unwrap().chain_count(), 4); // widest aperture
assert_eq!(p.evidence_level, EvidenceLevel::L1); // honest synthetic label
assert_eq!(p.provenance.model_version, MODEL_VERSION);
}
// ADR-306 validation: convergence (falling uncertainty) reduces exploration
// — the proposal narrows and the intent flips to Exploit.
#[test]
fn convergence_reduces_exploration() {
let ctrl = controller();
let high = ZoneBelief::new(zid("z"), Uncertainty::new(0.95), LastResponse::None);
let low = ZoneBelief::new(zid("z"), Uncertainty::new(0.05), LastResponse::None);
let ph = ctrl.step(&high).proposal().cloned().unwrap();
let pl = ctrl.step(&low).proposal().cloned().unwrap();
// Exploration strictly falls as the zone converges.
assert!(pl.exploration < ph.exploration);
assert_eq!(ph.intent, ControlIntent::Explore);
assert_eq!(pl.intent, ControlIntent::Exploit);
// The converged proposal is narrower/slower on every graded axis.
assert!(pl.action.bandwidth.unwrap().mhz() < ph.action.bandwidth.unwrap().mhz());
assert!(
pl.action.cadence.unwrap().interval_ms() > ph.action.cadence.unwrap().interval_ms()
);
assert!(
pl.action.antenna.as_ref().unwrap().chain_count()
< ph.action.antenna.as_ref().unwrap().chain_count()
);
assert_eq!(pl.action.bandwidth, Some(Bandwidth::Bw20)); // narrowest
}
// ADR-297 rule 1: an UNKNOWN last response widens exploration relative to
// the same uncertainty with an observed response.
#[test]
fn unknown_last_response_widens_exploration() {
let ctrl = controller();
let u = Uncertainty::new(0.4); // below the 0.5 explore threshold on its own
let observed = ZoneBelief::new(
zid("z"),
u,
LastResponse::Observed {
evidence_level: EvidenceLevel::L2,
residual: Uncertainty::new(0.4),
},
);
let unknown = ZoneBelief::new(zid("z"), u, LastResponse::Unknown);
let po = ctrl.step(&observed).proposal().cloned().unwrap();
let pu = ctrl.step(&unknown).proposal().cloned().unwrap();
// UNKNOWN pushes exploration strictly higher...
assert!(pu.exploration > po.exploration);
// ...enough to cross from Exploit into Explore (0.4 + 0.3 = 0.7 >= 0.5).
assert_eq!(po.intent, ControlIntent::Exploit);
assert_eq!(pu.intent, ControlIntent::Explore);
}
// ADR-306 §2 degradation: an empty controllable set (ESP32-only) falls back
// to the passive planner with no error and no fabricated gain.
#[test]
fn empty_capability_degrades_to_passive() {
let ctrl = ClosedLoopController::new(ControlCapability::none(), ControllerConfig::default());
let belief = ZoneBelief::new(zid("z"), Uncertainty::new(1.0), LastResponse::None);
match ctrl.step(&belief) {
ControlDecision::Passive { zone, reason } => {
assert_eq!(zone, zid("z"));
assert_eq!(reason, PassiveReason::NoControllableAxes);
}
other => panic!("expected passive fallback, got {other:?}"),
}
}
// A cadence-only deployment (ESP32 that can vary sounding rate) still closes
// the loop on its one controllable axis; the others stay uncontrolled.
#[test]
fn cadence_only_capability_controls_only_cadence() {
let cap = ControlCapability::new(
vec![],
vec![],
vec![
Cadence::from_interval_ms(2000).unwrap(),
Cadence::from_interval_ms(50).unwrap(),
],
vec![],
)
.unwrap();
let ctrl = ClosedLoopController::new(cap, ControllerConfig::default());
let belief = ZoneBelief::new(zid("z"), Uncertainty::new(1.0), LastResponse::None);
let p = ctrl.step(&belief).proposal().cloned().unwrap();
assert!(p.action.channel.is_none());
assert!(p.action.bandwidth.is_none());
assert!(p.action.antenna.is_none());
assert_eq!(p.action.cadence.unwrap().interval_ms(), 50); // fastest, exploring
assert!(!p.action.is_noop());
}
// Control ranges are validated: an invalid channel/bandwidth/cadence/antenna
// is rejected at the boundary and can never enter an action.
#[test]
fn invalid_control_values_are_rejected() {
// 2.4 GHz has no channel 15.
assert!(matches!(
Channel::new(Band::Ghz24, 15),
Err(ControlError::InvalidChannel { .. })
));
// 5 GHz channel 37 is not a standard channel.
assert!(matches!(
Channel::new(Band::Ghz5, 37),
Err(ControlError::InvalidChannel { .. })
));
// A valid 5 GHz channel is accepted.
assert!(Channel::new(Band::Ghz5, 36).is_ok());
// 33 MHz is not a recognised channel width.
assert!(matches!(
Bandwidth::from_mhz(33),
Err(ControlError::InvalidBandwidth { mhz: 33 })
));
assert_eq!(Bandwidth::from_mhz(80).unwrap(), Bandwidth::Bw80);
// Cadence outside the modelled range is rejected on both ends.
assert!(matches!(
Cadence::from_interval_ms(0),
Err(ControlError::InvalidCadence { .. })
));
assert!(matches!(
Cadence::from_interval_ms(MAX_CADENCE_MS + 1),
Err(ControlError::InvalidCadence { .. })
));
// Antenna selection: empty and out-of-range indices are rejected.
assert!(matches!(
AntennaSelection::new(Vec::<u8>::new(), 4),
Err(ControlError::EmptyAntennaSelection)
));
assert!(matches!(
AntennaSelection::new([4], 4),
Err(ControlError::AntennaChainOutOfRange { index: 4, .. })
));
// A zero-chain aperture is rejected.
assert!(matches!(
AntennaSelection::new([0], 0),
Err(ControlError::AntennaChainOutOfRange { .. })
));
}
// The capability set bounds allocation: an axis longer than MAX_AXIS_VALUES
// is rejected rather than accepted unbounded.
#[test]
fn oversized_capability_axis_is_rejected() {
let cadences: Vec<Cadence> = (1..=(MAX_AXIS_VALUES as u32 + 1))
.map(|ms| Cadence::from_interval_ms(ms).unwrap())
.collect();
assert!(matches!(
ControlCapability::new(vec![], vec![], cadences, vec![]),
Err(ControlError::AxisTooLarge { .. })
));
}
// Non-finite uncertainty is treated as maximal uncertainty, never a silent
// zero (ADR-297 rule 1), and never panics.
#[test]
fn non_finite_uncertainty_is_maximal_not_zero() {
assert_eq!(Uncertainty::new(f64::NAN).value(), 1.0);
assert_eq!(Uncertainty::new(f64::INFINITY).value(), 1.0);
assert_eq!(Uncertainty::new(-5.0).value(), 0.0);
assert_eq!(Uncertainty::new(2.0).value(), 1.0);
let belief = ZoneBelief::new(zid("z"), Uncertainty::new(f64::NAN), LastResponse::None);
let p = controller().step(&belief).proposal().cloned().unwrap();
assert_eq!(p.intent, ControlIntent::Explore); // maximal → explore
}
// Channel sweep: exploring across cycles rotates deterministically through
// the controllable channels; exploiting anchors to the first channel.
#[test]
fn channel_sweeps_when_exploring_and_anchors_when_exploiting() {
let ctrl = controller();
let mk = |cycle: u64| {
ZoneBelief::new(zid("z"), Uncertainty::new(1.0), LastResponse::None).with_cycle(cycle)
};
let c0 = ctrl.step(&mk(0)).proposal().unwrap().action.channel.unwrap();
let c1 = ctrl.step(&mk(1)).proposal().unwrap().action.channel.unwrap();
let c2 = ctrl.step(&mk(2)).proposal().unwrap().action.channel.unwrap();
let c3 = ctrl.step(&mk(3)).proposal().unwrap().action.channel.unwrap();
assert_eq!(c0.number(), 36);
assert_eq!(c1.number(), 40);
assert_eq!(c2.number(), 44);
assert_eq!(c3.number(), 36); // wraps deterministically
// Exploiting (low uncertainty) anchors to the first channel regardless
// of cycle.
let exploit = ZoneBelief::new(zid("z"), Uncertainty::new(0.0), LastResponse::None)
.with_cycle(2);
let ce = ctrl.step(&exploit).proposal().unwrap().action.channel.unwrap();
assert_eq!(ce.number(), 36);
}
// plan() orders proposals most-exploratory-first and separates passive
// zones; the ordering is a deterministic function of the inputs.
#[test]
fn plan_orders_by_exploration_and_collects_passive() {
let ctrl = controller();
let beliefs = vec![
ZoneBelief::new(zid("low"), Uncertainty::new(0.1), LastResponse::None),
ZoneBelief::new(zid("high"), Uncertainty::new(0.9), LastResponse::None),
ZoneBelief::new(zid("mid"), Uncertainty::new(0.5), LastResponse::None),
];
let plan = ctrl.plan(&beliefs);
let order: Vec<&str> = plan.proposals.iter().map(|p| p.zone.as_str()).collect();
assert_eq!(order, vec!["high", "mid", "low"]);
assert!(plan.passive.is_empty());
// With an empty capability every zone degrades to passive.
let passive_ctrl =
ClosedLoopController::new(ControlCapability::none(), ControllerConfig::default());
let plan2 = passive_ctrl.plan(&beliefs);
assert!(plan2.proposals.is_empty());
assert_eq!(plan2.passive, vec![zid("high"), zid("low"), zid("mid")]);
}
// Determinism: identical inputs yield identical decisions and plans.
#[test]
fn controller_is_deterministic() {
let ctrl = controller();
let beliefs = vec![
ZoneBelief::new(zid("a"), Uncertainty::new(0.7), LastResponse::Unknown).with_cycle(3),
ZoneBelief::new(
zid("b"),
Uncertainty::new(0.2),
LastResponse::Observed {
evidence_level: EvidenceLevel::L3,
residual: Uncertainty::new(0.2),
},
),
];
assert_eq!(ctrl.plan(&beliefs), ctrl.plan(&beliefs));
assert_eq!(ctrl.step(&beliefs[0]), ctrl.step(&beliefs[0]));
}
// The whole plan round-trips losslessly through serde (canonical output).
#[test]
fn plan_serde_round_trips() {
let ctrl = controller();
let beliefs = vec![
ZoneBelief::new(zid("a"), Uncertainty::new(0.9), LastResponse::None),
ZoneBelief::new(zid("b"), Uncertainty::new(0.1), LastResponse::Unknown),
];
let plan = ctrl.plan(&beliefs);
let json = serde_json::to_string(&plan).unwrap();
let back: MeasurementPlan = serde_json::from_str(&json).unwrap();
assert_eq!(plan, back);
}
// An empty belief set yields an empty plan, no panic.
#[test]
fn empty_beliefs_yield_empty_plan() {
let plan = controller().plan(&[]);
assert!(plan.is_empty());
assert_eq!(plan, MeasurementPlan::empty());
}
}
+377
View File
@@ -0,0 +1,377 @@
//! The closed-loop experiment controller (ADR-306 §2).
//!
//! **SYNTHETIC / L0 model scaffold.** This is the *loop* ADR-280 deferred: it
//! reads a modelled per-zone uncertainty and the last modelled response, and
//! proposes the next controllable measurement configuration expected to reduce
//! that uncertainty most. It is an information-driven **planning** policy — it
//! shares the *notion* of expected gain with ADR-311 but defines its own
//! control vocabulary and takes **no** dependency on `ruview-infogain`, so the
//! two crates build in parallel.
//!
//! No number here is `MEASURED`. Every exploration level is a modelled
//! magnitude, not a measured information gain (CLAUDE.md honesty discipline).
//! The controller **emits a plan; it never emits RF** and never bypasses the
//! ADR-280 governed admission/actuation surface — a fielded caller submits each
//! proposal through that fail-closed path.
//!
//! ## First-class UNKNOWN (ADR-297 rule 1)
//!
//! The last response is [`LastResponse::Unknown`] whenever the previous
//! solicited measurement returned nothing interpretable. UNKNOWN is **not**
//! zero uncertainty and **not** an error: the policy *widens* exploration by
//! [`ControllerConfig::unknown_widen`] rather than committing to a narrow,
//! exploitative configuration on a target it cannot currently resolve.
use serde::{Deserialize, Serialize};
use ruview_ontology::{EvidenceLevel, SemanticProvenance, ZoneId};
use crate::control::{ControlAction, ControlCapability};
/// A modelled uncertainty scalar in `[0, 1]`: `0.0` fully resolved, `1.0`
/// maximally uncertain. Construction clamps to range and maps a non-finite
/// input to maximal uncertainty (an unusable estimate is treated as "know
/// nothing", never silently as zero).
#[derive(Clone, Copy, Debug, PartialEq, PartialOrd, Serialize, Deserialize)]
#[serde(transparent)]
pub struct Uncertainty(f64);
impl Uncertainty {
/// Clamp an arbitrary value into `[0, 1]`; a non-finite value becomes
/// maximal uncertainty (`1.0`).
#[must_use]
pub fn new(value: f64) -> Self {
if value.is_finite() {
Self(value.clamp(0.0, 1.0))
} else {
Self(1.0)
}
}
/// The clamped scalar value.
#[must_use]
pub fn value(self) -> f64 {
self.0
}
}
/// The outcome of the previous solicited measurement for a zone.
///
/// This reuses the canonical [`EvidenceLevel`] vocabulary rather than a
/// per-crate grade (ADR-297 rule 3). A fielded caller derives it from the
/// ADR-303 [`Observation`](ruview_ontology::Observation) the sounding produced.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum LastResponse {
/// An interpretable response arrived at the given evidence level, leaving a
/// modelled residual uncertainty.
Observed {
/// The canonical evidence level of the response.
evidence_level: EvidenceLevel,
/// Modelled residual uncertainty left by the response.
residual: Uncertainty,
},
/// The last solicited measurement returned nothing interpretable — a
/// first-class UNKNOWN, not an error and not zero uncertainty.
Unknown,
/// No measurement has been solicited yet (loop start).
None,
}
/// The modelled belief about a single controllable target zone, and the input
/// to one closed-loop [`ClosedLoopController::step`].
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ZoneBelief {
/// The target zone (canonical ontology id, ADR-303).
pub zone: ZoneId,
/// Current modelled uncertainty about the zone.
pub uncertainty: Uncertainty,
/// The outcome of the previous solicited measurement.
pub last_response: LastResponse,
/// A deterministic loop counter used only to sweep channels across cycles.
/// Injected by the caller — never sampled from a clock (ADR-297 §rules).
#[serde(default)]
pub cycle: u64,
}
impl ZoneBelief {
/// Construct a belief. `cycle` defaults to `0`.
#[must_use]
pub fn new(zone: ZoneId, uncertainty: Uncertainty, last_response: LastResponse) -> Self {
Self {
zone,
uncertainty,
last_response,
cycle: 0,
}
}
/// Builder-style setter for the deterministic sweep cycle.
#[must_use]
pub fn with_cycle(mut self, cycle: u64) -> Self {
self.cycle = cycle;
self
}
}
/// Whether a proposal is exploratory (widen to resolve a poorly-known zone) or
/// exploitative (narrow, concentrate on a well-known zone).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ControlIntent {
/// Widen the measurement to resolve high uncertainty.
Explore,
/// Narrow the measurement to exploit an already-resolved zone.
Exploit,
}
/// Why the controller could not propose a controllable action and fell back to
/// the passive planner (ADR-306 §2 degradation).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PassiveReason {
/// The deployment exposes no controllable axis (e.g. ESP32-only). The
/// controller defers to the ADR-280 staleness planner rather than
/// fabricating a gain estimate.
NoControllableAxes,
}
/// A proposed governed measurement for one zone.
///
/// The `exploration` scalar is a **SYNTHETIC** modelled magnitude, never a
/// measured information gain, and the proposal carries L1 (heuristic/synthetic)
/// evidence with an explicit provenance so no projection can silently upgrade
/// it.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ControlProposal {
/// The target zone.
pub zone: ZoneId,
/// The controllable configuration to request (a plan, not an emission).
pub action: ControlAction,
/// Explore vs exploit.
pub intent: ControlIntent,
/// Modelled exploration level in `[0, 1]` (SYNTHETIC; not a measurement).
pub exploration: f64,
/// Honest evidence label for the proposal — always L1 (synthetic model).
pub evidence_level: EvidenceLevel,
/// Provenance tagging the proposal as a synthetic model output.
pub provenance: SemanticProvenance,
}
/// The controller's decision for one zone: either a governed proposal or a
/// passive fallback.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ControlDecision {
/// Request a controllable measurement.
Actuate(ControlProposal),
/// No controllable axis — defer to the passive planner.
Passive {
/// The zone that could not be actively controlled.
zone: ZoneId,
/// Why the fallback occurred.
reason: PassiveReason,
},
}
impl ControlDecision {
/// The proposal, if this decision is an actuation.
#[must_use]
pub fn proposal(&self) -> Option<&ControlProposal> {
match self {
Self::Actuate(p) => Some(p),
Self::Passive { .. } => None,
}
}
}
/// A full measurement plan over several zones, ordered most-uncertain-first.
///
/// It is a pure planning artifact: it starts no sounding and touches no
/// hardware. Zones with no controllable axis are recorded in
/// [`MeasurementPlan::passive`] so the caller knows to route them to the
/// staleness planner instead.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct MeasurementPlan {
/// Governed proposals, ordered by descending exploration then by zone id.
pub proposals: Vec<ControlProposal>,
/// Zones that degraded to the passive planner.
pub passive: Vec<ZoneId>,
}
impl MeasurementPlan {
/// An empty plan.
#[must_use]
pub fn empty() -> Self {
Self::default()
}
/// True when the plan contains neither a proposal nor a passive zone.
#[must_use]
pub fn is_empty(&self) -> bool {
self.proposals.is_empty() && self.passive.is_empty()
}
}
/// Configuration for the closed-loop controller. All knobs are deployment
/// choices; there is no wall clock and no randomness.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct ControllerConfig {
/// Exploration level at or above which a proposal is [`ControlIntent::Explore`]
/// (below it, [`ControlIntent::Exploit`]). In `[0, 1]`.
pub explore_threshold: f64,
/// Additive widening applied to the exploration level when the last
/// response was [`LastResponse::Unknown`]. Bounded into `[0, 1]` after
/// application (ADR-297 rule 1: UNKNOWN widens rather than commits).
pub unknown_widen: f64,
}
impl Default for ControllerConfig {
fn default() -> Self {
Self {
explore_threshold: 0.5,
unknown_widen: 0.3,
}
}
}
/// The synthetic model version stamped onto every proposal's provenance.
pub const MODEL_VERSION: &str = "ruview-active@synthetic-l0";
/// The closed-loop RF experiment controller (ADR-306).
///
/// Holds the controllable [`ControlCapability`] of the deployment and the
/// policy [`ControllerConfig`]. [`ClosedLoopController::step`] is a total,
/// deterministic function of its inputs: identical inputs always yield an
/// identical decision, and no input panics.
#[derive(Clone, Debug)]
pub struct ClosedLoopController {
capability: ControlCapability,
config: ControllerConfig,
}
impl ClosedLoopController {
/// Build a controller over a deployment's controllable capability set.
#[must_use]
pub fn new(capability: ControlCapability, config: ControllerConfig) -> Self {
Self { capability, config }
}
/// The controllable capability set.
#[must_use]
pub fn capability(&self) -> &ControlCapability {
&self.capability
}
/// One closed-loop step for a single zone: read the belief, compute the
/// modelled exploration level, and propose the next controllable
/// configuration — or fall back to the passive planner when nothing is
/// controllable.
#[must_use]
pub fn step(&self, belief: &ZoneBelief) -> ControlDecision {
if self.capability.is_empty() {
return ControlDecision::Passive {
zone: belief.zone.clone(),
reason: PassiveReason::NoControllableAxes,
};
}
let exploration = self.exploration_level(belief);
let explore = exploration >= self.config.explore_threshold;
let intent = if explore {
ControlIntent::Explore
} else {
ControlIntent::Exploit
};
let action = self.select_action(belief, exploration, explore);
ControlDecision::Actuate(ControlProposal {
zone: belief.zone.clone(),
action,
intent,
exploration,
evidence_level: EvidenceLevel::L1,
provenance: SemanticProvenance::declared(MODEL_VERSION),
})
}
/// Plan across several zones. Each zone is stepped; proposals are ordered
/// most-exploratory-first (tie-break by zone id) so the scarcest budget is
/// spent where uncertainty is highest, and passive zones are collected
/// separately.
#[must_use]
pub fn plan(&self, beliefs: &[ZoneBelief]) -> MeasurementPlan {
let mut proposals = Vec::new();
let mut passive = Vec::new();
for belief in beliefs {
match self.step(belief) {
ControlDecision::Actuate(p) => proposals.push(p),
ControlDecision::Passive { zone, .. } => passive.push(zone),
}
}
// Deterministic ordering: descending exploration, then ascending zone id.
proposals.sort_by(|a, b| {
b.exploration
.partial_cmp(&a.exploration)
.unwrap_or(core::cmp::Ordering::Equal)
.then_with(|| a.zone.as_str().cmp(b.zone.as_str()))
});
passive.sort();
MeasurementPlan { proposals, passive }
}
/// The modelled exploration level for a belief: driven by current
/// uncertainty, widened when the last response was UNKNOWN.
fn exploration_level(&self, belief: &ZoneBelief) -> f64 {
let base = belief.uncertainty.value();
let e = match &belief.last_response {
// UNKNOWN response: widen exploration rather than commit.
LastResponse::Unknown => base + self.config.unknown_widen.max(0.0),
LastResponse::Observed { .. } | LastResponse::None => base,
};
e.clamp(0.0, 1.0)
}
/// Map the exploration level onto a controllable action across the axes the
/// deployment exposes. Uncontrollable axes stay `None`.
fn select_action(&self, belief: &ZoneBelief, exploration: f64, explore: bool) -> ControlAction {
let cap = &self.capability;
// Channel: sweep across cycles when exploring; anchor to the first
// channel when exploiting. Categorical axis, no exploration grading.
let channel = if cap.channels.is_empty() {
None
} else if explore {
let idx = (belief.cycle as usize) % cap.channels.len();
Some(cap.channels[idx])
} else {
Some(cap.channels[0])
};
// Graded axes: capability vectors are sorted least-exploratory-first,
// so a higher exploration level selects a wider / faster value.
let bandwidth = graded_pick(&cap.bandwidths, exploration).copied();
let cadence = graded_pick(&cap.cadences, exploration).copied();
let antenna = graded_pick(&cap.antennas, exploration).cloned();
ControlAction {
channel,
bandwidth,
cadence,
antenna,
}
}
}
/// Pick from a least-exploratory-first slice by mapping an exploration level in
/// `[0, 1]` onto an index. Returns `None` for an empty slice.
fn graded_pick<T>(values: &[T], exploration: f64) -> Option<&T> {
if values.is_empty() {
return None;
}
let e = exploration.clamp(0.0, 1.0);
let last = values.len() - 1;
let idx = (e * last as f64).round() as usize;
values.get(idx.min(last))
}
@@ -0,0 +1,17 @@
[package]
name = "ruview-counterfactual"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
thiserror.workspace = true
serde = { workspace = true, features = ["derive"] }
ruview-ontology = { path = "../ruview-ontology" }
ruview-fusion = { path = "../ruview-fusion" }
ruview-twin = { path = "../ruview-twin" }
[dev-dependencies]
serde_json.workspace = true
@@ -0,0 +1,150 @@
//! Scene hypotheses over the canonical ontology (ADR-310 §1).
//!
//! **SYNTHETIC / L0 — a research-forward model scaffold, not a measurement
//! system.** A [`Hypothesis`] is a *hypothesized* scene state — an occupant
//! count and their coarse positions — expressed over the ADR-303 canonical
//! [`SpaceId`] so a counterfactual result is a governed spatial statement, not
//! an opaque score. Nothing here is a hardware, `MEASURED`, or accuracy claim,
//! and this crate asserts **no** discrimination-accuracy number (ADR-310
//! evidence discipline).
//!
//! Hypotheses are drawn from (and score *relative to*) the ADR-308 fused world
//! state and its neighbourhood: the current estimate, the **null hypothesis**
//! (nobody present, [`Hypothesis::empty`]), and a bounded set of nearby
//! alternatives (±1 occupant, shifted position). Positions are a coarse metric
//! abstraction in the twin's local frame, not surveyed coordinates.
use serde::{Deserialize, Serialize};
use thiserror::Error;
use ruview_ontology::SpaceId;
/// Upper bound on occupants in a single hypothesis. Bounds allocation and the
/// per-link scoring cost on untrusted input; construction beyond this is
/// rejected, never truncated.
pub const MAX_OCCUPANTS: usize = 64;
/// Upper bound on hypotheses evaluated in one call. Bounds allocation on
/// untrusted input.
pub const MAX_HYPOTHESES: usize = 256;
/// One hypothesized occupant at a coarse metric position in the twin's frame.
///
/// **SYNTHETIC.** An occupant is modelled by the counterfactual layer as a body
/// that attenuates any link whose line of sight passes near it (see
/// [`crate::infer`]); it is a hypothesis element, never evidence of a person.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Occupant {
/// Coarse `x` position, metres, in the twin's local frame.
pub x: f64,
/// Coarse `y` position, metres, in the twin's local frame.
pub y: f64,
}
impl Occupant {
/// Construct an occupant at a coarse position.
#[must_use]
pub const fn new(x: f64, y: f64) -> Self {
Self { x, y }
}
/// The horizontal-plane position `(x, y)` used for link-blocking geometry.
#[must_use]
pub const fn xy(&self) -> (f64, f64) {
(self.x, self.y)
}
/// True when both coordinates are finite (rejects `NaN`/`inf`).
#[must_use]
pub fn is_finite(&self) -> bool {
self.x.is_finite() && self.y.is_finite()
}
}
/// A hypothesized scene state: how many occupants are present and where.
///
/// **SYNTHETIC / L0.** The occupant count is `occupants.len()`; the **null
/// hypothesis** ([`Hypothesis::empty`]) is an empty occupant set — *nobody
/// present*. The [`id`](Self::id) is a stable label used both for reporting the
/// best explanation and as a deterministic tie-break in ranking.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Hypothesis {
/// Stable, caller-supplied label (e.g. `"empty"`, `"one"`, `"two"`). Used to
/// report the best explanation and to break ranking ties deterministically.
pub id: String,
/// The ontology space this hypothesis is over (governed spatial statement).
pub space: SpaceId,
/// The hypothesized occupants; `occupants.len()` is the occupant count. An
/// empty vector is the null (nobody-present) hypothesis.
pub occupants: Vec<Occupant>,
}
impl Hypothesis {
/// The **null hypothesis**: nobody present in `space`.
#[must_use]
pub fn empty(id: impl Into<String>, space: SpaceId) -> Self {
Self {
id: id.into(),
space,
occupants: Vec::new(),
}
}
/// Construct and validate a hypothesis. Rejects non-finite occupant
/// positions and occupant counts above [`MAX_OCCUPANTS`] at the boundary;
/// never panics on malformed input.
pub fn new(
id: impl Into<String>,
space: SpaceId,
occupants: Vec<Occupant>,
) -> Result<Self, HypothesisError> {
if occupants.len() > MAX_OCCUPANTS {
return Err(HypothesisError::TooManyOccupants {
len: occupants.len(),
max: MAX_OCCUPANTS,
});
}
for (i, occ) in occupants.iter().enumerate() {
if !occ.is_finite() {
return Err(HypothesisError::NonFinitePosition { index: i });
}
}
Ok(Self {
id: id.into(),
space,
occupants,
})
}
/// The hypothesized occupant count.
#[must_use]
pub fn occupant_count(&self) -> usize {
self.occupants.len()
}
/// True when this is the null (nobody-present) hypothesis.
#[must_use]
pub fn is_null(&self) -> bool {
self.occupants.is_empty()
}
}
/// Boundary errors from constructing a hypothesis. Malformed input yields one of
/// these; it never panics.
#[derive(Clone, Debug, PartialEq, Eq, Error)]
pub enum HypothesisError {
/// An occupant carried a non-finite coordinate.
#[error("non-finite occupant position at index {index}")]
NonFinitePosition {
/// Offending occupant index.
index: usize,
},
/// More occupants than [`MAX_OCCUPANTS`].
#[error("too many occupants: {len} exceeds maximum {max}")]
TooManyOccupants {
/// Actual count.
len: usize,
/// The enforced maximum.
max: usize,
},
}
@@ -0,0 +1,483 @@
//! Counterfactual scoring and best-explanation selection (ADR-310 §2, §3).
//!
//! **SYNTHETIC / L0 — a research-forward generative-scoring scaffold, not a
//! measurement system.** This module scores a small set of scene
//! [`Hypothesis`](crate::Hypothesis) against an observed link-measurement set,
//! using the ADR-312 [`RfTwin`] as the generative forward model. It is a
//! *consumer* of the twin, not a second simulator: the twin supplies the
//! baseline expected distribution per link (geometry + propagation), and this
//! layer applies a **documented SYNTHETIC occupant-attenuation model** on top —
//! a hypothesized occupant attenuates any link whose line of sight passes near
//! it. Nothing here is a hardware, `MEASURED`, or accuracy claim; this crate
//! asserts **no** discrimination-accuracy number (ADR-310 evidence discipline).
//!
//! ## Likelihood (documented SYNTHETIC)
//!
//! For each observed link with a known base distribution `N(m0, v0)` from the
//! twin, the occupant-adjusted distribution under a hypothesis is
//! `N(m0 - att, v0 + extra)`, where `att`/`extra` accumulate a linear-falloff
//! body effect over occupants that block the link. The per-link explanatory
//! score is the Gaussian log-likelihood of the observed value under that
//! adjusted distribution; a hypothesis's score is the sum over evaluated links.
//! This is a deliberately simple, deterministic model — clearly a scaffold, not
//! real RF.
//!
//! ## UNKNOWN is first-class (ADR-297 rule 1, ADR-310 §3)
//!
//! The layer never forces a label. It returns [`BestExplanation::Unknown`] when
//! the top two hypotheses are near-indistinguishable (margin below threshold),
//! when **no** hypothesis explains the observation well (best mean per-link
//! log-likelihood below a floor — the observation is outside what the twin can
//! account for, routed to the ADR-299 UNKNOWN verdict rather than a forced
//! occupancy label), when no hypotheses are supplied, or when no observed link
//! is evaluable against the twin.
use serde::{Deserialize, Serialize};
use ruview_ontology::{EvidenceLevel, SemanticProvenance};
use ruview_twin::{
ExpectedDistribution, LinkId, LinkObservation, ObservationSet, RfTwin,
};
use crate::hypothesis::Hypothesis;
/// `2π`, used in the Gaussian log-likelihood normaliser.
const TAU: f64 = std::f64::consts::TAU;
/// The SYNTHETIC occupant → link effect. A hypothesized occupant within
/// [`body_radius_m`](Self::body_radius_m) of a link's line of sight attenuates
/// it (and adds variance), with a linear falloff to zero at the radius edge.
///
/// **SYNTHETIC.** These are model parameters of a didactic occupant model, not a
/// calibrated RF body-shadowing fit.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct OccupantModel {
/// Perpendicular distance (metres) within which an occupant blocks a link.
/// Must be finite and `> 0`.
pub body_radius_m: f64,
/// Maximum attenuation (dB) added to a directly-blocked link (falloff → 0 at
/// the radius edge). Must be finite and `>= 0`.
pub attenuation_db: f64,
/// Maximum extra variance (dB²) added to a directly-blocked link's expected
/// distribution. Must be finite and `>= 0`.
pub extra_variance_db2: f64,
}
impl OccupantModel {
/// A neutral SYNTHETIC default: `0.9 m` body radius, `6 dB` peak
/// attenuation, `4 dB²` peak extra variance. Asserts nothing about any real
/// body or environment.
#[must_use]
pub fn default_body() -> Self {
Self {
body_radius_m: 0.9,
attenuation_db: 6.0,
extra_variance_db2: 4.0,
}
}
/// True when every parameter is in its valid domain.
#[must_use]
pub fn is_valid(&self) -> bool {
self.body_radius_m.is_finite()
&& self.body_radius_m > 0.0
&& self.attenuation_db.is_finite()
&& self.attenuation_db >= 0.0
&& self.extra_variance_db2.is_finite()
&& self.extra_variance_db2 >= 0.0
}
}
impl Default for OccupantModel {
fn default() -> Self {
Self::default_body()
}
}
/// Thresholds that route a scored hypothesis set to a best explanation or to a
/// first-class UNKNOWN verdict (ADR-310 §3).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct ScoringConfig {
/// Minimum log-likelihood-ratio margin (nats) between the top two
/// hypotheses required to declare a best explanation; below this the two are
/// near-indistinguishable and the result is UNKNOWN. Must be finite `>= 0`.
pub min_margin_nats: f64,
/// Minimum best *mean per-link* log-likelihood (nats) required for any
/// hypothesis to count as explaining the observation; below this the
/// observation is outside what the twin can account for and the result is
/// UNKNOWN (routed to the ADR-299 verdict). Must be finite.
pub min_mean_log_likelihood: f64,
}
impl ScoringConfig {
/// Neutral SYNTHETIC defaults: a `0.5`-nat margin and a `-10.0`-nat mean
/// per-link floor. These are model gates, not calibrated error rates.
#[must_use]
pub fn default_gates() -> Self {
Self {
min_margin_nats: 0.5,
min_mean_log_likelihood: -10.0,
}
}
}
impl Default for ScoringConfig {
fn default() -> Self {
Self::default_gates()
}
}
/// The explanatory score of one hypothesis against the observed measurements.
///
/// **SYNTHETIC / L0.** `log_likelihood` is a model-relative explanatory score
/// (summed Gaussian log-likelihood under the twin + occupant model), never a
/// detection or accuracy claim.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct HypothesisScore {
/// The scored hypothesis.
pub hypothesis: Hypothesis,
/// Summed per-link Gaussian log-likelihood over evaluated links (nats).
/// Higher ⇒ this hypothesis better explains the observation.
pub log_likelihood: f64,
/// Number of observed links evaluated against a known twin distribution.
pub evaluated_links: usize,
/// Number of observed links that could not be evaluated (unknown under the
/// twin, or a non-finite / undefined likelihood); first-class, never an
/// error.
pub unknown_links: usize,
}
impl HypothesisScore {
/// Mean per-link log-likelihood, or `None` when no link was evaluable.
#[must_use]
pub fn mean_log_likelihood(&self) -> Option<f64> {
(self.evaluated_links > 0).then(|| self.log_likelihood / self.evaluated_links as f64)
}
}
/// The best-explanation outcome. Either one hypothesis explains the observation
/// with a positive margin, or the result is a first-class UNKNOWN.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "outcome", rename_all = "snake_case")]
pub enum BestExplanation {
/// One hypothesis best explains the observation, by `margin` nats over the
/// runner-up (`f64::INFINITY` when it is the only hypothesis).
Explained {
/// The winning hypothesis's stable id.
hypothesis_id: String,
/// Its hypothesized occupant count.
occupant_count: usize,
/// Log-likelihood-ratio margin (nats) over the runner-up.
margin: f64,
},
/// No confident best explanation; carries a first-class reason (ADR-297
/// rule 1, ADR-310 §3).
Unknown {
/// Why the result is UNKNOWN.
reason: UnknownReason,
},
}
/// Why a counterfactual evaluation resolved to UNKNOWN instead of a confident
/// best explanation. UNKNOWN is a value, not an error.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "reason", rename_all = "snake_case")]
pub enum UnknownReason {
/// No hypotheses were supplied to evaluate.
NoHypotheses,
/// No observed link was evaluable against the twin (nothing to score).
NoEvaluableLinks,
/// The top two hypotheses are near-indistinguishable: the margin fell below
/// the configured threshold.
NearIndistinguishable {
/// The observed margin (nats).
margin: f64,
/// The configured minimum margin (nats).
threshold: f64,
},
/// No hypothesis explains the observation well: the best mean per-link
/// log-likelihood fell below the configured floor. The observation is
/// outside what the twin can account for (routes to the ADR-299 verdict).
NoHypothesisExplains {
/// The best hypothesis's mean per-link log-likelihood (nats).
best_mean_log_likelihood: f64,
/// The configured floor (nats).
floor: f64,
},
}
/// The typed result of a counterfactual evaluation.
///
/// **SYNTHETIC / L0.** Every score and the verdict are model-relative and
/// inherit the twin's `L0` evidence level; nothing here is a camera-grade or
/// `MEASURED` claim (CLAUDE.md honesty rule, ADR-310 evidence discipline).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct CounterfactualResult {
/// Every hypothesis's score, ranked best-first: descending by
/// `log_likelihood`, ties broken by ascending hypothesis id (deterministic).
pub ranked: Vec<HypothesisScore>,
/// The best explanation, or a first-class UNKNOWN.
pub best: BestExplanation,
/// Evidence level of the verdict — inherits the weakest input; the twin is
/// `L0` (SYNTHETIC), so a counterfactual verdict is always `L0`.
pub evidence_level: EvidenceLevel,
/// Provenance travelling with the verdict.
pub provenance: SemanticProvenance,
}
impl CounterfactualResult {
/// The top-ranked hypothesis score, if any hypotheses were scored.
#[must_use]
pub fn top(&self) -> Option<&HypothesisScore> {
self.ranked.first()
}
}
/// Perpendicular distance from point `p` to segment `a``b` (metres). Clamps to
/// the endpoints for a point beyond the segment; handles a degenerate
/// (zero-length) segment as point distance. Deterministic and allocation-free.
fn point_segment_distance(p: (f64, f64), a: (f64, f64), b: (f64, f64)) -> f64 {
let (px, py) = p;
let (ax, ay) = a;
let (bx, by) = b;
let dx = bx - ax;
let dy = by - ay;
let len2 = dx * dx + dy * dy;
if len2 <= f64::EPSILON {
return ((px - ax).powi(2) + (py - ay).powi(2)).sqrt();
}
let t = (((px - ax) * dx + (py - ay) * dy) / len2).clamp(0.0, 1.0);
let cx = ax + t * dx;
let cy = ay + t * dy;
((px - cx).powi(2) + (py - cy).powi(2)).sqrt()
}
/// Accumulated occupant effect (`attenuation_db`, `extra_variance_db2`) on one
/// link from a hypothesis's occupants under the model. Non-finite occupant
/// positions and links whose endpoints are absent from the twin contribute
/// nothing (defensive; never panics).
fn occupant_effect(
twin: &RfTwin,
link: &LinkId,
hypothesis: &Hypothesis,
model: &OccupantModel,
) -> (f64, f64) {
let (a, b) = match (twin.node(&link.a), twin.node(&link.b)) {
(Some(a), Some(b)) => (a.position.xy(), b.position.xy()),
_ => return (0.0, 0.0),
};
let mut att = 0.0;
let mut extra = 0.0;
for occ in &hypothesis.occupants {
if !occ.is_finite() {
continue;
}
let d = point_segment_distance(occ.xy(), a, b);
if d < model.body_radius_m {
// Linear falloff to zero at the radius edge (SYNTHETIC).
let shield = 1.0 - d / model.body_radius_m;
att += model.attenuation_db * shield;
extra += model.extra_variance_db2 * shield;
}
}
(att, extra)
}
/// The occupant-adjusted expected distribution `(mean, variance)` for a link
/// under a hypothesis, or `None` when the twin cannot predict the link or the
/// adjusted distribution is degenerate (non-positive / non-finite variance).
#[must_use]
fn adjusted_distribution(
twin: &RfTwin,
link: &LinkId,
hypothesis: &Hypothesis,
model: &OccupantModel,
) -> Option<(f64, f64)> {
let (m0, v0) = match twin.predict(link) {
ExpectedDistribution::Known { mean, variance, .. } => (mean, variance),
ExpectedDistribution::Unknown { .. } => return None,
};
let (att, extra) = occupant_effect(twin, link, hypothesis, model);
let mean = m0 - att;
let variance = v0 + extra;
if mean.is_finite() && variance.is_finite() && variance > 0.0 {
Some((mean, variance))
} else {
None
}
}
/// Gaussian log-likelihood of `observed` under `N(mean, variance)` (nats).
/// `variance` is required `> 0` by the caller; returns `None` on a non-finite
/// result rather than propagating a poisoned score.
#[must_use]
fn gaussian_log_likelihood(observed: f64, mean: f64, variance: f64) -> Option<f64> {
let dev = observed - mean;
let ll = -0.5 * (TAU * variance).ln() - (dev * dev) / (2.0 * variance);
ll.is_finite().then_some(ll)
}
/// Score one hypothesis against the observed set under the twin and occupant
/// model. Deterministic; allocation is bounded by the observation count.
#[must_use]
pub fn score_hypothesis(
twin: &RfTwin,
observed: &ObservationSet,
hypothesis: &Hypothesis,
model: &OccupantModel,
) -> HypothesisScore {
let mut log_likelihood = 0.0;
let mut evaluated_links = 0usize;
let mut unknown_links = 0usize;
for LinkObservation { link, value } in &observed.observations {
match adjusted_distribution(twin, link, hypothesis, model) {
Some((mean, variance)) => match gaussian_log_likelihood(*value, mean, variance) {
Some(ll) => {
log_likelihood += ll;
evaluated_links += 1;
}
None => unknown_links += 1,
},
None => unknown_links += 1,
}
}
HypothesisScore {
hypothesis: hypothesis.clone(),
log_likelihood,
evaluated_links,
unknown_links,
}
}
/// Evaluate counterfactual hypotheses against an observed measurement set using
/// the [`OccupantModel::default_body`] and [`ScoringConfig::default_gates`].
#[must_use]
pub fn evaluate(
twin: &RfTwin,
observed: &ObservationSet,
hypotheses: &[Hypothesis],
) -> CounterfactualResult {
evaluate_with(
twin,
observed,
hypotheses,
&OccupantModel::default_body(),
&ScoringConfig::default_gates(),
)
}
/// Evaluate counterfactual hypotheses with explicit model and scoring gates.
///
/// Scores every hypothesis, ranks them best-first (deterministically), and
/// selects a best explanation with a margin — or a first-class UNKNOWN when the
/// top two are near-indistinguishable, when no hypothesis explains the
/// observation, when no hypotheses are supplied, or when no link is evaluable.
///
/// **SYNTHETIC / L0.** The verdict inherits the twin's `L0` evidence level and
/// is never a camera-grade or `MEASURED` claim.
#[must_use]
pub fn evaluate_with(
twin: &RfTwin,
observed: &ObservationSet,
hypotheses: &[Hypothesis],
model: &OccupantModel,
config: &ScoringConfig,
) -> CounterfactualResult {
let provenance = SemanticProvenance::declared("ruview-counterfactual@0 (SYNTHETIC/L0)");
let evidence_level = EvidenceLevel::L0;
// Score every hypothesis, then rank deterministically (bounded input).
let capped = hypotheses.len().min(crate::hypothesis::MAX_HYPOTHESES);
let mut ranked: Vec<HypothesisScore> = hypotheses[..capped]
.iter()
.map(|h| score_hypothesis(twin, observed, h, model))
.collect();
// Descending by log-likelihood; ties broken by ascending hypothesis id.
ranked.sort_by(|a, b| {
b.log_likelihood
.total_cmp(&a.log_likelihood)
.then_with(|| a.hypothesis.id.cmp(&b.hypothesis.id))
});
let best = decide(&ranked, config);
CounterfactualResult {
ranked,
best,
evidence_level,
provenance,
}
}
/// Select the best explanation (or UNKNOWN) from a ranked score list.
fn decide(ranked: &[HypothesisScore], config: &ScoringConfig) -> BestExplanation {
let Some(top) = ranked.first() else {
return BestExplanation::Unknown {
reason: UnknownReason::NoHypotheses,
};
};
// Nothing was evaluable against the twin ⇒ nothing to explain.
let Some(mean_ll) = top.mean_log_likelihood() else {
return BestExplanation::Unknown {
reason: UnknownReason::NoEvaluableLinks,
};
};
// No hypothesis explains the observation well ⇒ ADR-299 UNKNOWN verdict.
if mean_ll < config.min_mean_log_likelihood {
return BestExplanation::Unknown {
reason: UnknownReason::NoHypothesisExplains {
best_mean_log_likelihood: mean_ll,
floor: config.min_mean_log_likelihood,
},
};
}
// Margin over the runner-up (infinite when the top is the only hypothesis).
let margin = match ranked.get(1) {
Some(second) => top.log_likelihood - second.log_likelihood,
None => f64::INFINITY,
};
if margin < config.min_margin_nats {
return BestExplanation::Unknown {
reason: UnknownReason::NearIndistinguishable {
margin,
threshold: config.min_margin_nats,
},
};
}
BestExplanation::Explained {
hypothesis_id: top.hypothesis.id.clone(),
occupant_count: top.hypothesis.occupant_count(),
margin,
}
}
/// Synthesize the observation set a scene would produce under the twin and
/// occupant model — the occupant-adjusted mean of every twin link with a known
/// base distribution. The empty hypothesis reproduces the twin's own
/// predictions (the zero-occupant reference); a populated hypothesis attenuates
/// the blocked links.
///
/// **SYNTHETIC.** This is a deterministic simulation fixture for exploring and
/// testing counterfactual scoring — not a measurement and not a sampler (no
/// randomness). Links the twin cannot predict are omitted.
#[must_use]
pub fn synthesize_observations(
twin: &RfTwin,
hypothesis: &Hypothesis,
model: &OccupantModel,
) -> ObservationSet {
let mut observed = ObservationSet::new();
for link in twin.links() {
if let Some((mean, _variance)) = adjusted_distribution(twin, &link, hypothesis, model) {
observed = observed.with(link, mean);
}
}
observed
}
+375
View File
@@ -0,0 +1,375 @@
//! # `ruview-counterfactual` — counterfactual spatial inference (ADR-310, ADR-297 phase 3)
//!
//! **SYNTHETIC / L0 — a research-forward generative-scoring scaffold, not a
//! measurement system.**
//!
//! This crate is a *phase-3, research-forward primitive*: a step beyond
//! discriminative classifiers toward a **generative spatial model**. A
//! classifier maps measurements to a label; it cannot say *"the observation is
//! better explained by absence"* or *"one occupant explains this better than
//! two,"* because it has no model of what a measurement *should* look like under
//! a hypothesized world state. This layer supplies that missing piece: given an
//! observed link-measurement set and the ADR-312 digital RF twin as the
//! generative forward model, it scores a small set of scene
//! [`Hypothesis`](Hypothesis) — including the **null hypothesis** (nobody
//! present) — and returns the maximum-likelihood explanation with a **margin**,
//! or a first-class `UNKNOWN` when the hypotheses are near-indistinguishable.
//!
//! It is a **consumer** of the twin, never a second simulator (ADR-310 option 3,
//! rejecting option 2): the ADR-312 twin supplies each link's baseline expected
//! distribution (geometry + propagation), and this layer applies a **documented
//! SYNTHETIC occupant-attenuation model** — a hypothesized occupant attenuates
//! any link whose line of sight passes near it. Hypotheses are drawn from the
//! ADR-308 fused world state and its neighbourhood and are expressed over the
//! canonical ADR-303 [`SpaceId`](ruview_ontology::SpaceId), so a counterfactual
//! result is a governed spatial statement, not an opaque score (ADR-297 rule 3).
//!
//! ## Honesty and evidence discipline (CLAUDE.md, ADR-310)
//!
//! Every likelihood is a **model-relative** score under a twin whose
//! distributions are a simulation at evidence level `L0`, labelled `SYNTHETIC`.
//! A twin *predicts*; it does not *measure*. A counterfactual verdict inherits
//! the `L0` level of its weakest input and is **never** presented as
//! camera-grade ground truth. This crate makes **no** hardware, `MEASURED`, or
//! accuracy claim, and asserts **no** discrimination-accuracy number (e.g. it
//! does not claim to "distinguish one occupant from two"); any such number would
//! require the mean-pose-style baseline discipline, a leakage-free held-out
//! split, and a reproducer before it could be tagged `MEASURED`.
//!
//! ## UNKNOWN is a first-class output (ADR-297 rule 1, ADR-310 §3)
//!
//! The layer never forces a label. The best explanation resolves to a
//! first-class [`BestExplanation::Unknown`] when the top two hypotheses are
//! near-indistinguishable (margin below threshold), when **no** hypothesis
//! explains the observation well (best mean per-link log-likelihood below a
//! floor — routed to the ADR-299 `UNKNOWN` verdict), when no hypotheses are
//! supplied, or when no observed link is evaluable against the twin. UNKNOWN is
//! a value, never an error, a panic, or a confident default.
//!
//! ## Determinism
//!
//! Everything is a pure, deterministic function of its inputs: no I/O, no clock,
//! and no randomness. Synthetic scenes vary only by the twin's explicit
//! [`seed`](ruview_twin::DeploymentDescription::seed); malformed input abstains
//! (first-class UNKNOWN or a typed error) rather than panicking, and allocation
//! is bounded by [`MAX_OCCUPANTS`] and [`MAX_HYPOTHESES`].
//!
//! ```
//! use ruview_counterfactual::*;
//! use ruview_twin::{synthetic_deployment, RfTwin};
//! use ruview_ontology::SpaceId;
//!
//! let twin = RfTwin::build(synthetic_deployment(7)).unwrap();
//! let space = SpaceId::new("space-7").unwrap();
//! let model = OccupantModel::default_body();
//!
//! // An empty-room observation set (nobody present) is best explained by the
//! // null hypothesis over a one-occupant alternative.
//! let empty = Hypothesis::empty("empty", space.clone());
//! let one = Hypothesis::new("one", space, vec![Occupant::new(2.5, 2.0)]).unwrap();
//! let observed = synthesize_observations(&twin, &empty, &model);
//!
//! let result = evaluate(&twin, &observed, &[empty, one]);
//! match result.best {
//! BestExplanation::Explained { hypothesis_id, .. } => assert_eq!(hypothesis_id, "empty"),
//! BestExplanation::Unknown { .. } => {} // also honest if indistinguishable
//! }
//! assert_eq!(result.evidence_level, ruview_ontology::EvidenceLevel::L0);
//! ```
#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod hypothesis;
mod infer;
pub use hypothesis::{Hypothesis, HypothesisError, Occupant, MAX_HYPOTHESES, MAX_OCCUPANTS};
pub use infer::{
evaluate, evaluate_with, score_hypothesis, synthesize_observations, BestExplanation,
CounterfactualResult, HypothesisScore, OccupantModel, ScoringConfig, UnknownReason,
};
// Re-export the canonical ontology and twin vocabulary this crate consumes, so
// downstream speaks one semantics (ADR-297 rule 3, ADR-303).
pub use ruview_ontology::{EvidenceLevel, SemanticProvenance, SpaceId};
pub use ruview_twin::{LinkId, LinkObservation, ObservationSet, RfTwin};
#[cfg(test)]
mod tests {
use super::*;
use ruview_twin::{synthetic_deployment, DeploymentDescription, RfTwin};
fn space(seed: u64) -> SpaceId {
SpaceId::new(format!("space-{seed}")).unwrap()
}
fn twin(seed: u64) -> RfTwin {
RfTwin::build(synthetic_deployment(seed)).unwrap()
}
// The centre of the synthetic 5m×4m room lies on both diagonals, so a
// centre occupant blocks the two diagonal links; edge occupants block an
// edge link. Positions are explicit — no randomness.
fn centre() -> Occupant {
Occupant::new(2.5, 2.0)
}
fn edge() -> Occupant {
Occupant::new(2.5, 0.5)
}
// Empty-room observations favour the null (empty) hypothesis over a
// one-occupant hypothesis.
#[test]
fn empty_room_observations_favour_the_empty_hypothesis() {
let t = twin(7);
let model = OccupantModel::default_body();
let empty = Hypothesis::empty("empty", space(7));
let one = Hypothesis::new("one", space(7), vec![centre()]).unwrap();
// Nobody present ⇒ observations are the twin's own predictions.
let observed = synthesize_observations(&t, &empty, &model);
let result = evaluate(&t, &observed, &[empty, one]);
match &result.best {
BestExplanation::Explained { hypothesis_id, occupant_count, margin } => {
assert_eq!(hypothesis_id, "empty");
assert_eq!(*occupant_count, 0);
assert!(*margin > 0.0);
}
other => panic!("expected the empty hypothesis to win, got {other:?}"),
}
// The empty hypothesis ranks first and out-scores the occupant one.
assert_eq!(result.ranked[0].hypothesis.id, "empty");
assert!(result.ranked[0].log_likelihood > result.ranked[1].log_likelihood);
// A twin verdict is always SYNTHETIC / L0.
assert_eq!(result.evidence_level, EvidenceLevel::L0);
}
// A clear single-person scene favours one occupant over both zero and two.
#[test]
fn single_person_scene_favours_one_over_two_and_empty() {
let t = twin(7);
let model = OccupantModel::default_body();
let empty = Hypothesis::empty("empty", space(7));
let one = Hypothesis::new("one", space(7), vec![centre()]).unwrap();
let two = Hypothesis::new("two", space(7), vec![centre(), edge()]).unwrap();
// A scene with exactly one occupant at the room centre.
let observed = synthesize_observations(&t, &one, &model);
let result = evaluate(&t, &observed, &[empty.clone(), one, two]);
match &result.best {
BestExplanation::Explained { hypothesis_id, occupant_count, margin } => {
assert_eq!(hypothesis_id, "one");
assert_eq!(*occupant_count, 1);
assert!(*margin > 0.0);
}
other => panic!("expected the one-occupant hypothesis to win, got {other:?}"),
}
// One out-scores both two and empty explicitly.
let ll = |id: &str| {
result
.ranked
.iter()
.find(|s| s.hypothesis.id == id)
.unwrap()
.log_likelihood
};
assert!(ll("one") > ll("two"));
assert!(ll("one") > ll("empty"));
}
// An occupant far outside the room blocks no link, so the one-occupant and
// empty hypotheses are near-indistinguishable ⇒ first-class UNKNOWN.
#[test]
fn ambiguous_scene_returns_unknown_low_margin() {
let t = twin(7);
let model = OccupantModel::default_body();
let empty = Hypothesis::empty("empty", space(7));
// Far outside the 5m×4m room ⇒ blocks nothing.
let ghost = Hypothesis::new("ghost", space(7), vec![Occupant::new(50.0, 50.0)]).unwrap();
let observed = synthesize_observations(&t, &empty, &model);
let result = evaluate(&t, &observed, &[empty, ghost]);
match result.best {
BestExplanation::Unknown {
reason: UnknownReason::NearIndistinguishable { margin, threshold },
} => {
assert!(margin < threshold);
assert!(margin.abs() < 1e-9, "blocking nothing ⇒ identical scores");
}
other => panic!("expected near-indistinguishable UNKNOWN, got {other:?}"),
}
}
// Out-of-model measurements (gross deviations the twin cannot account for)
// route to UNKNOWN rather than a forced occupancy label (ADR-310 §3).
#[test]
fn out_of_model_observation_routes_to_unknown() {
let t = twin(7);
let model = OccupantModel::default_body();
let empty = Hypothesis::empty("empty", space(7));
let one = Hypothesis::new("one", space(7), vec![centre()]).unwrap();
// Take the empty-room set and shove every value 100 dB off — nothing the
// twin or any hypothesis can explain.
let base = synthesize_observations(&t, &empty, &model);
let mut scattered = ObservationSet::new();
for obs in &base.observations {
scattered = scattered.with(obs.link.clone(), obs.value + 100.0);
}
let result = evaluate(&t, &scattered, &[empty, one]);
assert!(matches!(
result.best,
BestExplanation::Unknown {
reason: UnknownReason::NoHypothesisExplains { .. }
}
));
}
// Ranking is deterministic: identical inputs (in any hypothesis order)
// produce an identical ranked result.
#[test]
fn ranking_is_deterministic_and_order_independent() {
let t = twin(7);
let model = OccupantModel::default_body();
let empty = Hypothesis::empty("empty", space(7));
let one = Hypothesis::new("one", space(7), vec![centre()]).unwrap();
let two = Hypothesis::new("two", space(7), vec![centre(), edge()]).unwrap();
let observed = synthesize_observations(&t, &one, &model);
let r1 = evaluate(&t, &observed, &[empty.clone(), one.clone(), two.clone()]);
let r2 = evaluate(&t, &observed, &[empty.clone(), one.clone(), two.clone()]);
assert_eq!(r1, r2);
// Reordering the hypotheses does not change the ranked result or verdict.
let r3 = evaluate(&t, &observed, &[two, empty, one]);
assert_eq!(r1.ranked, r3.ranked);
assert_eq!(r1.best, r3.best);
}
// Boundary validation: malformed input abstains, never panics.
#[test]
fn boundary_validation_does_not_panic() {
let t = twin(9);
let model = OccupantModel::default_body();
// No hypotheses ⇒ first-class UNKNOWN, not an error.
let none: Vec<Hypothesis> = Vec::new();
let empty_observed = ObservationSet::new();
let r = evaluate(&t, &empty_observed, &none);
assert!(matches!(
r.best,
BestExplanation::Unknown { reason: UnknownReason::NoHypotheses }
));
assert!(r.ranked.is_empty());
// Hypotheses present but nothing evaluable ⇒ NoEvaluableLinks.
let empty = Hypothesis::empty("empty", space(9));
let r = evaluate(&t, &empty_observed, std::slice::from_ref(&empty));
assert!(matches!(
r.best,
BestExplanation::Unknown { reason: UnknownReason::NoEvaluableLinks }
));
// Non-finite occupant position is rejected at construction.
assert!(matches!(
Hypothesis::new("bad", space(9), vec![Occupant::new(f64::NAN, 0.0)]),
Err(HypothesisError::NonFinitePosition { index: 0 })
));
// Too many occupants is rejected at construction (bounded allocation).
let many = vec![Occupant::new(0.0, 0.0); MAX_OCCUPANTS + 1];
assert!(matches!(
Hypothesis::new("big", space(9), many),
Err(HypothesisError::TooManyOccupants { .. })
));
// An observation for a link outside the twin is counted UNKNOWN, not a
// panic. Build a set referencing a ghost node.
let ghost_link = LinkId::new(
ruview_ontology::SensorId::new("node-0").unwrap(),
ruview_ontology::SensorId::new("ghost").unwrap(),
);
let observed = ObservationSet::new().with(ghost_link, -50.0);
let r = evaluate(&t, &observed, std::slice::from_ref(&empty));
assert_eq!(r.ranked[0].unknown_links, 1);
assert_eq!(r.ranked[0].evaluated_links, 0);
assert!(matches!(
r.best,
BestExplanation::Unknown { reason: UnknownReason::NoEvaluableLinks }
));
// A malformed occupant injected via the struct literal (bypassing the
// validating constructor) is treated as non-blocking, not a NaN score.
let malformed = Hypothesis {
id: "malformed".into(),
space: space(9),
occupants: vec![Occupant::new(f64::INFINITY, 0.0)],
};
let good_observed = synthesize_observations(&t, &empty, &model);
let score = score_hypothesis(&t, &good_observed, &malformed, &model);
assert!(score.log_likelihood.is_finite());
}
// A single hypothesis that fits well is Explained with an infinite margin
// (no competitor), still gated by the fit floor.
#[test]
fn single_hypothesis_has_infinite_margin_when_it_fits() {
let t = twin(3);
let model = OccupantModel::default_body();
let empty = Hypothesis::empty("empty", space(3));
let observed = synthesize_observations(&t, &empty, &model);
let result = evaluate(&t, &observed, std::slice::from_ref(&empty));
match result.best {
BestExplanation::Explained { margin, occupant_count, .. } => {
assert!(margin.is_infinite());
assert_eq!(occupant_count, 0);
}
other => panic!("expected Explained with infinite margin, got {other:?}"),
}
}
// The result serde round-trips losslessly (one canonical semantics), and the
// SYNTHETIC / L0 discipline is on the wire.
#[test]
fn result_serde_round_trip_is_lossless() {
let t = twin(2);
let model = OccupantModel::default_body();
let empty = Hypothesis::empty("empty", space(2));
let one = Hypothesis::new("one", space(2), vec![centre()]).unwrap();
let observed = synthesize_observations(&t, &one, &model);
let result = evaluate(&t, &observed, &[empty, one]);
let json = serde_json::to_string_pretty(&result).unwrap();
let back: CounterfactualResult = serde_json::from_str(&json).unwrap();
assert_eq!(result, back);
assert!(json.contains("\"L0\""));
}
// Distinct twin seeds give distinct-but-reproducible synthetic scenes; the
// scene variation is driven only by the explicit seed (no wall clock).
#[test]
fn scenes_vary_only_by_explicit_seed() {
let model = OccupantModel::default_body();
let a: DeploymentDescription = synthetic_deployment(1);
let b: DeploymentDescription = synthetic_deployment(1);
assert_eq!(a, b);
let ta = RfTwin::build(a).unwrap();
let tb = twin(1);
let one_a = Hypothesis::new("one", space(1), vec![centre()]).unwrap();
let one_b = Hypothesis::new("one", space(1), vec![centre()]).unwrap();
let obs_a = synthesize_observations(&ta, &one_a, &model);
let obs_b = synthesize_observations(&tb, &one_b, &model);
assert_eq!(obs_a, obs_b);
}
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "ruview-infogain"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
thiserror.workspace = true
serde = { workspace = true, features = ["derive"] }
ruview-ontology = { path = "../ruview-ontology" }
ruview-hal = { path = "../ruview-hal" }
[dev-dependencies]
serde_json.workspace = true
+106
View File
@@ -0,0 +1,106 @@
//! Candidate sensor actions the scheduler ranks (ADR-311 §1).
//!
//! **SYNTHETIC / L0 scaffold (ADR-282).** An [`ExpectedReduction`] is a *model
//! prediction* of how much a not-yet-taken measurement would shrink the fused
//! covariance — in a fielded system it comes from the ADR-312 RF-twin forward
//! model evaluated against the ADR-308 covariance. It is never a measured
//! quantity: a value-of-information estimate made *before* paying for the
//! measurement. No accuracy claim is made.
use serde::{Deserialize, Serialize};
use ruview_hal::Modality;
use ruview_ontology::SensorId;
use crate::cost::Cost;
/// The predicted uncertainty reduction of taking one candidate measurement,
/// with UNKNOWN as a first-class value (ADR-297 rule 1).
///
/// A candidate whose informativeness the forward model cannot predict is
/// [`ExpectedReduction::Unknown`] — it is **not** silently treated as zero. The
/// scheduler's [`UnknownPolicy`](crate::UnknownPolicy) decides whether such a
/// candidate is probed (to *learn* its informativeness) or deferred; either way
/// the choice is explicit.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ExpectedReduction {
/// A predicted, non-negative uncertainty reduction on the ADR-299 objective.
Known(f64),
/// The forward model cannot predict this candidate's informativeness.
Unknown,
}
impl ExpectedReduction {
/// Construct a [`Known`](ExpectedReduction::Known) reduction from a raw
/// prediction, sanitizing at the boundary: a non-finite prediction becomes
/// [`Unknown`](ExpectedReduction::Unknown) (honest, per rule 1), and a
/// negative prediction — uncertainty cannot be *increased* by sampling — is
/// clamped to `0.0`.
#[must_use]
pub fn known(raw: f64) -> Self {
if !raw.is_finite() {
Self::Unknown
} else {
Self::Known(raw.max(0.0))
}
}
/// The predicted reduction if known, else `None`.
#[must_use]
pub fn value(&self) -> Option<f64> {
match self {
Self::Known(v) => Some(*v),
Self::Unknown => None,
}
}
/// True when the informativeness is unknown.
#[must_use]
pub fn is_unknown(&self) -> bool {
matches!(self, Self::Unknown)
}
}
/// One candidate sensor action the scheduler may spend budget on.
///
/// It names the radio/[`Modality`] to sample, the modelled
/// [`ExpectedReduction`] of doing so, and the [`Cost`] triple it would consume.
/// [`cycles_since_sampled`](SensorAction::cycles_since_sampled) is caller-
/// supplied staleness that feeds the sampling floor — the scheduler is a pure
/// function of its inputs and holds no cross-cycle state of its own.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SensorAction {
/// The authenticated sensor identity (ADR-302) this action would sample.
pub sensor: SensorId,
/// The sensing modality of that sensor (ADR-317).
pub modality: Modality,
/// Modelled expected uncertainty reduction of taking the measurement.
pub expected_reduction: ExpectedReduction,
/// Modelled cost triple the action would consume.
pub cost: Cost,
/// Cycles since this sensor was last sampled, supplied by the caller. Feeds
/// the sampling floor so a currently-low-value sensor is not starved into
/// permanent blindness (ADR-311 §2). `0` means "sampled last cycle".
#[serde(default)]
pub cycles_since_sampled: u32,
}
impl SensorAction {
/// Convenience constructor with `cycles_since_sampled = 0`.
#[must_use]
pub fn new(
sensor: SensorId,
modality: Modality,
expected_reduction: ExpectedReduction,
cost: Cost,
) -> Self {
Self {
sensor,
modality,
expected_reduction,
cost,
cycles_since_sampled: 0,
}
}
}
+131
View File
@@ -0,0 +1,131 @@
//! Cost descriptors and the deployment cost policy (ADR-311 §1).
//!
//! **SYNTHETIC / L0 scaffold (ADR-282).** Every quantity here is a *modelled*
//! resource figure supplied by the caller (in a fielded system, read from the
//! ADR-317 HAL descriptors); nothing in this module measures a device. No
//! `MEASURED` energy/latency/throughput claim is made or implied — a scheduler
//! predicts where budget is best spent, it does not observe hardware.
use serde::{Deserialize, Serialize};
/// Numerical floor for the weighted-cost denominator so a zero-cost (or nearly
/// free) action never produces a non-finite value density. It does not model a
/// physical minimum; it only keeps the division bounded and deterministic.
pub(crate) const MIN_WEIGHTED_COST: f64 = 1e-9;
/// The three scarce edge resources one sensor action is modelled to consume.
///
/// These are the ADR-311 denominator terms — compute, energy, and bandwidth —
/// the three resources ADR-311 names as scarce on the ESP32-class nodes and
/// small gateways RuView targets. Values are unitless modelled magnitudes; the
/// caller supplies them (from ADR-317 HAL descriptors in a fielded system).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Cost {
/// Modelled compute cost of running the action (unitless magnitude).
pub compute: f64,
/// Modelled energy cost of running the action (unitless magnitude).
pub energy: f64,
/// Modelled bandwidth cost of shipping the result (unitless magnitude).
pub bandwidth: f64,
}
impl Cost {
/// The zero-cost triple (identity for accumulation).
pub const ZERO: Cost = Cost {
compute: 0.0,
energy: 0.0,
bandwidth: 0.0,
};
/// Construct a cost triple.
#[must_use]
pub const fn new(compute: f64, energy: f64, bandwidth: f64) -> Self {
Self {
compute,
energy,
bandwidth,
}
}
/// True when every component is finite and non-negative. A malformed cost
/// (NaN/∞/negative) is not silently coerced to a number; the scheduler
/// defers such a candidate as UNKNOWN-cost rather than guessing (ADR-297
/// rule 1).
#[must_use]
pub fn is_well_formed(&self) -> bool {
[self.compute, self.energy, self.bandwidth]
.iter()
.all(|c| c.is_finite() && *c >= 0.0)
}
/// Component-wise sum, used to accumulate the spent budget.
#[must_use]
pub(crate) fn plus(&self, other: &Cost) -> Cost {
Cost {
compute: self.compute + other.compute,
energy: self.energy + other.energy,
bandwidth: self.bandwidth + other.bandwidth,
}
}
}
/// The deployment cost policy: how the three cost terms are weighted into one
/// scalar denominator (ADR-311 §1).
///
/// The weighting is a *deployment* choice, not a hardcoded constant: a battery
/// node weights energy heavily, a wired gateway weights bandwidth. The policy
/// is configured, never assumed.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct CostPolicy {
/// Weight applied to the compute term.
pub w_compute: f64,
/// Weight applied to the energy term.
pub w_energy: f64,
/// Weight applied to the bandwidth term.
pub w_bandwidth: f64,
}
impl CostPolicy {
/// Equal weight on all three resources.
pub const UNIFORM: CostPolicy = CostPolicy {
w_compute: 1.0,
w_energy: 1.0,
w_bandwidth: 1.0,
};
/// Construct a policy, clamping any non-finite or negative weight to `0.0`
/// at the boundary so a malformed weight can never poison the ranking.
#[must_use]
pub fn new(w_compute: f64, w_energy: f64, w_bandwidth: f64) -> Self {
Self {
w_compute: sanitize_weight(w_compute),
w_energy: sanitize_weight(w_energy),
w_bandwidth: sanitize_weight(w_bandwidth),
}
}
/// Collapse a well-formed cost triple into the single scalar denominator
/// used by the value function, floored at [`MIN_WEIGHTED_COST`] so the
/// division is always finite. Callers must only pass a
/// [`Cost::is_well_formed`] triple.
#[must_use]
pub fn scalar_cost(&self, cost: &Cost) -> f64 {
let weighted =
self.w_compute * cost.compute + self.w_energy * cost.energy + self.w_bandwidth * cost.bandwidth;
weighted.max(MIN_WEIGHTED_COST)
}
}
impl Default for CostPolicy {
fn default() -> Self {
Self::UNIFORM
}
}
fn sanitize_weight(w: f64) -> f64 {
if w.is_finite() && w >= 0.0 {
w
} else {
0.0
}
}
+393
View File
@@ -0,0 +1,393 @@
//! # `ruview-infogain` — information-gain scheduler (ADR-311, ADR-297 primitive 14)
//!
//! **SYNTHETIC / L0 research-forward scaffold (ADR-282, ADR-297 phase 3).**
//! This crate models *which radios/modalities to spend the next sampling budget
//! on* by value of information. It is a **simulation/model scaffold**: every
//! informativeness estimate is a model prediction and every cost is a modelled
//! magnitude. Nothing here measures hardware, and **no** `MEASURED`, accuracy,
//! or energy/latency/throughput claim is made or implied — a twin predicts, it
//! does not measure (CLAUDE.md honesty discipline; ADR-311 asserts no
//! efficiency number). Any figure produced by this crate is `SYNTHETIC`.
//!
//! ## What it does
//!
//! With many sensors, processing every stream at full rate wastes the three
//! scarce edge resources — compute, energy, bandwidth. This scheduler assigns
//! each candidate action a value
//!
//! ```text
//! Value(sensor) ≈ expected_uncertainty_reduction / (compute + energy + bandwidth)
//! ```
//!
//! and spends the budget on the highest-value actions, so the edge samples the
//! most informative radios first. In a fielded system the numerator comes from
//! the ADR-312 RF-twin forward model against the ADR-308 fused covariance and
//! the denominator from ADR-317 HAL cost descriptors; this crate takes both as
//! caller-supplied inputs and stays a pure allocator.
//!
//! ## The four ADR-297 non-negotiable rules, as they bind this crate
//!
//! 1. **UNKNOWN is first-class, never an error.** A candidate whose
//! informativeness the model cannot predict is
//! [`ExpectedReduction::Unknown`] — handled by an explicit
//! [`UnknownPolicy`] (probe or defer), **never** silently treated as zero. A
//! malformed cost is UNKNOWN cost and defers the candidate rather than
//! panicking or guessing. [`Scheduler::plan`] is total: no input panics.
//! 2. **Certificates bind cryptographically.** Out of scope here; a candidate
//! names an already-authenticated ADR-302 [`SensorId`](ruview_ontology::SensorId).
//! 3. **One canonical semantics downstream.** Candidates reuse the canonical
//! [`SensorId`](ruview_ontology::SensorId) and HAL [`Modality`](ruview_hal::Modality)
//! rather than reinventing per-crate identity/modality shapes.
//! 4. **Honest evidence.** A scheduling decision is a resource choice, not a
//! sensing claim; the plan records which sensors were skipped so ADR-299 can
//! raise `UNKNOWN` for an under-sampled zone rather than reporting a stale
//! estimate as current.
//!
//! ## Purity
//!
//! [`Scheduler::plan`] has **no** scheduling side effects: it starts no
//! sampling and touches no hardware — it returns a [`SchedulePlan`]. ADR-306
//! active sensing chooses the probe on each selected sensor; ADR-308 fusion
//! incorporates the result. It is deterministic (no wall clock, no randomness;
//! synthetic scenes vary only by explicit caller-supplied parameters) and
//! bounded in allocation.
//!
//! ## Example
//!
//! ```
//! use ruview_infogain::*;
//! use ruview_hal::Modality;
//! use ruview_ontology::SensorId;
//!
//! let candidates = vec![
//! SensorAction::new(
//! SensorId::new("wifi-1").unwrap(),
//! Modality::Csi,
//! ExpectedReduction::known(0.9), // high modelled information...
//! Cost::new(1.0, 1.0, 1.0), // ...at low cost → high value
//! ),
//! SensorAction::new(
//! SensorId::new("mmwave-occluded").unwrap(),
//! Modality::Mmwave,
//! ExpectedReduction::known(0.1), // low information...
//! Cost::new(5.0, 5.0, 5.0), // ...at high cost → low value
//! ),
//! ];
//!
//! let sched = Scheduler::new(SchedulerConfig::default());
//! let plan = sched.plan(&candidates, &Budget::new(2.0, 2.0, 2.0));
//!
//! // Only the informative-per-cost WiFi link fits the budget.
//! assert_eq!(plan.sampled_sensors(), vec![&SensorId::new("wifi-1").unwrap()]);
//! ```
#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod candidate;
mod cost;
mod scheduler;
pub use candidate::{ExpectedReduction, SensorAction};
pub use cost::{Cost, CostPolicy};
pub use scheduler::{
Budget, DeferReason, DeferredAction, ScheduledAction, SchedulePlan, Scheduler, SchedulerConfig,
SelectReason, UnknownPolicy,
};
#[cfg(test)]
mod tests {
use super::*;
use ruview_hal::Modality;
use ruview_ontology::SensorId;
fn sid(s: &str) -> SensorId {
SensorId::new(s).unwrap()
}
fn action(name: &str, reduction: ExpectedReduction, cost: Cost) -> SensorAction {
SensorAction::new(sid(name), Modality::Csi, reduction, cost)
}
fn known(name: &str, r: f64, c: f64) -> SensorAction {
action(name, ExpectedReduction::known(r), Cost::new(c, c, c))
}
fn plan(candidates: &[SensorAction], budget: Budget) -> SchedulePlan {
Scheduler::new(SchedulerConfig::default()).plan(candidates, &budget)
}
// ADR-311 §2: the highest value/cost candidate is ranked and selected first.
#[test]
fn highest_value_per_cost_selected_first() {
let candidates = vec![
known("low", 0.2, 1.0), // density 0.2 / 3.0
known("high", 0.9, 1.0), // density 0.9 / 3.0
known("mid", 0.5, 1.0), // density 0.5 / 3.0
];
// Ample budget: all fit, but order must be by descending value density.
let p = plan(&candidates, Budget::new(99.0, 99.0, 99.0));
let order: Vec<&str> = p.selected.iter().map(|a| a.sensor.as_str()).collect();
assert_eq!(order, vec!["high", "mid", "low"]);
assert!(p.selected.iter().all(|a| a.reason == SelectReason::Value));
assert!(p.deferred.is_empty());
}
// ADR-311 §1: a high-cost low-gain sensor is deferred when the budget cannot
// hold both it and the more valuable action.
#[test]
fn high_cost_low_gain_deferred_under_budget() {
let candidates = vec![
known("cheap-informative", 0.9, 1.0), // density 0.30
known("costly-uninformative", 0.1, 5.0), // density ~0.0067
];
// Budget fits the cheap action but not both.
let p = plan(&candidates, Budget::new(3.0, 3.0, 3.0));
assert_eq!(p.sampled_sensors(), vec![&sid("cheap-informative")]);
assert_eq!(p.deferred.len(), 1);
assert_eq!(p.deferred[0].sensor.as_str(), "costly-uninformative");
assert_eq!(p.deferred[0].reason, DeferReason::Budget);
}
// The cumulative selected cost never exceeds the budget in any dimension.
#[test]
fn budget_is_respected_in_every_dimension() {
let candidates = vec![
known("a", 0.9, 2.0),
known("b", 0.8, 2.0),
known("c", 0.7, 2.0),
known("d", 0.6, 2.0),
];
let budget = Budget::new(5.0, 5.0, 5.0);
let p = plan(&candidates, budget);
assert!(p.spent.compute <= budget.compute + 1e-9);
assert!(p.spent.energy <= budget.energy + 1e-9);
assert!(p.spent.bandwidth <= budget.bandwidth + 1e-9);
// Two of the cost-2 actions fit under a budget of 5; the third does not.
assert_eq!(p.selected.len(), 2);
}
// A candidate that exactly fills the remaining budget is admitted.
#[test]
fn exact_fit_is_admitted() {
let candidates = vec![known("exact", 0.5, 2.0)];
let p = plan(&candidates, Budget::new(2.0, 2.0, 2.0));
assert_eq!(p.selected.len(), 1);
assert_eq!(p.deferred.len(), 0);
}
// Deterministic tie-break: equal value density resolves by cheapest weighted
// cost, then by sensor id — same inputs always give the same plan.
#[test]
fn tie_break_is_deterministic() {
// Equal density (0.5 / 2.0), so tie-break falls to sensor id.
let candidates = vec![
known("zebra", 0.5, 2.0),
known("alpha", 0.5, 2.0),
known("mike", 0.5, 2.0),
];
let p1 = plan(&candidates, Budget::new(99.0, 99.0, 99.0));
let p2 = plan(&candidates, Budget::new(99.0, 99.0, 99.0));
assert_eq!(p1, p2);
let order: Vec<&str> = p1.selected.iter().map(|a| a.sensor.as_str()).collect();
assert_eq!(order, vec!["alpha", "mike", "zebra"]);
// Cost tie-break wins over id: cheaper same-density action ranks first.
let mixed = vec![
action("expensive", ExpectedReduction::known(1.0), Cost::new(2.0, 2.0, 2.0)), // 1/6
action("cheap", ExpectedReduction::known(0.5), Cost::new(1.0, 1.0, 1.0)), // 0.5/3 = 1/6
];
let pm = plan(&mixed, Budget::new(99.0, 99.0, 99.0));
let order: Vec<&str> = pm.selected.iter().map(|a| a.sensor.as_str()).collect();
assert_eq!(order, vec!["cheap", "expensive"]);
}
// ADR-297 rule 1: an unknown-value candidate is NOT treated as zero. Under
// the default Defer policy it is deferred with an explicit reason.
#[test]
fn unknown_value_defer_policy_defers_explicitly() {
let candidates = vec![
action("unknown", ExpectedReduction::Unknown, Cost::new(1.0, 1.0, 1.0)),
known("known", 0.5, 1.0),
];
let p = plan(&candidates, Budget::new(99.0, 99.0, 99.0));
assert_eq!(p.sampled_sensors(), vec![&sid("known")]);
assert_eq!(p.deferred.len(), 1);
assert_eq!(p.deferred[0].sensor.as_str(), "unknown");
assert_eq!(p.deferred[0].reason, DeferReason::UnknownDeferred);
}
// ADR-311: the Probe policy spends budget to LEARN an unknown candidate's
// informativeness, with an explicit synthetic probe value (not zero).
#[test]
fn unknown_value_probe_policy_selects_to_learn() {
let config = SchedulerConfig {
policy: CostPolicy::UNIFORM,
unknown: UnknownPolicy::Probe { probe_value: 1.0 },
sampling_floor: None,
};
let candidates = vec![
action("unknown", ExpectedReduction::Unknown, Cost::new(1.0, 1.0, 1.0)),
known("weak", 0.1, 1.0),
];
let p = Scheduler::new(config).plan(&candidates, &Budget::new(1.0, 1.0, 1.0));
// Probe value (1.0) beats the weak known (0.1), so the unknown is probed.
assert_eq!(p.selected.len(), 1);
assert_eq!(p.selected[0].sensor.as_str(), "unknown");
assert_eq!(p.selected[0].reason, SelectReason::Probe);
// No honest value figure is reported for an unknown reduction.
assert_eq!(p.selected[0].value_density, None);
}
// ADR-311 §2: the sampling floor force-includes a starved low-value sensor
// so it is re-evaluated rather than permanently blinded.
#[test]
fn sampling_floor_forces_starved_low_value_sensor() {
let config = SchedulerConfig {
policy: CostPolicy::UNIFORM,
unknown: UnknownPolicy::Defer,
sampling_floor: Some(3),
};
let mut starved = known("starved", 0.0, 1.0); // zero value: would be deferred
starved.cycles_since_sampled = 5; // ... but it is past the floor
let fresh = known("fresh", 0.9, 1.0);
let candidates = vec![starved, fresh];
let p = Scheduler::new(config).plan(&candidates, &Budget::new(99.0, 99.0, 99.0));
// Both selected; the starved one is force-included with the Floor reason.
assert_eq!(p.selected.len(), 2);
let starved_sel = p
.selected
.iter()
.find(|a| a.sensor.as_str() == "starved")
.unwrap();
assert_eq!(starved_sel.reason, SelectReason::Floor);
// Floor is best-effort under a hard budget: it cannot fit → deferred.
let tight = Scheduler::new(config).plan(&candidates, &Budget::new(0.0, 0.0, 0.0));
assert!(tight.selected.is_empty());
assert!(tight.deferred.iter().all(|d| d.reason == DeferReason::Budget));
}
// Empty candidate set → empty plan, nothing spent, no panic.
#[test]
fn empty_candidate_set_yields_empty_plan() {
let p = plan(&[], Budget::new(10.0, 10.0, 10.0));
assert!(p.is_empty());
assert!(p.selected.is_empty());
assert!(p.deferred.is_empty());
assert_eq!(p.spent, Cost::ZERO);
assert_eq!(p, SchedulePlan::empty());
}
// Malformed input never panics: non-finite/negative cost defers the
// candidate (UNKNOWN cost); non-finite reduction becomes Unknown; negative
// reduction clamps to zero.
#[test]
fn malformed_input_never_panics() {
// Non-finite reduction → Unknown.
assert_eq!(ExpectedReduction::known(f64::NAN), ExpectedReduction::Unknown);
assert_eq!(
ExpectedReduction::known(f64::INFINITY),
ExpectedReduction::Unknown
);
// Negative reduction clamps to zero.
assert_eq!(ExpectedReduction::known(-3.0), ExpectedReduction::Known(0.0));
let candidates = vec![
action("nan-cost", ExpectedReduction::known(0.9), Cost::new(f64::NAN, 1.0, 1.0)),
action("neg-cost", ExpectedReduction::known(0.9), Cost::new(-1.0, 1.0, 1.0)),
action("inf-cost", ExpectedReduction::known(0.9), Cost::new(f64::INFINITY, 1.0, 1.0)),
known("good", 0.9, 1.0),
];
let p = plan(&candidates, Budget::new(99.0, 99.0, 99.0));
// Only the well-formed candidate is sampled; the rest defer as malformed.
assert_eq!(p.sampled_sensors(), vec![&sid("good")]);
let malformed: Vec<&str> = p
.deferred
.iter()
.filter(|d| d.reason == DeferReason::MalformedCost)
.map(|d| d.sensor.as_str())
.collect();
assert_eq!(malformed, vec!["inf-cost", "nan-cost", "neg-cost"]);
}
// A zero-cost (free) action gets a bounded, finite value density and is not
// rejected by a division by zero.
#[test]
fn zero_cost_action_is_bounded_not_infinite() {
let candidates = vec![action(
"free",
ExpectedReduction::known(1.0),
Cost::new(0.0, 0.0, 0.0),
)];
let p = plan(&candidates, Budget::new(1.0, 1.0, 1.0));
assert_eq!(p.selected.len(), 1);
let d = p.selected[0].value_density.unwrap();
assert!(d.is_finite());
}
// A known-zero-reduction candidate is deferred as NoGain (honest: no
// modelled information), distinct from UNKNOWN.
#[test]
fn known_zero_reduction_defers_as_no_gain() {
let candidates = vec![known("nogain", 0.0, 1.0)];
let p = plan(&candidates, Budget::new(99.0, 99.0, 99.0));
assert!(p.selected.is_empty());
assert_eq!(p.deferred.len(), 1);
assert_eq!(p.deferred[0].reason, DeferReason::NoGain);
}
// The cost policy is a deployment choice: weighting a resource heavily can
// flip which sensor is more valuable per unit cost.
#[test]
fn cost_policy_weighting_changes_ranking() {
// "a" is cheap on compute but expensive on energy; "b" the reverse.
let a = action("a", ExpectedReduction::known(1.0), Cost::new(1.0, 10.0, 1.0));
let b = action("b", ExpectedReduction::known(1.0), Cost::new(10.0, 1.0, 1.0));
let candidates = vec![a, b];
// Energy-heavy policy (battery node): "a" is costlier → "b" ranks first.
let energy_heavy = SchedulerConfig {
policy: CostPolicy::new(1.0, 100.0, 1.0),
unknown: UnknownPolicy::Defer,
sampling_floor: None,
};
let p = Scheduler::new(energy_heavy).plan(&candidates, &Budget::new(99.0, 99.0, 99.0));
assert_eq!(p.selected[0].sensor.as_str(), "b");
// Compute-heavy policy (wired gateway): the ranking flips to "a".
let compute_heavy = SchedulerConfig {
policy: CostPolicy::new(100.0, 1.0, 1.0),
unknown: UnknownPolicy::Defer,
sampling_floor: None,
};
let p = Scheduler::new(compute_heavy).plan(&candidates, &Budget::new(99.0, 99.0, 99.0));
assert_eq!(p.selected[0].sensor.as_str(), "a");
}
// Determinism: identical inputs yield byte-identical plans across runs.
#[test]
fn plan_is_deterministic() {
let candidates = vec![
known("a", 0.7, 2.0),
known("b", 0.3, 1.0),
action("c", ExpectedReduction::Unknown, Cost::new(1.0, 1.0, 1.0)),
];
let budget = Budget::new(3.0, 3.0, 3.0);
let sched = Scheduler::new(SchedulerConfig::default());
assert_eq!(sched.plan(&candidates, &budget), sched.plan(&candidates, &budget));
}
// The whole plan round-trips losslessly through serde (canonical output).
#[test]
fn plan_serde_round_trips() {
let candidates = vec![
known("a", 0.9, 1.0),
known("b", 0.1, 5.0),
action("c", ExpectedReduction::Unknown, Cost::new(1.0, 1.0, 1.0)),
];
let p = plan(&candidates, Budget::new(2.0, 2.0, 2.0));
let json = serde_json::to_string(&p).unwrap();
let back: SchedulePlan = serde_json::from_str(&json).unwrap();
assert_eq!(p, back);
}
}
+391
View File
@@ -0,0 +1,391 @@
//! The information-gain scheduler: rank candidates by value of information and
//! select the most informative subset under a resource budget (ADR-311 §2).
//!
//! **SYNTHETIC / L0 scaffold (ADR-282).** The scheduler emits an *allocation*
//! (a [`SchedulePlan`]), never a measurement and never a sensing claim. It has
//! **no** side effects: it starts no sampling, touches no hardware, and asserts
//! no efficiency figure — ADR-306 active sensing chooses the probe on each
//! selected sensor and ADR-308 fusion incorporates the result. A scheduling
//! decision is a resource choice, not evidence.
//!
//! ## Selection algorithm (documented)
//!
//! The value function is
//!
//! ```text
//! Value(action) = expected_uncertainty_reduction / weighted_cost
//! ```
//!
//! where `weighted_cost` collapses the compute/energy/bandwidth triple under the
//! configured [`CostPolicy`](crate::CostPolicy). Maximising total expected
//! reduction under a multi-resource budget is a knapsack; this scheduler uses a
//! **bounded greedy** heuristic — sort candidates by value density (reduction
//! per unit weighted cost) and take each that still fits the remaining budget.
//! It is `O(n log n)`, allocates one bounded working vector, and is fully
//! deterministic. A candidate that does not fit is deferred, not dropped, and
//! the scheduler keeps scanning lower-density candidates that may still fit —
//! so a small cheap action can be picked after a large one is skipped.
//!
//! Two policies sit on top of the greedy core:
//! - **Sampling floor**: a candidate whose `cycles_since_sampled` has reached
//! the configured floor is *force-included* (subject only to the hard budget)
//! so a low-value sensor is re-evaluated as the scene changes rather than
//! being starved permanently.
//! - **Unknown-value handling**: a candidate with an
//! [`Unknown`](crate::ExpectedReduction::Unknown) reduction is never treated
//! as zero — the [`UnknownPolicy`] either probes it (assigns an explicit probe
//! value so budget is spent to *learn* its informativeness) or defers it.
use serde::{Deserialize, Serialize};
use ruview_hal::Modality;
use ruview_ontology::SensorId;
use crate::candidate::{ExpectedReduction, SensorAction};
use crate::cost::{Cost, CostPolicy};
/// Tolerance for the budget fit comparison, absorbing float round-off so a
/// candidate that exactly fills the budget is not spuriously rejected.
const BUDGET_EPSILON: f64 = 1e-9;
/// How the scheduler treats a candidate with an
/// [`Unknown`](crate::ExpectedReduction::Unknown) expected reduction (ADR-311:
/// unknown value is not zero value).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "kind")]
pub enum UnknownPolicy {
/// Defer unknown-value candidates: they are not ranked by value (an unknown
/// is not asserted to be worthless), but they remain eligible for the
/// sampling floor so they are eventually re-evaluated.
Defer,
/// Probe unknown-value candidates: assign them an explicit optimistic
/// `probe_value` so the scheduler may spend budget to *learn* their
/// informativeness. The value is synthetic exploration pressure, not a
/// prediction; it is clamped to a finite non-negative number.
Probe {
/// The synthetic value density weight given to an unknown candidate.
probe_value: f64,
},
}
/// Scheduler configuration: the cost policy, the unknown-value policy, and the
/// optional sampling floor.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct SchedulerConfig {
/// How the cost triple is weighted into the value-function denominator.
pub policy: CostPolicy,
/// How unknown-value candidates are handled.
pub unknown: UnknownPolicy,
/// Force-sample a candidate once `cycles_since_sampled >=` this value.
/// `None` disables the floor. The floor is best-effort under the hard
/// budget — a forced candidate that cannot fit any resource is still
/// deferred rather than violating the budget.
#[serde(default)]
pub sampling_floor: Option<u32>,
}
impl Default for SchedulerConfig {
fn default() -> Self {
Self {
policy: CostPolicy::UNIFORM,
unknown: UnknownPolicy::Defer,
sampling_floor: None,
}
}
}
/// The multi-resource budget for one scheduling cycle. Each selected action
/// consumes its [`Cost`] triple; the cumulative spend may not exceed the budget
/// in any single dimension.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Budget {
/// Compute budget for the cycle.
pub compute: f64,
/// Energy budget for the cycle.
pub energy: f64,
/// Bandwidth budget for the cycle.
pub bandwidth: f64,
}
impl Budget {
/// Construct a budget, clamping any non-finite or negative dimension to
/// `0.0` (an unusable dimension admits nothing, rather than erroring).
#[must_use]
pub fn new(compute: f64, energy: f64, bandwidth: f64) -> Self {
Self {
compute: clamp_budget(compute),
energy: clamp_budget(energy),
bandwidth: clamp_budget(bandwidth),
}
}
/// True when `spent + cost` stays within every dimension of this budget.
fn admits(&self, spent: &Cost, cost: &Cost) -> bool {
spent.compute + cost.compute <= self.compute + BUDGET_EPSILON
&& spent.energy + cost.energy <= self.energy + BUDGET_EPSILON
&& spent.bandwidth + cost.bandwidth <= self.bandwidth + BUDGET_EPSILON
}
}
fn clamp_budget(v: f64) -> f64 {
if v.is_finite() && v >= 0.0 {
v
} else {
0.0
}
}
/// Why a candidate was selected into the plan.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SelectReason {
/// Selected by information-gain value density (the ordinary path).
Value,
/// Force-included by the sampling floor, not by its current value.
Floor,
/// Selected to probe an unknown-value candidate and learn its informativeness.
Probe,
}
/// Why a candidate was deferred (skipped this cycle).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DeferReason {
/// No remaining budget in at least one resource dimension.
Budget,
/// Unknown expected reduction under [`UnknownPolicy::Defer`] — deferred
/// explicitly, *not* treated as zero value.
UnknownDeferred,
/// A known, non-positive expected reduction: no modelled information to gain.
NoGain,
/// The cost triple was malformed (non-finite/negative); cost is UNKNOWN, so
/// the candidate is deferred rather than guessed at.
MalformedCost,
}
/// One selected action in the emitted plan.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ScheduledAction {
/// The sensor to sample.
pub sensor: SensorId,
/// Its modality.
pub modality: Modality,
/// The value density that ranked it, when defined (`None` for an
/// unknown-value candidate forced in by the floor).
pub value_density: Option<f64>,
/// Why it was selected.
pub reason: SelectReason,
}
/// One deferred (skipped) action in the emitted plan.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DeferredAction {
/// The sensor that was not sampled this cycle.
pub sensor: SensorId,
/// Its modality.
pub modality: Modality,
/// Why it was deferred.
pub reason: DeferReason,
}
/// The scheduler's output: a pure allocation for one cycle.
///
/// Recording both `selected` and `deferred` is the ADR-311 §3 honesty
/// requirement — skipping a sensor is a *deliberate* reduction in coverage, so
/// downstream observability (ADR-299) can raise `UNKNOWN` for an under-sampled
/// zone rather than reporting a stale estimate as current.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SchedulePlan {
/// Actions to sample this cycle, in selection order (floor-forced first,
/// then descending value density).
pub selected: Vec<ScheduledAction>,
/// Actions skipped this cycle, each with its reason, sorted by sensor id.
pub deferred: Vec<DeferredAction>,
/// Total modelled cost the plan commits (component-wise, ≤ budget).
pub spent: Cost,
}
impl SchedulePlan {
/// The empty plan (no candidates, nothing spent).
#[must_use]
pub fn empty() -> Self {
Self {
selected: Vec::new(),
deferred: Vec::new(),
spent: Cost::ZERO,
}
}
/// True when nothing was selected.
#[must_use]
pub fn is_empty(&self) -> bool {
self.selected.is_empty()
}
/// The sensors actually sampled by this plan, for the ADR-311 §3 sampling
/// record consumed downstream.
#[must_use]
pub fn sampled_sensors(&self) -> Vec<&SensorId> {
self.selected.iter().map(|a| &a.sensor).collect()
}
}
/// The information-gain scheduler.
#[derive(Clone, Copy, Debug)]
pub struct Scheduler {
config: SchedulerConfig,
}
/// Internal classification of a candidate before selection.
struct Ranked<'a> {
action: &'a SensorAction,
density: f64,
reason: SelectReason,
/// The value density to report, `None` when the reduction is unknown.
reported_density: Option<f64>,
}
impl Scheduler {
/// Construct a scheduler with the given configuration.
#[must_use]
pub fn new(config: SchedulerConfig) -> Self {
Self { config }
}
/// Borrow the configuration.
#[must_use]
pub fn config(&self) -> &SchedulerConfig {
&self.config
}
/// Produce an allocation for one cycle. Pure and deterministic: identical
/// candidates + budget always yield an identical plan, with no side effects.
#[must_use]
pub fn plan(&self, candidates: &[SensorAction], budget: &Budget) -> SchedulePlan {
let mut forced: Vec<Ranked<'_>> = Vec::new();
let mut ranked: Vec<Ranked<'_>> = Vec::new();
let mut deferred: Vec<DeferredAction> = Vec::new();
for action in candidates {
// Malformed cost is UNKNOWN cost — defer, never guess a number.
if !action.cost.is_well_formed() {
deferred.push(defer(action, DeferReason::MalformedCost));
continue;
}
let floor_forced = self
.config
.sampling_floor
.is_some_and(|n| action.cycles_since_sampled >= n);
// Determine the value density and the "ordinary" (non-floor) reason.
let (density, reported, ordinary_reason, defer_reason) =
self.classify(action);
if floor_forced {
// Force-included regardless of value; report the floor reason
// but keep the density we could compute (may be None).
forced.push(Ranked {
action,
density,
reason: SelectReason::Floor,
reported_density: reported,
});
continue;
}
match ordinary_reason {
Some(reason) => ranked.push(Ranked {
action,
density,
reason,
reported_density: reported,
}),
// Not force-forced and no positive value: defer with the honest
// reason (NoGain or UnknownDeferred).
None => deferred.push(defer(action, defer_reason)),
}
}
// Forced candidates go first, in a deterministic (sensor-id) order.
forced.sort_by(|a, b| a.action.sensor.as_str().cmp(b.action.sensor.as_str()));
// Value-ranked candidates: highest density first, then cheapest, then
// sensor id — a fully deterministic total order (no NaN, all clamped).
ranked.sort_by(|a, b| {
b.density
.total_cmp(&a.density)
.then_with(|| {
let ca = self.config.policy.scalar_cost(&a.action.cost);
let cb = self.config.policy.scalar_cost(&b.action.cost);
ca.total_cmp(&cb)
})
.then_with(|| a.action.sensor.as_str().cmp(b.action.sensor.as_str()))
});
let mut selected: Vec<ScheduledAction> = Vec::new();
let mut spent = Cost::ZERO;
for r in forced.into_iter().chain(ranked.into_iter()) {
if budget.admits(&spent, &r.action.cost) {
spent = spent.plus(&r.action.cost);
selected.push(ScheduledAction {
sensor: r.action.sensor.clone(),
modality: r.action.modality.clone(),
value_density: r.reported_density,
reason: r.reason,
});
} else {
deferred.push(defer(r.action, DeferReason::Budget));
}
}
deferred.sort_by(|a, b| a.sensor.as_str().cmp(b.sensor.as_str()));
SchedulePlan {
selected,
deferred,
spent,
}
}
/// Classify a well-formed candidate into `(density, reported_density,
/// ordinary_reason, defer_reason_if_no_value)`.
fn classify(
&self,
action: &SensorAction,
) -> (f64, Option<f64>, Option<SelectReason>, DeferReason) {
match action.expected_reduction {
ExpectedReduction::Known(v) if v > 0.0 => {
let density = v / self.config.policy.scalar_cost(&action.cost);
(density, Some(density), Some(SelectReason::Value), DeferReason::NoGain)
}
ExpectedReduction::Known(_) => {
// Known zero reduction: no modelled information to gain.
(0.0, Some(0.0), None, DeferReason::NoGain)
}
ExpectedReduction::Unknown => match self.config.unknown {
UnknownPolicy::Probe { probe_value } => {
let pv = if probe_value.is_finite() && probe_value >= 0.0 {
probe_value
} else {
0.0
};
let density = pv / self.config.policy.scalar_cost(&action.cost);
// Reported density stays None: an unknown reduction has no
// honest value figure even when probed.
(density, None, Some(SelectReason::Probe), DeferReason::UnknownDeferred)
}
UnknownPolicy::Defer => (0.0, None, None, DeferReason::UnknownDeferred),
},
}
}
}
fn defer(action: &SensorAction, reason: DeferReason) -> DeferredAction {
DeferredAction {
sensor: action.sensor.clone(),
modality: action.modality.clone(),
reason,
}
}
+17
View File
@@ -0,0 +1,17 @@
[package]
name = "ruview-memory"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
thiserror.workspace = true
serde = { workspace = true, features = ["derive"] }
ruview-ontology = { path = "../ruview-ontology" }
ruview-twin = { path = "../ruview-twin" }
ruview-evidence = { path = "../ruview-evidence" }
[dev-dependencies]
serde_json.workspace = true
+171
View File
@@ -0,0 +1,171 @@
//! Deviation categories, per-channel assessment, and the anomaly event
//! (ADR-309 §3 — anomaly = deviation from learned normal).
//!
//! **SYNTHETIC / L0.** An [`AnomalyEvent`] is a *model-relative* statement: a
//! live value sits statistically far from the location's own learned normal. It
//! is a **candidate** change to corroborate, never a confident detection and
//! never a diagnosis (ADR-282 bounded-claims discipline, ADR-297). No accuracy,
//! detection-rate, or false-positive number is asserted anywhere. Consistent
//! with ADR-297 rule 1, [`Assessment::Unknown`] (insufficient history) is a
//! first-class value, never an error and never a false positive.
use serde::{Deserialize, Serialize};
use ruview_evidence::{AccuracyMetrics, EvidenceContext, EvidenceError, EvidenceRecord};
use ruview_ontology::{EvidenceLevel, SemanticProvenance, ZoneId};
/// The coarse category of a learned-normal deviation (ADR-309 §1). None of
/// these is a labelled anomaly *class* trained from examples — each is a
/// deviation from a baseline of normality, so a novel change still registers.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AnomalyKind {
/// The space is occupied (or unoccupied) at an hour-of-day it normally is
/// not — the "bedroom usually occupied certain hours" case.
UnusualOccupancyHour,
/// A link's propagation signature deviates from the learned normal — the
/// "chair moved" / "RF propagation changed" / "new reflector appeared"
/// cases, which all surface as a per-link RSSI delta.
PropagationChange,
/// A coarse per-modality signature channel deviates from normal — the
/// "machine's vibration signature changed" case.
ModalityChange,
}
impl AnomalyKind {
/// A stable snake_case label used in evidence-record context keys.
#[must_use]
pub fn label(&self) -> &'static str {
match self {
AnomalyKind::UnusualOccupancyHour => "unusual_occupancy_hour",
AnomalyKind::PropagationChange => "propagation_change",
AnomalyKind::ModalityChange => "modality_change",
}
}
}
/// The outcome of scoring one channel (occupancy bucket, link, or modality
/// channel) against its learned baseline.
///
/// **SYNTHETIC / L0.** `significance` is a modelled standard-deviation count
/// against the baseline's own learned variance, not a calibrated probability.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum Assessment {
/// Insufficient history to judge (fewer than `min_history` updates). A
/// first-class value (ADR-297 rule 1): the channel is *not* flagged, so no
/// anomaly is emitted before a baseline exists.
Unknown,
/// Evaluated and within normal variation (`significance < threshold`).
Normal {
/// Modelled deviation significance (standard deviations), `≥ 0`.
significance: f64,
},
/// Evaluated and statistically far from normal (`significance ≥ threshold`).
Anomalous {
/// Modelled deviation significance (standard deviations), `≥ 0`.
significance: f64,
},
}
impl Assessment {
/// True when the channel had too little history to judge.
#[must_use]
pub fn is_unknown(&self) -> bool {
matches!(self, Assessment::Unknown)
}
/// True when the channel deviated beyond the threshold.
#[must_use]
pub fn is_anomalous(&self) -> bool {
matches!(self, Assessment::Anomalous { .. })
}
/// The modelled significance when evaluated, `None` when UNKNOWN.
#[must_use]
pub fn significance(&self) -> Option<f64> {
match self {
Assessment::Unknown => None,
Assessment::Normal { significance } | Assessment::Anomalous { significance } => {
Some(*significance)
}
}
}
}
/// A flagged deviation from a zone's learned normal (ADR-309 §3).
///
/// **SYNTHETIC / L0.** Carries the baseline it deviated from, the deviation
/// magnitude and significance, and its evidence level — which is the floor of
/// the observations the baseline was learned from and is **never presented
/// above** them (ADR-282 no-upgrade rule).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AnomalyEvent {
/// The zone whose learned normal was deviated from.
pub zone: ZoneId,
/// The deviation category.
pub kind: AnomalyKind,
/// Producer-supplied observation time (Unix ms). Injected, never sampled.
pub at_unix_ms: i64,
/// UTC hour-of-day derived deterministically from `at_unix_ms`.
pub hour_of_day: u8,
/// Which channel deviated (link endpoints, modality channel, or hour).
pub detail: String,
/// The observed value.
pub observed: f64,
/// The learned baseline mean it deviated from.
pub baseline_mean: f64,
/// Modelled deviation significance (standard deviations), `≥ 0`.
pub significance: f64,
/// Number of observations backing the baseline.
pub baseline_count: u32,
/// Evidence level — the floor of the baseline's source observations, never
/// above them.
pub evidence_level: EvidenceLevel,
/// Provenance travelling with the event (SYNTHETIC / L0 scaffold).
pub provenance: SemanticProvenance,
}
impl AnomalyEvent {
/// The signed deviation `observed baseline_mean`.
#[must_use]
pub fn deviation(&self) -> f64 {
self.observed - self.baseline_mean
}
/// Project this anomaly into an append-only [`EvidenceRecord`]
/// (ADR-301/ADR-309: "emit anomalies as evidence records with provenance").
///
/// The record is always **synthetic** (forced [`ruview_evidence::EvidenceLevel::L0`]),
/// keyed by context `(room = zone, device = "spatial-memory-scaffold",
/// subject_class = "anomaly:<kind>", model_version)`. The deviation
/// magnitude is carried as the record's `drift` (fingerprint distance from
/// baseline) and the significance as its `uncertainty`; `sample_count` is
/// the baseline's backing history. No rate is fabricated — the accuracy
/// rates are left at `0.0` because this scaffold asserts none.
///
/// # Errors
/// Propagates [`EvidenceError`] if a context field is empty/over-length.
pub fn to_evidence_record(
&self,
model_version: &str,
timestamp_ns: u64,
) -> Result<EvidenceRecord, EvidenceError> {
let context = EvidenceContext::new(
self.zone.as_str(),
"spatial-memory-scaffold",
format!("anomaly:{}", self.kind.label()),
model_version,
)?;
let metrics = AccuracyMetrics {
moving_recall: 0.0,
stationary_recall: 0.0,
false_positive_rate: 0.0,
drift: self.deviation().abs(),
uncertainty: self.significance,
calibration_age_secs: 0,
sample_count: u64::from(self.baseline_count.max(1)),
};
EvidenceRecord::synthetic(context, metrics, timestamp_ns)
}
}
+384
View File
@@ -0,0 +1,384 @@
//! Configuration, the per-zone learned baseline, and the live observation
//! snapshot (ADR-309 §1–§2 — what "normal" is learned over, on the RuVector
//! temporal substrate; here a bounded in-memory scaffold).
//!
//! **SYNTHETIC / L0.** Every structure here is part of a simulation scaffold. A
//! [`ZoneBaseline`] is a *learned model* of a location's normal physics; it
//! predicts what is normal, it never measures. No value it holds is a hardware,
//! `MEASURED`, or accuracy claim (ADR-282, ADR-297).
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use ruview_ontology::{EvidenceLevel, ZoneId};
use ruview_twin::{LinkId, ObservationSet};
use crate::error::MemoryError;
use crate::stat::RunningStat;
/// Hours in the occupancy-by-hour periodicity model (ADR-309 §1).
pub const HOURS_PER_DAY: usize = 24;
/// Upper bound on distinct zones a memory holds. Bounds allocation on untrusted
/// input (CLAUDE.md); construction beyond this is rejected, never truncated.
pub const MAX_ZONES: usize = 4096;
/// Upper bound on learned links per zone.
pub const MAX_LINKS_PER_ZONE: usize = 65_536;
/// Upper bound on modality signature channels per zone.
pub const MAX_MODALITY_CHANNELS: usize = 256;
/// Upper bound, in bytes, on a modality channel identifier.
pub const MAX_CHANNEL_ID_LEN: usize = 256;
/// Tuning of the learned-normal model. All fields are validated at construction
/// so no downstream computation can divide by zero or adapt on a nonsensical
/// factor.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MemoryConfig {
/// Forgetting factor `λ ∈ [0, 1)` — the weight retained on history each
/// update; learning rate `α = 1 λ`. Near `1` tracks only slow legitimate
/// drift; near `0` adapts fast. See [`crate::stat`].
pub forgetting_factor: f64,
/// Minimum updates a channel needs before it is scored; below this the
/// channel is [`Assessment::Unknown`](crate::Assessment::Unknown) — never a
/// false positive on thin history (ADR-297 rule 1).
pub min_history: u32,
/// Significance gate (standard deviations). A channel whose deviation meets
/// or exceeds this is flagged. Not a calibrated false-alarm rate — a model
/// gate (cf. ADR-312 `DEFAULT_SIGNIFICANCE_THRESHOLD`).
pub significance_threshold: f64,
/// Standard-deviation floor for the occupancy model, so an always-empty hour
/// (zero variance) yields finite significance rather than a divide-by-zero.
pub occupancy_floor_std: f64,
/// Standard-deviation floor (dB) for propagation and modality signatures.
pub signature_floor_std: f64,
/// Model version handle stamped into emitted evidence records (ADR-136).
pub model_version: String,
}
impl MemoryConfig {
/// A neutral SYNTHETIC default: `λ = 0.9` (learning rate 0.1), `min_history
/// = 8`, `3σ` gate, occupancy floor `0.1`, signature floor `1.0 dB`. Asserts
/// nothing about any real environment.
#[must_use]
pub fn default_synthetic() -> Self {
Self {
forgetting_factor: 0.9,
min_history: 8,
significance_threshold: 3.0,
occupancy_floor_std: 0.1,
signature_floor_std: 1.0,
model_version: "ruview-memory-scaffold@0 (SYNTHETIC/L0)".to_string(),
}
}
/// Validate the configuration at the boundary. Never panics.
///
/// # Errors
/// [`MemoryError::InvalidConfig`] for any out-of-domain field.
pub fn validate(&self) -> Result<(), MemoryError> {
if !(self.forgetting_factor.is_finite() && (0.0..1.0).contains(&self.forgetting_factor)) {
return Err(MemoryError::InvalidConfig {
what: "forgetting_factor must be finite and in [0, 1)",
});
}
if self.min_history < 1 {
return Err(MemoryError::InvalidConfig {
what: "min_history must be >= 1",
});
}
if !(self.significance_threshold.is_finite() && self.significance_threshold > 0.0) {
return Err(MemoryError::InvalidConfig {
what: "significance_threshold must be finite and > 0",
});
}
if !(self.occupancy_floor_std.is_finite() && self.occupancy_floor_std > 0.0) {
return Err(MemoryError::InvalidConfig {
what: "occupancy_floor_std must be finite and > 0",
});
}
if !(self.signature_floor_std.is_finite() && self.signature_floor_std > 0.0) {
return Err(MemoryError::InvalidConfig {
what: "signature_floor_std must be finite and > 0",
});
}
if self.model_version.is_empty() || self.model_version.len() > MAX_CHANNEL_ID_LEN {
return Err(MemoryError::InvalidConfig {
what: "model_version must be non-empty and bounded",
});
}
Ok(())
}
}
/// UTC hour-of-day derived deterministically from an injected Unix-ms timestamp.
/// Pure arithmetic on the caller-supplied value — no wall-clock is read. Handles
/// negative timestamps (pre-1970) via Euclidean remainder.
#[must_use]
pub fn hour_of_day_utc(at_unix_ms: i64) -> u8 {
let hours = at_unix_ms.div_euclid(3_600_000);
hours.rem_euclid(HOURS_PER_DAY as i64) as u8
}
/// One learned per-link propagation statistic within a zone. Stored as a `Vec`
/// (not a map) so the whole baseline serializes to JSON — [`LinkId`] is a
/// struct, not a string key.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LinkStat {
/// The link this statistic describes.
pub link: LinkId,
/// The learned normal RSSI distribution for the link.
pub stat: RunningStat,
}
/// A live snapshot of a zone used to score against, and then update, its learned
/// normal. Time is injected; nothing here samples a clock.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ZoneObservation {
/// The zone this snapshot is for.
pub zone: ZoneId,
/// Producer-supplied capture time (Unix ms). Injected; the occupancy hour is
/// derived from it deterministically.
pub at_unix_ms: i64,
/// Occupancy indicator / count for this snapshot (`≥ 0`, finite).
pub occupancy: f64,
/// Per-link observed values (reusing the twin's [`ObservationSet`]
/// vocabulary), scored against the learned propagation baseline.
pub links: ObservationSet,
/// Coarse per-modality signature channels (e.g. `"vibration_rms"`), scored
/// against the learned modality baseline.
pub modality: BTreeMap<String, f64>,
/// Evidence level of the source observations. A learned baseline never rises
/// above the floor of these (ADR-282 no-upgrade).
pub evidence_level: EvidenceLevel,
}
impl ZoneObservation {
/// A snapshot with no links or modality channels yet.
#[must_use]
pub fn new(zone: ZoneId, at_unix_ms: i64, occupancy: f64, evidence_level: EvidenceLevel) -> Self {
Self {
zone,
at_unix_ms,
occupancy,
links: ObservationSet::new(),
modality: BTreeMap::new(),
evidence_level,
}
}
/// Add a link observation (builder style).
#[must_use]
pub fn with_link(mut self, link: LinkId, value: f64) -> Self {
self.links = self.links.with(link, value);
self
}
/// Add a modality signature channel (builder style).
#[must_use]
pub fn with_modality(mut self, channel: impl Into<String>, value: f64) -> Self {
self.modality.insert(channel.into(), value);
self
}
/// Validate the snapshot at the boundary: finite, non-negative occupancy;
/// finite link/modality values; bounded channel count and id length. Never
/// panics.
pub(crate) fn validate(&self) -> Result<(), MemoryError> {
if !self.occupancy.is_finite() {
return Err(MemoryError::NonFiniteValue { what: "occupancy" });
}
if self.occupancy < 0.0 {
return Err(MemoryError::NegativeOccupancy {
value: self.occupancy,
});
}
for obs in &self.links.observations {
if !obs.value.is_finite() {
return Err(MemoryError::NonFiniteValue { what: "link value" });
}
}
if self.modality.len() > MAX_MODALITY_CHANNELS {
return Err(MemoryError::TooManyChannels {
max: MAX_MODALITY_CHANNELS,
});
}
for (channel, value) in &self.modality {
if channel.len() > MAX_CHANNEL_ID_LEN {
return Err(MemoryError::ChannelIdTooLong {
len: channel.len(),
max: MAX_CHANNEL_ID_LEN,
});
}
if !value.is_finite() {
return Err(MemoryError::NonFiniteValue {
what: "modality value",
});
}
}
Ok(())
}
}
/// The learned normal physics of one zone (ADR-309 §1): occupancy periodicity,
/// per-link RF propagation, and coarse per-modality signatures.
///
/// **SYNTHETIC / L0.** A learned model of normality, never a measurement.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ZoneBaseline {
/// Occupancy distribution indexed by UTC hour-of-day (`0..24`).
occupancy_by_hour: [RunningStat; HOURS_PER_DAY],
/// Learned per-link propagation normal, in deterministic insertion order.
propagation: Vec<LinkStat>,
/// Learned per-channel modality normal.
modality: BTreeMap<String, RunningStat>,
/// Floor of the evidence levels the baseline was learned from; `None` until
/// the first observation. A learned normal is never above this.
evidence_floor: Option<EvidenceLevel>,
/// Total observations folded into this baseline.
updates: u64,
}
impl Default for ZoneBaseline {
fn default() -> Self {
Self::new()
}
}
impl ZoneBaseline {
/// An empty baseline with no history.
#[must_use]
pub fn new() -> Self {
Self {
occupancy_by_hour: [RunningStat::new(); HOURS_PER_DAY],
propagation: Vec::new(),
modality: BTreeMap::new(),
evidence_floor: None,
updates: 0,
}
}
/// The occupancy statistic for a UTC hour (`0..24`).
#[must_use]
pub fn occupancy_hour(&self, hour: u8) -> &RunningStat {
&self.occupancy_by_hour[(hour as usize) % HOURS_PER_DAY]
}
/// The learned statistic for a link, if any.
#[must_use]
pub fn link_stat(&self, link: &LinkId) -> Option<&RunningStat> {
self.propagation
.iter()
.find(|ls| &ls.link == link)
.map(|ls| &ls.stat)
}
/// The learned statistic for a modality channel, if any.
#[must_use]
pub fn modality_stat(&self, channel: &str) -> Option<&RunningStat> {
self.modality.get(channel)
}
/// The floor of evidence levels this baseline was learned from, `None`
/// before any observation.
#[must_use]
pub fn evidence_floor(&self) -> Option<EvidenceLevel> {
self.evidence_floor
}
/// Total observations folded into this baseline.
#[must_use]
pub fn updates(&self) -> u64 {
self.updates
}
/// The learned links, read-only.
#[must_use]
pub fn links(&self) -> &[LinkStat] {
&self.propagation
}
/// Seed (or overwrite) a link's baseline from a prior mean/variance — used to
/// anchor the propagation model on the twin's expected distribution. Bounded.
pub(crate) fn seed_link(
&mut self,
link: LinkId,
mean: f64,
variance: f64,
count: u32,
) -> Result<(), MemoryError> {
let seeded = RunningStat::seeded(mean, variance, count);
if let Some(ls) = self.propagation.iter_mut().find(|ls| ls.link == link) {
ls.stat = seeded;
return Ok(());
}
if self.propagation.len() >= MAX_LINKS_PER_ZONE {
return Err(MemoryError::TooManyLinks {
max: MAX_LINKS_PER_ZONE,
});
}
self.propagation.push(LinkStat { link, stat: seeded });
Ok(())
}
/// Mutable access to a link statistic, inserting a fresh one if absent.
/// Bounded — an over-capacity zone is rejected, never grown unbounded.
pub(crate) fn link_stat_mut(&mut self, link: &LinkId) -> Result<&mut RunningStat, MemoryError> {
if let Some(pos) = self.propagation.iter().position(|ls| &ls.link == link) {
return Ok(&mut self.propagation[pos].stat);
}
if self.propagation.len() >= MAX_LINKS_PER_ZONE {
return Err(MemoryError::TooManyLinks {
max: MAX_LINKS_PER_ZONE,
});
}
self.propagation.push(LinkStat {
link: link.clone(),
stat: RunningStat::new(),
});
let last = self.propagation.len() - 1;
Ok(&mut self.propagation[last].stat)
}
/// Mutable access to a modality statistic, inserting a fresh one if absent.
/// Bounded.
pub(crate) fn modality_stat_mut(
&mut self,
channel: &str,
) -> Result<&mut RunningStat, MemoryError> {
if !self.modality.contains_key(channel) && self.modality.len() >= MAX_MODALITY_CHANNELS {
return Err(MemoryError::TooManyChannels {
max: MAX_MODALITY_CHANNELS,
});
}
Ok(self
.modality
.entry(channel.to_string())
.or_insert_with(RunningStat::new))
}
/// Fold one snapshot's occupancy into the hour bucket.
pub(crate) fn update_occupancy(&mut self, hour: u8, occupancy: f64, forgetting: f64) {
self.occupancy_by_hour[(hour as usize) % HOURS_PER_DAY].update(occupancy, forgetting);
}
/// Lower the evidence floor to include a prior/source at `level`, without
/// counting it as an observation. Used to record the twin's SYNTHETIC/L0
/// prior when seeding a propagation baseline.
pub(crate) fn record_prior(&mut self, level: EvidenceLevel) {
self.evidence_floor = Some(match self.evidence_floor {
Some(existing) => existing.min(level),
None => level,
});
}
/// Lower the evidence floor to include a new source observation, and bump the
/// update count.
pub(crate) fn record_source(&mut self, level: EvidenceLevel) {
self.record_prior(level);
self.updates = self.updates.saturating_add(1);
}
}
+57
View File
@@ -0,0 +1,57 @@
//! Boundary errors (ADR-309 / CLAUDE.md — validate untrusted input, never
//! panic).
//!
//! Malformed input yields one of these typed errors; nothing here panics.
use thiserror::Error;
/// Errors raised at the spatial-memory input boundaries.
#[derive(Clone, Debug, PartialEq, Error)]
pub enum MemoryError {
/// A configuration field was out of its valid domain.
#[error("invalid config: {what}")]
InvalidConfig {
/// Human-readable reason.
what: &'static str,
},
/// A supplied value was non-finite (`NaN`/`inf`).
#[error("non-finite value: {what}")]
NonFiniteValue {
/// Which value.
what: &'static str,
},
/// Occupancy was negative.
#[error("occupancy must be >= 0, got {value}")]
NegativeOccupancy {
/// The rejected value.
value: f64,
},
/// More zones than [`MAX_ZONES`](crate::MAX_ZONES).
#[error("too many zones (max {max})")]
TooManyZones {
/// The enforced maximum.
max: usize,
},
/// More links in a zone than [`MAX_LINKS_PER_ZONE`](crate::MAX_LINKS_PER_ZONE).
#[error("too many links in a zone (max {max})")]
TooManyLinks {
/// The enforced maximum.
max: usize,
},
/// More modality channels than
/// [`MAX_MODALITY_CHANNELS`](crate::MAX_MODALITY_CHANNELS).
#[error("too many modality channels (max {max})")]
TooManyChannels {
/// The enforced maximum.
max: usize,
},
/// A modality channel id exceeded
/// [`MAX_CHANNEL_ID_LEN`](crate::MAX_CHANNEL_ID_LEN).
#[error("modality channel id length {len} exceeds maximum {max}")]
ChannelIdTooLong {
/// Actual length in bytes.
len: usize,
/// The enforced maximum.
max: usize,
},
}
+653
View File
@@ -0,0 +1,653 @@
//! # `ruview-memory` — long-term spatial memory (ADR-309, ADR-297 phase 3)
//!
//! **SYNTHETIC / L0 — a simulation / model scaffold, not a measurement system.**
//!
//! This crate is a *research-forward primitive*: it learns the **normal physics
//! of a location** so anomalies surface as *deviations from a learned baseline
//! of normality* — without training a detector for every anomaly class. It is a
//! **model**, not a sensor. It predicts what is normal for a place and time and
//! flags a statistically significant delta; it never *measures* anything, and it
//! asserts **no** detection-accuracy, false-positive, or health/safety number
//! (ADR-282 bounded-claims discipline, ADR-309 evidence discipline, CLAUDE.md
//! honesty rule). A flagged deviation is a *candidate change to corroborate*,
//! never a confident detection and never a diagnosis.
//!
//! Following ADR-297 rule 1, *insufficient information* is a first-class value
//! ([`Assessment::Unknown`]), never an error and never a false positive: no
//! anomaly is ever flagged before a baseline exists.
//!
//! ## What "normal" is learned over (ADR-309 §1)
//!
//! Per ADR-303 [`ZoneId`], a [`ZoneBaseline`] accumulates:
//!
//! - **Occupancy periodicity** — a distribution of occupancy by UTC hour-of-day
//! (the "bedroom usually occupied certain hours" case).
//! - **RF-propagation signature** — a per-link learned normal, *anchored on the
//! twin's expected distributions* ([`SpatialMemory::seed_zone_propagation_from_twin`])
//! and refined online (the "chair moved" / "propagation changed" / "new
//! reflector" cases, which all surface as a per-link RSSI delta).
//! - **Coarse modality signatures** — per-channel learned normal (the "machine's
//! vibration signature changed" case).
//!
//! Each baseline updates **online** with a documented forgetting factor
//! ([`crate::stat`]); slow legitimate drift is absorbed into the baseline while
//! an abrupt change deviates from it. Every baseline carries the **floor**
//! evidence level of the observations it was learned from and is never presented
//! above them (ADR-282 no-upgrade).
//!
//! ## Anomaly = deviation from learned normal (ADR-309 §3)
//!
//! [`SpatialMemory::observe`] scores a live [`ZoneObservation`] against the
//! applicable learned baseline (matched by zone and hour), returns an
//! [`ObserveOutcome`] of per-channel [`Assessment`]s, and emits an
//! [`AnomalyEvent`] for each channel whose deviation meets the significance
//! gate. Anomalies project to append-only [`ruview_evidence`] records with
//! provenance ([`AnomalyEvent::to_evidence_record`]).
//!
//! ## The four ADR-297 non-negotiable rules, as they bind this crate
//!
//! 1. **UNKNOWN is first-class, never an error.** A channel with fewer than
//! `min_history` updates is [`Assessment::Unknown`]; `observe` is total and
//! never panics on malformed input (it returns a typed [`MemoryError`]).
//! 2. **Certificates bind cryptographically.** Out of scope here; a baseline is
//! keyed by an already-authenticated ADR-303 [`ZoneId`] and its evidence
//! level is the floor of its source observations.
//! 3. **One canonical semantics.** The memory reuses the canonical
//! [`ZoneId`]/[`EvidenceLevel`]/[`SemanticProvenance`] vocabulary, the twin's
//! [`LinkId`]/[`ObservationSet`]/[`ExpectedDistribution`], and the
//! [`ruview_evidence`] ledger, rather than reinventing per-crate shapes.
//! 4. **Honest evidence.** Every emitted record is **synthetic** (forced L0);
//! the deviation is carried as `drift` and its significance as `uncertainty`.
//! No accuracy rate is fabricated.
//!
//! ## Determinism
//!
//! Everything is deterministic: injected time (no wall-clock), no randomness
//! (synthetic scenes vary only by the twin's explicit seed), fixed iteration
//! order, and bounded allocation. The same observations in the same order always
//! produce the same state and the same anomalies.
//!
//! ```
//! use ruview_memory::*;
//! use ruview_ontology::{EvidenceLevel, ZoneId};
//! use ruview_twin::{synthetic_deployment, RfTwin, ObservationSet};
//!
//! let mut mem = SpatialMemory::new(MemoryConfig::default_synthetic()).unwrap();
//! let zone = ZoneId::new("bedroom").unwrap();
//! let twin = RfTwin::build(synthetic_deployment(7)).unwrap();
//!
//! // Anchor the propagation baseline on the twin's expected distributions.
//! mem.seed_zone_propagation_from_twin(zone.clone(), &twin).unwrap();
//!
//! // A snapshot that matches the twin's predictions is normal, not an anomaly.
//! let mut obs = ZoneObservation::new(zone, 0, 1.0, EvidenceLevel::L0);
//! obs.links = ObservationSet::from_twin_prediction(&twin);
//! let outcome = mem.observe(&obs).unwrap();
//! assert!(!outcome.has_anomaly());
//! ```
#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod anomaly;
mod baseline;
mod error;
mod stat;
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use ruview_evidence::{EvidenceError, EvidenceLedger};
use ruview_twin::{predict_link, ExpectedDistribution, RfTwin};
pub use anomaly::{AnomalyEvent, AnomalyKind, Assessment};
pub use baseline::{
hour_of_day_utc, LinkStat, MemoryConfig, ZoneBaseline, ZoneObservation, HOURS_PER_DAY,
MAX_CHANNEL_ID_LEN, MAX_LINKS_PER_ZONE, MAX_MODALITY_CHANNELS, MAX_ZONES,
};
pub use error::MemoryError;
pub use stat::RunningStat;
// Re-export the canonical vocabulary consumers speak (ADR-297 rule 3, ADR-303),
// and the twin's link type the propagation model is keyed by.
pub use ruview_ontology::{EvidenceLevel, SemanticProvenance, ZoneId};
pub use ruview_twin::LinkId;
/// The provenance stamped on every anomaly this scaffold emits.
const PROVENANCE_MODEL: &str = "ruview-memory@0 (SYNTHETIC/L0)";
/// The learned normal physics of every zone, and the operation that scores a
/// live snapshot against it (ADR-309).
///
/// **SYNTHETIC / L0.** A learned model of normality, never a measurement.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SpatialMemory {
config: MemoryConfig,
zones: BTreeMap<ZoneId, ZoneBaseline>,
}
impl SpatialMemory {
/// Build an empty memory with the given configuration, validated at the
/// boundary.
///
/// # Errors
/// [`MemoryError::InvalidConfig`] for an out-of-domain configuration.
pub fn new(config: MemoryConfig) -> Result<Self, MemoryError> {
config.validate()?;
Ok(Self {
config,
zones: BTreeMap::new(),
})
}
/// The active configuration.
#[must_use]
pub fn config(&self) -> &MemoryConfig {
&self.config
}
/// The learned baseline for a zone, if one exists.
#[must_use]
pub fn baseline(&self, zone: &ZoneId) -> Option<&ZoneBaseline> {
self.zones.get(zone)
}
/// The number of zones with a learned baseline.
#[must_use]
pub fn zone_count(&self) -> usize {
self.zones.len()
}
/// Anchor a zone's propagation baseline on a twin's expected distributions
/// (ADR-309 §1). Each link the twin can predict seeds a learned statistic
/// with the twin's modelled mean/variance and a prior weight of
/// `min_history`, so the propagation model is usable immediately as a prior
/// and then refined online. The twin prior is SYNTHETIC/L0, so the zone's
/// evidence floor is lowered to L0.
///
/// Returns the number of links seeded.
///
/// # Errors
/// [`MemoryError::TooManyZones`] / [`MemoryError::TooManyLinks`] at the
/// bounded-allocation limits.
pub fn seed_zone_propagation_from_twin(
&mut self,
zone: ZoneId,
twin: &RfTwin,
) -> Result<usize, MemoryError> {
self.ensure_zone(&zone)?;
let min_history = self.config.min_history;
let baseline = self.zones.get_mut(&zone).expect("zone ensured above");
let mut seeded = 0;
for link in twin.links() {
if let ExpectedDistribution::Known { mean, variance, .. } = predict_link(twin, &link) {
baseline.seed_link(link, mean, variance, min_history)?;
seeded += 1;
}
}
baseline.record_prior(EvidenceLevel::L0);
Ok(seeded)
}
/// Score a live snapshot against the zone's learned normal, then fold it into
/// the baseline (ADR-309 §3). Scoring uses the baseline learned *before* this
/// snapshot, so a flagged anomaly is a genuine deviation and the current
/// value does not mask itself. Deterministic; never panics on malformed
/// input.
///
/// # Errors
/// [`MemoryError`] for non-finite/negative input or a bounded-allocation
/// limit; the memory is left unchanged when an error is returned.
pub fn observe(&mut self, obs: &ZoneObservation) -> Result<ObserveOutcome, MemoryError> {
obs.validate()?;
self.ensure_zone(&obs.zone)?;
let hour = hour_of_day_utc(obs.at_unix_ms);
let cfg = self.config.clone();
let baseline = self.zones.get_mut(&obs.zone).expect("zone ensured above");
// Evidence level attributed to any anomaly: the floor of the source
// observations that formed the baseline, never above the current source.
let source_level = baseline.evidence_floor().unwrap_or(obs.evidence_level);
// --- Read phase: copy stats out, score against the prior baseline. ---
let occ_stat = *baseline.occupancy_hour(hour);
let occupancy = assess(
Some(occ_stat),
obs.occupancy,
cfg.occupancy_floor_std,
cfg.min_history,
cfg.significance_threshold,
);
let mut propagation: Vec<(LinkId, Assessment)> =
Vec::with_capacity(obs.links.observations.len());
for lo in &obs.links.observations {
let stat = baseline.link_stat(&lo.link).copied();
let a = assess(
stat,
lo.value,
cfg.signature_floor_std,
cfg.min_history,
cfg.significance_threshold,
);
propagation.push((lo.link.clone(), a));
}
let mut modality: Vec<(String, Assessment)> = Vec::with_capacity(obs.modality.len());
for (channel, value) in &obs.modality {
let stat = baseline.modality_stat(channel).copied();
let a = assess(
stat,
*value,
cfg.signature_floor_std,
cfg.min_history,
cfg.significance_threshold,
);
modality.push((channel.clone(), a));
}
// --- Collect anomalies from the assessments made above. ---
let mut anomalies = Vec::new();
if let Assessment::Anomalous { significance } = occupancy {
anomalies.push(make_event(
obs.zone.clone(),
AnomalyKind::UnusualOccupancyHour,
obs.at_unix_ms,
hour,
format!("hour={hour}"),
obs.occupancy,
&occ_stat,
significance,
source_level,
));
}
for (lo, (link, a)) in obs.links.observations.iter().zip(propagation.iter()) {
if let Assessment::Anomalous { significance } = a {
let stat = baseline.link_stat(link).copied().unwrap_or_default();
anomalies.push(make_event(
obs.zone.clone(),
AnomalyKind::PropagationChange,
obs.at_unix_ms,
hour,
format!("link={}~{}", link.a, link.b),
lo.value,
&stat,
*significance,
source_level,
));
}
}
for ((channel, value), (_, a)) in obs.modality.iter().zip(modality.iter()) {
if let Assessment::Anomalous { significance } = a {
let stat = baseline.modality_stat(channel).copied().unwrap_or_default();
anomalies.push(make_event(
obs.zone.clone(),
AnomalyKind::ModalityChange,
obs.at_unix_ms,
hour,
format!("channel={channel}"),
*value,
&stat,
*significance,
source_level,
));
}
}
// --- Write phase: fold the snapshot into the baseline. ---
baseline.update_occupancy(hour, obs.occupancy, cfg.forgetting_factor);
for lo in &obs.links.observations {
baseline
.link_stat_mut(&lo.link)?
.update(lo.value, cfg.forgetting_factor);
}
for (channel, value) in &obs.modality {
baseline
.modality_stat_mut(channel)?
.update(*value, cfg.forgetting_factor);
}
baseline.record_source(obs.evidence_level);
Ok(ObserveOutcome {
zone: obs.zone.clone(),
hour_of_day: hour,
occupancy,
propagation,
modality,
anomalies,
})
}
/// Append each anomaly in an outcome to an [`EvidenceLedger`] as a synthetic
/// record (ADR-309: "emit anomalies as evidence records with provenance").
/// Returns the ledger sequence assigned to each record, in order.
///
/// # Errors
/// Propagates [`EvidenceError`] from record construction or a full ledger.
pub fn record_anomalies(
&self,
outcome: &ObserveOutcome,
ledger: &mut EvidenceLedger,
timestamp_ns: u64,
) -> Result<Vec<u64>, EvidenceError> {
let mut seqs = Vec::with_capacity(outcome.anomalies.len());
for ev in &outcome.anomalies {
let record = ev.to_evidence_record(&self.config.model_version, timestamp_ns)?;
seqs.push(ledger.append(record)?);
}
Ok(seqs)
}
/// Ensure a zone has a baseline, respecting the bounded-allocation cap.
fn ensure_zone(&mut self, zone: &ZoneId) -> Result<(), MemoryError> {
if !self.zones.contains_key(zone) {
if self.zones.len() >= MAX_ZONES {
return Err(MemoryError::TooManyZones { max: MAX_ZONES });
}
self.zones.insert(zone.clone(), ZoneBaseline::new());
}
Ok(())
}
}
/// Score one value against its (optional) learned statistic. UNKNOWN when there
/// is no statistic or its history is below `min_history` (ADR-297 rule 1).
fn assess(
stat: Option<RunningStat>,
x: f64,
floor: f64,
min_history: u32,
threshold: f64,
) -> Assessment {
match stat {
Some(s) if s.count() >= min_history => {
let significance = s.significance(x, floor);
if significance >= threshold {
Assessment::Anomalous { significance }
} else {
Assessment::Normal { significance }
}
}
_ => Assessment::Unknown,
}
}
/// Assemble an [`AnomalyEvent`] from a flagged channel and its baseline stat.
#[allow(clippy::too_many_arguments)]
fn make_event(
zone: ZoneId,
kind: AnomalyKind,
at_unix_ms: i64,
hour_of_day: u8,
detail: String,
observed: f64,
stat: &RunningStat,
significance: f64,
evidence_level: EvidenceLevel,
) -> AnomalyEvent {
AnomalyEvent {
zone,
kind,
at_unix_ms,
hour_of_day,
detail,
observed,
baseline_mean: stat.mean(),
significance,
baseline_count: stat.count(),
evidence_level,
provenance: SemanticProvenance::declared(PROVENANCE_MODEL),
}
}
/// The result of scoring one snapshot against a zone's learned normal.
///
/// **SYNTHETIC / L0.** Per-channel [`Assessment`]s and the derived
/// [`AnomalyEvent`]s are model-relative summaries, not detections.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ObserveOutcome {
/// The zone scored.
pub zone: ZoneId,
/// UTC hour-of-day the snapshot was attributed to.
pub hour_of_day: u8,
/// Occupancy assessment for that hour.
pub occupancy: Assessment,
/// Per-link propagation assessments, in observation order.
pub propagation: Vec<(LinkId, Assessment)>,
/// Per-channel modality assessments, in channel order.
pub modality: Vec<(String, Assessment)>,
/// Flagged deviations, derived from the anomalous assessments above.
pub anomalies: Vec<AnomalyEvent>,
}
impl ObserveOutcome {
/// True when any channel deviated beyond the significance gate.
#[must_use]
pub fn has_anomaly(&self) -> bool {
!self.anomalies.is_empty()
}
/// The distinct anomaly kinds flagged, in first-seen order.
#[must_use]
pub fn anomaly_kinds(&self) -> Vec<AnomalyKind> {
let mut out = Vec::new();
for ev in &self.anomalies {
if !out.contains(&ev.kind) {
out.push(ev.kind);
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use ruview_evidence::{EvidenceContext, EvidenceLevel as EvLevel, EvidenceLedger};
use ruview_twin::{synthetic_deployment, LinkId, ObservationSet, RfTwin, SensorId};
fn cfg() -> MemoryConfig {
MemoryConfig::default_synthetic()
}
fn zone() -> ZoneId {
ZoneId::new("bedroom").unwrap()
}
fn sensor(id: &str) -> SensorId {
SensorId::new(id).unwrap()
}
/// UTC-ms for a given day index and hour, so occupancy buckets are addressable
/// deterministically without a wall-clock.
fn ts(day: i64, hour: i64) -> i64 {
day * 86_400_000 + hour * 3_600_000
}
#[test]
fn no_anomaly_within_normal_variation() {
let mut mem = SpatialMemory::new(cfg()).unwrap();
let twin = RfTwin::build(synthetic_deployment(7)).unwrap();
mem.seed_zone_propagation_from_twin(zone(), &twin).unwrap();
let predicted = ObservationSet::from_twin_prediction(&twin);
let mut last = None;
for day in 0..12 {
let mut obs = ZoneObservation::new(zone(), ts(day, 14), 1.0, EvidenceLevel::L0);
obs.links = predicted.clone();
let outcome = mem.observe(&obs).unwrap();
// A snapshot matching the twin prediction never flags an anomaly.
assert!(!outcome.has_anomaly(), "day {day} should be within normal variation");
// Propagation is assessable from the twin prior and stays Normal.
assert!(outcome.propagation.iter().all(|(_, a)| !a.is_anomalous()));
last = Some(outcome);
}
// After enough history the occupancy bucket is Normal (not Unknown).
let last = last.unwrap();
assert!(matches!(last.occupancy, Assessment::Normal { .. }));
}
#[test]
fn flags_a_propagation_signature_delta() {
let mut mem = SpatialMemory::new(cfg()).unwrap();
let twin = RfTwin::build(synthetic_deployment(2)).unwrap();
mem.seed_zone_propagation_from_twin(zone(), &twin).unwrap();
// One link observed far from its twin-anchored normal (a "chair moved" /
// "new reflector" style propagation change).
let target = twin.links()[0].clone();
let predicted_mean = mem
.baseline(&zone())
.unwrap()
.link_stat(&target)
.unwrap()
.mean();
let mut obs = ZoneObservation::new(zone(), ts(0, 12), 1.0, EvidenceLevel::L1);
obs.links = ObservationSet::new().with(target.clone(), predicted_mean + 30.0);
let outcome = mem.observe(&obs).unwrap();
assert!(outcome.has_anomaly());
assert!(outcome.anomaly_kinds().contains(&AnomalyKind::PropagationChange));
let (_, a) = &outcome.propagation[0];
assert!(a.is_anomalous());
// The anomaly's evidence level is the SYNTHETIC/L0 twin-prior floor,
// never above the source.
let ev = &outcome.anomalies[0];
assert_eq!(ev.kind, AnomalyKind::PropagationChange);
assert_eq!(ev.evidence_level, EvidenceLevel::L0);
assert!(ev.significance >= cfg().significance_threshold);
}
#[test]
fn flags_off_hours_occupancy() {
let mut mem = SpatialMemory::new(cfg()).unwrap();
// Learn that hour 3 (night) is normally unoccupied, and hour 14 (day) is
// normally occupied.
for day in 0..12 {
let night = ZoneObservation::new(zone(), ts(day, 3), 0.0, EvidenceLevel::L2);
let day_obs = ZoneObservation::new(zone(), ts(day, 14), 1.0, EvidenceLevel::L2);
assert!(!mem.observe(&night).unwrap().has_anomaly());
assert!(!mem.observe(&day_obs).unwrap().has_anomaly());
}
// Occupied at 3am: an unusual-occupancy-hour deviation.
let off = ZoneObservation::new(zone(), ts(99, 3), 1.0, EvidenceLevel::L2);
let outcome = mem.observe(&off).unwrap();
assert!(outcome.occupancy.is_anomalous());
assert!(outcome.anomaly_kinds().contains(&AnomalyKind::UnusualOccupancyHour));
// Occupied at 2pm is normal, not flagged.
let normal = ZoneObservation::new(zone(), ts(100, 14), 1.0, EvidenceLevel::L2);
let outcome = mem.observe(&normal).unwrap();
assert!(matches!(outcome.occupancy, Assessment::Normal { .. }));
assert!(!outcome.has_anomaly());
}
#[test]
fn insufficient_history_is_unknown_not_false_positive() {
let mut mem = SpatialMemory::new(cfg()).unwrap();
// No baseline anywhere, and a wildly off snapshot: every channel is
// UNKNOWN (first-class), and nothing is flagged.
let obs = ZoneObservation::new(zone(), ts(0, 3), 999.0, EvidenceLevel::L2)
.with_link(LinkId::new(sensor("a"), sensor("b")), -999.0)
.with_modality("vibration_rms", 999.0);
let outcome = mem.observe(&obs).unwrap();
assert!(outcome.occupancy.is_unknown());
assert!(outcome.propagation.iter().all(|(_, a)| a.is_unknown()));
assert!(outcome.modality.iter().all(|(_, a)| a.is_unknown()));
assert!(!outcome.has_anomaly(), "must not false-positive on thin history");
}
#[test]
fn baseline_update_is_deterministic_and_serde_round_trips() {
let build = || {
let mut mem = SpatialMemory::new(cfg()).unwrap();
let twin = RfTwin::build(synthetic_deployment(5)).unwrap();
mem.seed_zone_propagation_from_twin(zone(), &twin).unwrap();
let predicted = ObservationSet::from_twin_prediction(&twin);
for day in 0..10 {
let mut obs = ZoneObservation::new(zone(), ts(day, 9), 1.0, EvidenceLevel::L1);
obs.links = predicted.clone();
obs.modality.insert("vibration_rms".into(), 0.5);
mem.observe(&obs).unwrap();
}
mem
};
// Same inputs in the same order ⇒ bitwise-identical learned state. (The
// update recursion is a pure, deterministic function of the input stream;
// see `stat::tests` for the per-statistic proof.)
let a = build();
let b = build();
assert_eq!(a, b, "same observations in the same order ⇒ identical state");
let json = serde_json::to_string(&a).unwrap();
let back: SpatialMemory = serde_json::from_str(&json).unwrap();
assert_eq!(a, back);
}
#[test]
fn flags_a_modality_signature_delta() {
let mut mem = SpatialMemory::new(cfg()).unwrap();
// Learn a normal vibration signature (constant baseline).
for day in 0..12 {
let obs = ZoneObservation::new(zone(), ts(day, 10), 1.0, EvidenceLevel::L2)
.with_modality("vibration_rms", 0.5);
assert!(!mem.observe(&obs).unwrap().has_anomaly());
}
// A machine whose vibration signature changed: a modality deviation.
let obs = ZoneObservation::new(zone(), ts(99, 10), 1.0, EvidenceLevel::L2)
.with_modality("vibration_rms", 5.0);
let outcome = mem.observe(&obs).unwrap();
assert!(outcome.anomaly_kinds().contains(&AnomalyKind::ModalityChange));
assert!(outcome.modality[0].1.is_anomalous());
}
#[test]
fn anomalies_emit_synthetic_evidence_records_with_provenance() {
let mut mem = SpatialMemory::new(cfg()).unwrap();
let twin = RfTwin::build(synthetic_deployment(3)).unwrap();
mem.seed_zone_propagation_from_twin(zone(), &twin).unwrap();
let target = twin.links()[0].clone();
let predicted_mean = mem
.baseline(&zone())
.unwrap()
.link_stat(&target)
.unwrap()
.mean();
let mut obs = ZoneObservation::new(zone(), ts(0, 12), 1.0, EvidenceLevel::L1);
obs.links = ObservationSet::new().with(target.clone(), predicted_mean - 40.0);
let outcome = mem.observe(&obs).unwrap();
assert!(outcome.has_anomaly());
let mut ledger = EvidenceLedger::new();
let seqs = mem.record_anomalies(&outcome, &mut ledger, 42).unwrap();
assert_eq!(seqs.len(), outcome.anomalies.len());
assert!(!ledger.is_empty());
let ev = &outcome.anomalies[0];
let ctx = EvidenceContext::new(
ev.zone.as_str(),
"spatial-memory-scaffold",
format!("anomaly:{}", ev.kind.label()),
&mem.config().model_version,
)
.unwrap();
let slice = ledger.query(&ctx);
assert_eq!(slice.len(), 1);
let rec = slice.records()[0];
// Emitted evidence is honest: synthetic, forced L0.
assert_eq!(rec.level(), EvLevel::L0);
// The deviation magnitude is carried as drift.
assert!((rec.metrics().drift - ev.deviation().abs()).abs() < 1e-9);
assert!((rec.metrics().uncertainty - ev.significance).abs() < 1e-9);
}
}
+185
View File
@@ -0,0 +1,185 @@
//! Online, forgetting running statistics (ADR-309 §2 — continuously learned
//! baseline).
//!
//! **SYNTHETIC / L0.** A [`RunningStat`] is a bounded, deterministic model of a
//! single scalar's *normal* value: an exponentially weighted mean and variance
//! that update online with a documented **forgetting factor**. It is part of a
//! simulation scaffold — it estimates a modelled normal, it never *measures*
//! anything, and it makes no accuracy claim (ADR-282 L0, ADR-297 evidence
//! discipline).
//!
//! ## The forgetting factor
//!
//! The forgetting factor `λ ∈ [0, 1)` is the weight retained on accumulated
//! history at each update; the effective learning rate is `α = 1 λ`. A value
//! near `1` adapts slowly (long memory, tracks only slow legitimate drift); a
//! value near `0` adapts fast (short memory). Update rule (the standard
//! exponentially weighted moving mean/variance):
//!
//! ```text
//! diff = x mean
//! incr = α · diff
//! mean ← mean + incr
//! var ← λ · (var + diff · incr) // = λ·(var + α·diff²) ≥ 0
//! ```
//!
//! The recursion keeps `var ≥ 0` exactly, so `sqrt` is always defined. There is
//! no wall-clock and no randomness anywhere: the same inputs in the same order
//! always yield the same state (ADR-297 determinism discipline).
use serde::{Deserialize, Serialize};
/// An exponentially weighted running mean/variance with an update count.
///
/// **SYNTHETIC / L0.** A modelled estimate of a scalar's normal value, never a
/// measurement.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct RunningStat {
mean: f64,
var: f64,
count: u32,
}
impl Default for RunningStat {
fn default() -> Self {
Self::new()
}
}
impl RunningStat {
/// A fresh statistic with no history (`count == 0`).
#[must_use]
pub const fn new() -> Self {
Self {
mean: 0.0,
var: 0.0,
count: 0,
}
}
/// A statistic pre-seeded from an external prior — used to anchor a
/// propagation baseline on the twin's expected distribution (ADR-309 §1:
/// "RF-propagation baseline … via the twin's expected distributions").
///
/// `count` is the synthetic prior weight; a non-finite mean/variance is
/// clamped to a valid `(mean = if finite else 0, var = max(0))` so the
/// seeded stat never carries a poisoned value.
#[must_use]
pub fn seeded(mean: f64, var: f64, count: u32) -> Self {
Self {
mean: if mean.is_finite() { mean } else { 0.0 },
var: if var.is_finite() { var.max(0.0) } else { 0.0 },
count,
}
}
/// Fold one observation into the estimate with the given forgetting factor
/// `λ ∈ [0, 1)`. Deterministic; total (never panics). The first observation
/// seeds the mean exactly and leaves the variance at zero.
pub fn update(&mut self, x: f64, forgetting: f64) {
if !x.is_finite() {
return; // malformed value is ignored, never panics or poisons state
}
if self.count == 0 {
self.mean = x;
self.var = 0.0;
self.count = 1;
return;
}
let alpha = 1.0 - forgetting;
let diff = x - self.mean;
let incr = alpha * diff;
self.mean += incr;
// λ·(var + α·diff²): the α·diff² term is non-negative, so var stays ≥ 0.
self.var = forgetting * (self.var + diff * incr);
if !self.var.is_finite() {
self.var = 0.0;
}
self.count = self.count.saturating_add(1);
}
/// The current modelled mean.
#[must_use]
pub fn mean(&self) -> f64 {
self.mean
}
/// The current modelled (exponentially weighted) variance, always `≥ 0`.
#[must_use]
pub fn variance(&self) -> f64 {
self.var
}
/// The number of observations folded in so far (seed weight included).
#[must_use]
pub fn count(&self) -> u32 {
self.count
}
/// The standard deviation, floored at `floor` so significance is finite even
/// for a degenerate zero-variance baseline (a channel that has only ever
/// held one value). `floor` is expected to be `> 0` (config-validated).
#[must_use]
pub fn std_floored(&self, floor: f64) -> f64 {
self.var.max(0.0).sqrt().max(floor)
}
/// How many floored standard deviations `x` sits from the learned mean — the
/// deviation significance. Non-negative and finite whenever `floor > 0`.
#[must_use]
pub fn significance(&self, x: f64, floor: f64) -> f64 {
let std = self.std_floored(floor);
if std > 0.0 {
(x - self.mean).abs() / std
} else {
0.0
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn first_update_seeds_mean_and_zero_variance() {
let mut s = RunningStat::new();
s.update(10.0, 0.9);
assert_eq!(s.count(), 1);
assert_eq!(s.mean(), 10.0);
assert_eq!(s.variance(), 0.0);
}
#[test]
fn variance_never_negative_and_update_is_deterministic() {
let run = || {
let mut s = RunningStat::new();
for x in [1.0, 2.0, 1.5, 1.7, 1.6, 1.55] {
s.update(x, 0.8);
}
s
};
let a = run();
let b = run();
assert_eq!(a, b);
assert!(a.variance() >= 0.0);
}
#[test]
fn non_finite_value_is_ignored_not_panicking() {
let mut s = RunningStat::new();
s.update(f64::NAN, 0.9);
assert_eq!(s.count(), 0);
s.update(5.0, 0.9);
s.update(f64::INFINITY, 0.9);
assert_eq!(s.count(), 1);
assert_eq!(s.mean(), 5.0);
}
#[test]
fn significance_is_finite_under_zero_variance_floor() {
let s = RunningStat::seeded(0.0, 0.0, 10);
// std floored at 0.1 ⇒ significance of 1.0 is 10 sigma, finite.
assert!((s.significance(1.0, 0.1) - 10.0).abs() < 1e-9);
}
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "ruview-placement"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
thiserror.workspace = true
serde = { workspace = true, features = ["derive"] }
ruview-ontology = { path = "../ruview-ontology" }
ruview-twin = { path = "../ruview-twin" }
[dev-dependencies]
serde_json.workspace = true
+317
View File
@@ -0,0 +1,317 @@
//! Post-install loop: predicted vs. measured observability → adjustments
//! (ADR-305 §3).
//!
//! **This crate never measures.** The `measured` observability values are
//! supplied by the caller — the ADR-299 runtime observability signal from freshly
//! enrolled, calibrated sensors — and this module only *compares* them against the
//! optimizer's own SYNTHETIC/L0 prediction. The predicted side stays labelled
//! `L0`; a `MEASURED` statement, if any, belongs to the caller's measured input
//! together with its reproducer (CLAUDE.md hardware rule). Where measurement
//! disagrees with prediction, the module recommends an adjustment and a coarse
//! twin-parameter residual to feed back into the ADR-312 twin. Following ADR-297
//! rule 1, a target with no measured value yields a first-class UNKNOWN verdict,
//! never an error.
use serde::{Deserialize, Serialize};
use ruview_ontology::{Container, EvidenceLevel};
use crate::coverage::{Observability, PlacementScore};
/// Default tolerance (in observability units) inside which predicted and measured
/// are treated as matching.
pub const DEFAULT_COMPARE_TOLERANCE: f64 = 0.15;
/// Coarse dB of implied effective attenuation per unit of observability shortfall,
/// used only to suggest a twin-parameter residual to feed back into ADR-312. A
/// rough SYNTHETIC heuristic, not a calibrated figure.
const RESIDUAL_DB_PER_UNIT: f64 = 30.0;
/// A single caller-supplied measured observability for a target.
///
/// The value's evidence level is the *caller's* to assert (with a reproducer, per
/// the hardware rule); this crate carries it through unchanged.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MeasuredTarget {
/// The target region measured.
pub target: Container,
/// The caller-supplied observed observability score, `[0, 1]`.
pub observed_score: f64,
}
/// A set of caller-supplied measured observability values.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
pub struct MeasuredObservability {
/// Per-target measurements.
pub per_target: Vec<MeasuredTarget>,
}
impl MeasuredObservability {
/// An empty measurement set.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Add one measured target and return `self` for chaining.
#[must_use]
pub fn with(mut self, target: Container, observed_score: f64) -> Self {
self.per_target.push(MeasuredTarget { target, observed_score });
self
}
/// The measured score for a target, if present.
#[must_use]
fn score_for(&self, target: &Container) -> Option<f64> {
self.per_target
.iter()
.find(|m| &m.target == target)
.map(|m| m.observed_score)
}
}
/// The verdict comparing predicted and measured observability for one target.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CompareVerdict {
/// Measured is within tolerance of predicted.
Match,
/// Measured is materially below predicted (reality is worse than the model).
Underperforming,
/// Measured is materially above predicted (the model was pessimistic).
Overperforming,
/// Cannot compare (no measured value, or prediction was UNKNOWN). First-class
/// UNKNOWN (ADR-297 rule 1).
Unknown,
}
/// The recommended action for a target.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AdjustmentAction {
/// Nothing to change; prediction and measurement agree.
NoActionNeeded,
/// Re-aim / reposition an existing node (cheap first move — favoured when the
/// prediction itself was uncertain).
ReAim,
/// Move a node materially, or accept a larger geometry change.
MoveNode,
/// Add another node to recover the objective.
AddNode,
/// Not enough information to recommend anything (measurement missing/UNKNOWN).
InsufficientData,
}
/// Direction of a suggested twin-parameter residual.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ResidualKind {
/// Reality attenuates more than the twin modelled (measured < predicted).
EffectiveAttenuationHigher,
/// Reality attenuates less than the twin modelled (measured > predicted).
EffectiveAttenuationLower,
}
/// A coarse twin-parameter residual to feed back into the ADR-312 twin.
///
/// **SYNTHETIC / L0.** A rough model-improvement hint, not a calibrated value.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct TwinResidual {
/// Which way the twin's effective attenuation should move.
pub kind: ResidualKind,
/// Rough magnitude of the suggested effective-attenuation change, dB.
pub magnitude_db: f64,
}
/// A recommended adjustment for one target.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Adjustment {
/// The target this adjustment concerns.
pub target: Container,
/// The recommended action.
pub action: AdjustmentAction,
/// Human-readable rationale.
pub rationale: String,
/// Coarse twin-parameter residual to feed back into ADR-312, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub twin_residual: Option<TwinResidual>,
}
/// The predicted-vs-measured comparison for one target.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TargetComparison {
/// The target region.
pub target: Container,
/// The optimizer's predicted observability (SYNTHETIC/L0).
pub predicted: Observability,
/// The caller-supplied measured score, if present.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub measured: Option<f64>,
/// `measured - predicted_score`, when both are available.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delta: Option<f64>,
/// The verdict.
pub verdict: CompareVerdict,
}
/// The full post-install adjustment report.
///
/// The predicted side is `L0` (SYNTHETIC); the measured side is caller-supplied.
/// This report is a set of recommendations, never a sensing claim.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct AdjustmentReport {
/// Per-target comparisons.
pub comparisons: Vec<TargetComparison>,
/// Recommended adjustments (targets needing action).
pub adjustments: Vec<Adjustment>,
/// Evidence level of the *predicted* side. Always `L0` (SYNTHETIC).
pub predicted_evidence_level: EvidenceLevel,
}
impl AdjustmentReport {
/// True when at least one target needs a corrective action.
#[must_use]
pub fn needs_adjustment(&self) -> bool {
self.adjustments.iter().any(|a| {
!matches!(
a.action,
AdjustmentAction::NoActionNeeded | AdjustmentAction::InsufficientData
)
})
}
}
/// Compare predicted against measured observability using the default tolerance.
#[must_use]
pub fn compare_post_install(
predicted: &PlacementScore,
measured: &MeasuredObservability,
) -> AdjustmentReport {
compare_post_install_with_tolerance(predicted, measured, DEFAULT_COMPARE_TOLERANCE)
}
/// Compare predicted against measured observability with an explicit tolerance.
///
/// Deterministic and never panics. A target whose prediction is UNKNOWN, or that
/// has no measured value, yields a [`CompareVerdict::Unknown`] and an
/// [`AdjustmentAction::InsufficientData`] recommendation (ADR-297 rule 1).
#[must_use]
pub fn compare_post_install_with_tolerance(
predicted: &PlacementScore,
measured: &MeasuredObservability,
tolerance: f64,
) -> AdjustmentReport {
let tol = if tolerance.is_finite() && tolerance >= 0.0 {
tolerance
} else {
DEFAULT_COMPARE_TOLERANCE
};
let mut comparisons = Vec::with_capacity(predicted.per_target.len());
let mut adjustments = Vec::new();
for cov in &predicted.per_target {
let target = cov.target.clone();
let measured_score = measured.score_for(&target).filter(|v| v.is_finite());
let (predicted_score, predicted_uncertainty) = match cov.observability.known() {
Some((s, u)) => (Some(s), u),
None => (None, 1.0),
};
match (predicted_score, measured_score) {
(Some(pred), Some(meas)) => {
let delta = meas - pred;
let (verdict, action, residual) = classify(delta, tol, predicted_uncertainty);
let rationale = rationale_for(action, delta, pred, meas);
comparisons.push(TargetComparison {
target: target.clone(),
predicted: cov.observability,
measured: Some(meas),
delta: Some(delta),
verdict,
});
adjustments.push(Adjustment { target, action, rationale, twin_residual: residual });
}
_ => {
// Missing measurement or UNKNOWN prediction: first-class UNKNOWN.
comparisons.push(TargetComparison {
target: target.clone(),
predicted: cov.observability,
measured: measured_score,
delta: None,
verdict: CompareVerdict::Unknown,
});
adjustments.push(Adjustment {
target,
action: AdjustmentAction::InsufficientData,
rationale: "no measured observability to compare against prediction".to_string(),
twin_residual: None,
});
}
}
}
AdjustmentReport {
comparisons,
adjustments,
predicted_evidence_level: EvidenceLevel::L0,
}
}
/// Classify a predicted-vs-measured delta into a verdict, action, and residual.
fn classify(
delta: f64,
tolerance: f64,
predicted_uncertainty: f64,
) -> (CompareVerdict, AdjustmentAction, Option<TwinResidual>) {
if delta < -tolerance {
// Reality worse than modelled: recommend a corrective move.
// Favour the cheap re-aim when the prediction itself was uncertain.
let action = if predicted_uncertainty > 0.5 {
AdjustmentAction::ReAim
} else if delta < -2.0 * tolerance {
AdjustmentAction::AddNode
} else {
AdjustmentAction::MoveNode
};
let residual = TwinResidual {
kind: ResidualKind::EffectiveAttenuationHigher,
magnitude_db: (delta.abs() * RESIDUAL_DB_PER_UNIT).min(120.0),
};
(CompareVerdict::Underperforming, action, Some(residual))
} else if delta > tolerance {
// Reality better than modelled: no action, but the twin was pessimistic.
let residual = TwinResidual {
kind: ResidualKind::EffectiveAttenuationLower,
magnitude_db: (delta.abs() * RESIDUAL_DB_PER_UNIT).min(120.0),
};
(CompareVerdict::Overperforming, AdjustmentAction::NoActionNeeded, Some(residual))
} else {
(CompareVerdict::Match, AdjustmentAction::NoActionNeeded, None)
}
}
/// Build a human-readable rationale string.
fn rationale_for(action: AdjustmentAction, delta: f64, predicted: f64, measured: f64) -> String {
match action {
AdjustmentAction::NoActionNeeded => format!(
"measured {measured:.2} matches predicted {predicted:.2} within tolerance"
),
AdjustmentAction::ReAim => format!(
"measured {measured:.2} below predicted {predicted:.2}{delta:.2}); prediction was \
uncertain, so re-aim an existing node first"
),
AdjustmentAction::MoveNode => format!(
"measured {measured:.2} below predicted {predicted:.2}{delta:.2}); move a node to \
recover coverage"
),
AdjustmentAction::AddNode => format!(
"measured {measured:.2} far below predicted {predicted:.2}{delta:.2}); add a node \
to recover the objective"
),
AdjustmentAction::InsufficientData => {
"no measured observability to compare against prediction".to_string()
}
}
}
+570
View File
@@ -0,0 +1,570 @@
//! Coverage / observability scoring of a candidate placement (ADR-305 §2).
//!
//! **SYNTHETIC / L0.** Everything here is a *recommendation derived from a
//! simulation*, never a sensing claim. A candidate placement is scored by
//! consuming the ADR-312 [`RfTwin`] forward model: for a grid of sample points in
//! a target region, a point is "observable" when it lies inside the first Fresnel
//! zone of a well-predicted link ([`crate::fresnel`]). Per target we report an
//! [`Observability`] carrying **both** a modelled score and its uncertainty —
//! never a single confident number for a simulated result (ADR-305 §2). Following
//! ADR-297 rule 1, a target the model cannot evaluate is [`Observability::Unknown`],
//! a first-class value, not an error.
use serde::{Deserialize, Serialize};
use ruview_ontology::{Container, EvidenceLevel, SemanticProvenance, SensorId};
use ruview_twin::{
predict_link, Container as TwinContainer, DeploymentDescription, ExpectedDistribution, Point3,
PropagationParams, RadioNode, RfTwin,
};
use crate::fresnel::link_clearance;
use crate::geometry::{sample_points, FloorPlan, PlacementError};
/// A single placed radio in a candidate placement. Position is reused twin
/// geometry ([`Point3`]); identity is assigned when the twin is built.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct PlacedRadio {
/// Metric position (metres) in the plan frame.
pub position: Point3,
/// Modelled transmit power, dBm.
pub tx_power_dbm: f64,
}
impl PlacedRadio {
/// Construct a placed radio.
#[must_use]
pub const fn new(position: Point3, tx_power_dbm: f64) -> Self {
Self { position, tx_power_dbm }
}
}
/// A candidate set of radio positions to score.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
pub struct Placement {
/// The placed radios.
pub radios: Vec<PlacedRadio>,
}
impl Placement {
/// An empty placement.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Number of placed radios.
#[must_use]
pub fn len(&self) -> usize {
self.radios.len()
}
/// True when no radios are placed.
#[must_use]
pub fn is_empty(&self) -> bool {
self.radios.is_empty()
}
}
/// The phenomenon a sensing objective requires. Higher-order phenomena demand
/// stronger Fresnel clearance to count a point as observable ([`Self::demand`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Phenomenon {
/// Coarse presence / occupancy.
Presence,
/// Vital-sign sensing (stricter clearance demand).
Vitals,
/// Pose estimation (strictest clearance demand).
Pose,
}
impl Phenomenon {
/// Multiplier applied to the base coverage threshold: a stricter phenomenon
/// needs a higher modelled sensing value at a point to count it as covered.
#[must_use]
pub fn demand(self) -> f64 {
match self {
Phenomenon::Presence => 1.0,
Phenomenon::Vitals => 1.6,
Phenomenon::Pose => 2.2,
}
}
}
/// A sensing objective: a phenomenon that must be observable in a target region.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Objective {
/// The space or zone that must be observable.
pub target: Container,
/// The phenomenon required there.
pub phenomenon: Phenomenon,
}
impl Objective {
/// Construct an objective.
#[must_use]
pub fn new(target: Container, phenomenon: Phenomenon) -> Self {
Self { target, phenomenon }
}
}
/// Parameters of the SYNTHETIC coverage model. All defaults are didactic; they
/// assert nothing about any real environment.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct PlacementParams {
/// Modelled wavelength, metres (default ≈ 2.4 GHz).
pub wavelength_m: f64,
/// Modelled RSSI (dBm) at/above which a link has full sensing quality.
pub good_rssi_dbm: f64,
/// Modelled RSSI (dBm) at/below which a link has zero sensing quality.
pub floor_rssi_dbm: f64,
/// Point sensing value (`clearance × quality`) at/above which a sample point
/// counts as covered, before the per-phenomenon demand multiplier.
pub coverage_threshold: f64,
/// Covered-fraction below which a target is flagged as a blind spot.
pub blind_spot_fraction: f64,
/// Sample-grid step, metres.
pub grid_step_m: f64,
/// Candidate-grid step, metres (used by the search).
pub candidate_step_m: f64,
/// Reference variance (dB²) mapping modelled link variance to `[0, 1]`
/// uncertainty.
pub uncertainty_variance_ref_db2: f64,
/// Cap on sample points per target (bounded allocation).
pub max_sample_points: usize,
/// Cap on generated candidate positions (bounded search).
pub max_candidates: usize,
/// Explicit seed for deterministic candidate generation. No RNG anywhere.
pub seed: u64,
/// Twin propagation-model parameters.
pub propagation: PropagationParams,
}
impl PlacementParams {
/// A neutral SYNTHETIC default set.
#[must_use]
pub fn default_synthetic() -> Self {
Self {
wavelength_m: 0.1249,
good_rssi_dbm: -50.0,
floor_rssi_dbm: -85.0,
coverage_threshold: 0.10,
blind_spot_fraction: 0.15,
grid_step_m: 0.5,
candidate_step_m: 1.0,
uncertainty_variance_ref_db2: 64.0,
max_sample_points: 4096,
max_candidates: 512,
seed: 0,
propagation: PropagationParams::default_indoor(),
}
}
/// Validate the parameters at the boundary.
pub fn validate(&self) -> Result<(), PlacementError> {
let finite_pos = |v: f64| v.is_finite() && v > 0.0;
if !finite_pos(self.wavelength_m) {
return Err(PlacementError::InvalidParameter { what: "wavelength_m must be > 0" });
}
if !finite_pos(self.grid_step_m) {
return Err(PlacementError::InvalidParameter { what: "grid_step_m must be > 0" });
}
if !finite_pos(self.candidate_step_m) {
return Err(PlacementError::InvalidParameter { what: "candidate_step_m must be > 0" });
}
if !(self.good_rssi_dbm.is_finite()
&& self.floor_rssi_dbm.is_finite()
&& self.good_rssi_dbm > self.floor_rssi_dbm)
{
return Err(PlacementError::InvalidParameter {
what: "good_rssi_dbm must be finite and > floor_rssi_dbm",
});
}
if !(self.coverage_threshold.is_finite() && self.coverage_threshold > 0.0) {
return Err(PlacementError::InvalidParameter { what: "coverage_threshold must be > 0" });
}
if !(self.uncertainty_variance_ref_db2.is_finite() && self.uncertainty_variance_ref_db2 > 0.0)
{
return Err(PlacementError::InvalidParameter {
what: "uncertainty_variance_ref_db2 must be > 0",
});
}
if self.max_sample_points == 0 || self.max_candidates == 0 {
return Err(PlacementError::InvalidParameter {
what: "sample/candidate caps must be > 0",
});
}
// Mirror the twin's propagation-parameter domain (its own validator is
// private); the twin re-checks these on build regardless.
let p = &self.propagation;
if !(p.path_loss_exponent.is_finite() && p.path_loss_exponent > 0.0)
|| !(p.reference_distance_m.is_finite() && p.reference_distance_m > 0.0)
|| !p.reference_loss_db.is_finite()
|| !(p.shadowing_sigma_db.is_finite() && p.shadowing_sigma_db >= 0.0)
{
return Err(PlacementError::InvalidParameter { what: "invalid propagation params" });
}
Ok(())
}
}
/// Why a target's observability is unknown. UNKNOWN is a first-class output
/// (ADR-297 rule 1), not an error.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ObservabilityUnknown {
/// The objective's target is not present in this floor plan.
TargetNotInPlan,
/// The target region produced no sample points (degenerate geometry).
EmptyRegion,
/// Fewer than two placed radios, so the twin has no links to evaluate.
NoLinks,
/// The modelled computation produced a non-finite value.
NonFinite,
}
/// Modelled observability of a target region.
///
/// **SYNTHETIC / L0.** A model-relative statement carrying its own uncertainty,
/// never evidence that a region *is* being sensed.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum Observability {
/// A modelled observability `score` in `[0, 1]` and its `uncertainty` in
/// `[0, 1]`. Both are reported; neither is a confident single number.
Known {
/// Mean modelled sensing value over the region, `[0, 1]`.
score: f64,
/// Modelled uncertainty of that score, `[0, 1]` (higher = less certain).
uncertainty: f64,
},
/// The model cannot evaluate this target; carries a first-class reason.
Unknown {
/// Why it is unknown.
reason: ObservabilityUnknown,
},
}
impl Observability {
/// Borrow `(score, uncertainty)` when known.
#[must_use]
pub fn known(&self) -> Option<(f64, f64)> {
match self {
Observability::Known { score, uncertainty } => Some((*score, *uncertainty)),
Observability::Unknown { .. } => None,
}
}
}
/// Per-target coverage detail.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TargetCoverage {
/// The target region.
pub target: Container,
/// The phenomenon required there.
pub phenomenon: Phenomenon,
/// Modelled observability (score + uncertainty, or UNKNOWN).
pub observability: Observability,
/// Fraction of sample points that met the (phenomenon-scaled) coverage
/// threshold, `[0, 1]`.
pub covered_fraction: f64,
/// Number of sample points evaluated.
pub sample_count: usize,
/// True when this target is flagged as a blind spot.
pub blind_spot: bool,
}
/// The score of a candidate placement across all objectives.
///
/// **SYNTHETIC / L0.** A recommendation, never a sensing claim.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PlacementScore {
/// Sample-weighted mean of the per-target modelled scores (Unknown targets
/// contribute nothing), `[0, 1]`.
pub total_score: f64,
/// Per-target coverage detail.
pub per_target: Vec<TargetCoverage>,
/// Targets flagged as blind spots (a subset of `per_target`, by container).
pub blind_spots: Vec<Container>,
/// Number of radios in the scored placement.
pub node_count: usize,
/// Evidence level of this score. Always `L0` (SYNTHETIC).
pub evidence_level: EvidenceLevel,
/// Provenance travelling with the score.
pub provenance: SemanticProvenance,
}
impl PlacementScore {
/// True when at least one target is flagged as a blind spot.
#[must_use]
pub fn has_blind_spot(&self) -> bool {
!self.blind_spots.is_empty()
}
}
/// A precomputed link with the geometry and modelled quality the coverage model
/// needs. Internal.
struct LinkGeom {
a: (f64, f64),
b: (f64, f64),
quality: f64,
variance: f64,
}
/// Map a modelled RSSI mean to a sensing quality in `[0, 1]`.
fn quality_from_mean(mean: f64, params: &PlacementParams) -> f64 {
if !mean.is_finite() {
return 0.0;
}
let span = params.good_rssi_dbm - params.floor_rssi_dbm;
((mean - params.floor_rssi_dbm) / span).clamp(0.0, 1.0)
}
/// Build the twin from a placement and derive per-link geometry + quality. An
/// empty result means "no evaluable links" (fewer than two finite nodes, or the
/// twin rejected the scene) — surfaced as UNKNOWN by callers, never a panic.
fn build_links(plan: &FloorPlan, placement: &Placement, params: &PlacementParams) -> Vec<LinkGeom> {
let space = plan.space.clone();
let mut nodes: Vec<RadioNode> = Vec::new();
for (i, radio) in placement.radios.iter().enumerate() {
if !radio.position.is_finite() || !radio.tx_power_dbm.is_finite() {
continue;
}
let id = match SensorId::new(format!("place-{i}")) {
Ok(id) => id,
Err(_) => continue,
};
nodes.push(RadioNode {
id,
position: radio.position,
located_in: TwinContainer::Space { id: space.clone() },
tx_power_dbm: radio.tx_power_dbm,
});
}
if nodes.len() < 2 {
return Vec::new();
}
let desc = DeploymentDescription {
space,
nodes,
walls: plan.walls.clone(),
params: params.propagation,
multipath: Vec::new(),
calibration_version: "synthetic-placement".to_string(),
seed: 0,
};
let twin = match RfTwin::build(desc) {
Ok(twin) => twin,
Err(_) => return Vec::new(),
};
let mut links = Vec::new();
for link in twin.links() {
if let ExpectedDistribution::Known { mean, variance, .. } = predict_link(&twin, &link) {
let (Some(na), Some(nb)) = (twin.node(&link.a), twin.node(&link.b)) else {
continue;
};
links.push(LinkGeom {
a: na.position.xy(),
b: nb.position.xy(),
quality: quality_from_mean(mean, params),
variance,
});
}
}
links
}
/// The best modelled sensing value at a point and the variance of the link that
/// achieved it. Sensing is `max over links of clearance × quality`.
fn point_sensing(links: &[LinkGeom], p: (f64, f64), params: &PlacementParams) -> (f64, f64) {
let mut best = 0.0_f64;
let mut best_var = params.uncertainty_variance_ref_db2;
for link in links {
let s = link_clearance(link.a, link.b, p, params.wavelength_m) * link.quality;
if s > best {
best = s;
best_var = link.variance;
}
}
(best, best_var)
}
/// Score one target region against the precomputed links.
fn score_target(
plan: &FloorPlan,
links: &[LinkGeom],
objective: &Objective,
params: &PlacementParams,
) -> TargetCoverage {
let phenomenon = objective.phenomenon;
let target = objective.target.clone();
let region = match plan.region_for(&target) {
Some(r) => r,
None => {
return TargetCoverage {
target,
phenomenon,
observability: Observability::Unknown {
reason: ObservabilityUnknown::TargetNotInPlan,
},
covered_fraction: 0.0,
sample_count: 0,
blind_spot: false,
};
}
};
let samples = sample_points(&region, params.grid_step_m, params.max_sample_points);
if samples.is_empty() {
return TargetCoverage {
target,
phenomenon,
observability: Observability::Unknown { reason: ObservabilityUnknown::EmptyRegion },
covered_fraction: 0.0,
sample_count: 0,
blind_spot: false,
};
}
if links.is_empty() {
// No links to evaluate: genuinely unknown, and a blind spot by definition.
return TargetCoverage {
target,
phenomenon,
observability: Observability::Unknown { reason: ObservabilityUnknown::NoLinks },
covered_fraction: 0.0,
sample_count: samples.len(),
blind_spot: true,
};
}
let threshold = (params.coverage_threshold * phenomenon.demand()).clamp(f64::MIN_POSITIVE, 1.0);
let n = samples.len() as f64;
let mut score_sum = 0.0_f64;
let mut unc_sum = 0.0_f64;
let mut covered = 0usize;
for &p in &samples {
let (sensing, var) = point_sensing(links, p, params);
score_sum += sensing;
if sensing >= threshold {
covered += 1;
unc_sum += (var / params.uncertainty_variance_ref_db2).clamp(0.0, 1.0);
} else {
// An uncovered point is maximally uncertain.
unc_sum += 1.0;
}
}
let score = (score_sum / n).clamp(0.0, 1.0);
let uncertainty = (unc_sum / n).clamp(0.0, 1.0);
let covered_fraction = covered as f64 / n;
let blind_spot = covered_fraction < params.blind_spot_fraction;
let observability = if score.is_finite() && uncertainty.is_finite() {
Observability::Known { score, uncertainty }
} else {
Observability::Unknown { reason: ObservabilityUnknown::NonFinite }
};
TargetCoverage {
target,
phenomenon,
observability,
covered_fraction,
sample_count: samples.len(),
blind_spot,
}
}
/// Provenance stamped on every placement result (SYNTHETIC/L0).
fn placement_provenance() -> SemanticProvenance {
SemanticProvenance::declared("ruview-placement@0 (SYNTHETIC/L0)")
}
/// Score a candidate placement against a set of objectives over a floor plan.
///
/// **SYNTHETIC / L0.** Never panics: malformed geometry or an out-of-plan target
/// yields [`Observability::Unknown`] for that target, not an error. The returned
/// score is `EvidenceLevel::L0` — a recommendation, never a sensing claim.
#[must_use]
pub fn score_placement(
plan: &FloorPlan,
placement: &Placement,
objectives: &[Objective],
params: &PlacementParams,
) -> PlacementScore {
let links = build_links(plan, placement, params);
let mut per_target = Vec::with_capacity(objectives.len());
let mut blind_spots = Vec::new();
let mut weighted_score = 0.0_f64;
let mut weight = 0.0_f64;
for objective in objectives {
let cov = score_target(plan, &links, objective, params);
if let Observability::Known { score, .. } = cov.observability {
let w = cov.sample_count as f64;
weighted_score += score * w;
weight += w;
}
if cov.blind_spot {
blind_spots.push(cov.target.clone());
}
per_target.push(cov);
}
let total_score = if weight > 0.0 { weighted_score / weight } else { 0.0 };
PlacementScore {
total_score,
per_target,
blind_spots,
node_count: placement.radios.len(),
evidence_level: EvidenceLevel::L0,
provenance: placement_provenance(),
}
}
/// A placement paired with its computed score and its position in the input list.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RankedPlacement {
/// Index of this placement in the input slice.
pub index: usize,
/// The placement.
pub placement: Placement,
/// Its score.
pub score: PlacementScore,
}
/// Rank candidate placements by modelled total score, highest first.
///
/// Deterministic: ties break by ascending input index, so the same inputs always
/// yield the same ranking. SYNTHETIC/L0.
#[must_use]
pub fn rank_placements(
plan: &FloorPlan,
placements: &[Placement],
objectives: &[Objective],
params: &PlacementParams,
) -> Vec<RankedPlacement> {
let mut ranked: Vec<RankedPlacement> = placements
.iter()
.enumerate()
.map(|(index, placement)| RankedPlacement {
index,
placement: placement.clone(),
score: score_placement(plan, placement, objectives, params),
})
.collect();
ranked.sort_by(|x, y| {
y.score
.total_score
.partial_cmp(&x.score.total_score)
.unwrap_or(std::cmp::Ordering::Equal)
.then(x.index.cmp(&y.index))
});
ranked
}
+118
View File
@@ -0,0 +1,118 @@
//! Fresnel-zone geometry for link observability (ADR-305 §2).
//!
//! **SYNTHETIC / L0.** WiFi sensing perturbs a link when the target sits inside
//! the link's first Fresnel zone. This module implements that geometry as a
//! deliberately simple, documented analytic model — the first Fresnel radius and
//! a clearance factor for a point relative to a link line — not real RF and not a
//! measurement. Everything is deterministic and allocation-free.
/// First Fresnel-zone radius (metres) at a point that splits the path into
/// longitudinal legs `d1` and `d2`:
///
/// `F1 = sqrt(λ · d1 · d2 / (d1 + d2))`.
///
/// Returns `0.0` for non-finite or non-physical inputs (never `NaN`/`inf`); the
/// caller treats a zero radius as "no clearance information", not a divide-by-zero.
/// At the midpoint (`d1 == d2 == L/2`) this reduces to `0.5·sqrt(λ·L)`, the
/// known analytic maximum used in tests.
#[must_use]
pub fn fresnel_radius(wavelength_m: f64, d1: f64, d2: f64) -> f64 {
if !(wavelength_m.is_finite() && d1.is_finite() && d2.is_finite()) {
return 0.0;
}
let sum = d1 + d2;
if wavelength_m <= 0.0 || d1 < 0.0 || d2 < 0.0 || sum <= 0.0 {
return 0.0;
}
let r = wavelength_m * d1 * d2 / sum;
if r.is_finite() && r >= 0.0 {
r.sqrt()
} else {
0.0
}
}
/// Clearance factor in `[0, 1]` for point `p` relative to the link line `a → b`,
/// at wavelength `λ`.
///
/// - `1.0` on the link line, falling linearly to `0.0` at the first Fresnel-zone
/// boundary and `0.0` beyond it.
/// - `0.0` when `p` does not project *between* the endpoints (a target off the
/// ends of a link is not in its sensing corridor).
/// - `0.0` for a degenerate (coincident-endpoint) link.
///
/// SYNTHETIC geometry, not an RF measurement. Deterministic.
#[must_use]
pub fn link_clearance(a: (f64, f64), b: (f64, f64), p: (f64, f64), wavelength_m: f64) -> f64 {
let abx = b.0 - a.0;
let aby = b.1 - a.1;
let len2 = abx * abx + aby * aby;
if !len2.is_finite() || len2 <= 1e-12 {
return 0.0;
}
let apx = p.0 - a.0;
let apy = p.1 - a.1;
let t = (apx * abx + apy * aby) / len2;
if !t.is_finite() || !(0.0..=1.0).contains(&t) {
return 0.0;
}
let len = len2.sqrt();
let d1 = t * len;
let d2 = (1.0 - t) * len;
// Perpendicular distance from p to the foot of the projection on the line.
let foot_x = a.0 + t * abx;
let foot_y = a.1 + t * aby;
let h = ((p.0 - foot_x).powi(2) + (p.1 - foot_y).powi(2)).sqrt();
let f1 = fresnel_radius(wavelength_m, d1, d2);
if !f1.is_finite() || f1 <= 0.0 || !h.is_finite() {
return 0.0;
}
let clearance = 1.0 - h / f1;
if clearance.is_finite() {
clearance.clamp(0.0, 1.0)
} else {
0.0
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fresnel_radius_matches_analytic_midpoint() {
// Midpoint of a path of length L: F1 = 0.5·sqrt(λ·L).
let wavelength = 0.125_f64; // ~2.4 GHz
let l = 4.0_f64;
let (d1, d2) = (l / 2.0, l / 2.0);
let expected = 0.5 * (wavelength * l).sqrt();
assert!((fresnel_radius(wavelength, d1, d2) - expected).abs() < 1e-12);
}
#[test]
fn fresnel_radius_is_zero_for_non_physical_input() {
assert_eq!(fresnel_radius(f64::NAN, 1.0, 1.0), 0.0);
assert_eq!(fresnel_radius(-1.0, 1.0, 1.0), 0.0);
assert_eq!(fresnel_radius(0.125, 0.0, 0.0), 0.0);
assert_eq!(fresnel_radius(0.125, -1.0, 2.0), 0.0);
}
#[test]
fn clearance_is_one_on_the_line_and_zero_off_the_ends() {
let a = (0.0, 0.0);
let b = (4.0, 0.0);
// On the line at the midpoint.
assert!((link_clearance(a, b, (2.0, 0.0), 0.125) - 1.0).abs() < 1e-12);
// Off the end of the segment: no clearance.
assert_eq!(link_clearance(a, b, (5.0, 0.0), 0.125), 0.0);
// Far off the line (perpendicular ≫ Fresnel radius): no clearance.
assert_eq!(link_clearance(a, b, (2.0, 3.0), 0.125), 0.0);
}
#[test]
fn degenerate_link_has_zero_clearance() {
assert_eq!(link_clearance((1.0, 1.0), (1.0, 1.0), (1.0, 1.0), 0.125), 0.0);
}
}
+225
View File
@@ -0,0 +1,225 @@
//! Coarse 2D floor-plan geometry the optimizer plans over (ADR-305 §1).
//!
//! **SYNTHETIC / L0.** This is a deliberately coarse stand-in for the ADR-303
//! scene: axis-aligned rectangular [`Rect`] bounds for a [`Space`](ruview_ontology::Space)
//! and its [`Zone`](ruview_ontology::Zone)s, plus attenuating [`Wall`] segments
//! (reused from the twin). It is a *model* of a room, never a surveyed floor
//! plan, and it makes no measurement or accuracy claim. Geometry references the
//! canonical ontology vocabulary ([`SpaceId`], [`ZoneId`], [`Container`]); it
//! does not invent a second identity scheme (ADR-297 rule 3).
use serde::{Deserialize, Serialize};
use thiserror::Error;
use ruview_ontology::{Container, SpaceId, ZoneId};
use ruview_twin::Wall;
/// Upper bound on wall/reflector segments accepted in one floor plan. Bounds
/// allocation on untrusted input.
pub const MAX_PLAN_WALLS: usize = 4096;
/// Upper bound on zones accepted in one floor plan.
pub const MAX_ZONES: usize = 1024;
/// An axis-aligned rectangle in the deployment's local metric frame (metres).
/// A coarse abstraction of a room/zone footprint, not a surveyed boundary.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Rect {
/// Minimum x (east), metres.
pub min_x: f64,
/// Minimum y (north), metres.
pub min_y: f64,
/// Maximum x (east), metres.
pub max_x: f64,
/// Maximum y (north), metres.
pub max_y: f64,
}
impl Rect {
/// Construct a rectangle. Validity is checked separately with [`Self::is_valid`].
#[must_use]
pub const fn new(min_x: f64, min_y: f64, max_x: f64, max_y: f64) -> Self {
Self { min_x, min_y, max_x, max_y }
}
/// True when every coordinate is finite and the rectangle is non-degenerate
/// (`min < max` on both axes). Rejects `NaN`/`inf`/inverted rectangles at the
/// boundary.
#[must_use]
pub fn is_valid(&self) -> bool {
self.min_x.is_finite()
&& self.min_y.is_finite()
&& self.max_x.is_finite()
&& self.max_y.is_finite()
&& self.max_x > self.min_x
&& self.max_y > self.min_y
}
/// Width (x extent), metres.
#[must_use]
pub fn width(&self) -> f64 {
self.max_x - self.min_x
}
/// Height (y extent), metres.
#[must_use]
pub fn height(&self) -> f64 {
self.max_y - self.min_y
}
/// Centre point `(x, y)`.
#[must_use]
pub fn center(&self) -> (f64, f64) {
((self.min_x + self.max_x) / 2.0, (self.min_y + self.max_y) / 2.0)
}
}
/// A zone footprint within a floor plan's space.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ZoneGeometry {
/// Ontology zone id (ADR-303).
pub id: ZoneId,
/// The zone's rectangular footprint.
pub bounds: Rect,
}
/// A coarse floor plan: one space footprint, its zones, and attenuating walls.
///
/// **SYNTHETIC / L0.** A model of the physical scene the optimizer plans over;
/// not a measurement.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct FloorPlan {
/// Ontology space this plan describes (geometry reference, not a copy).
pub space: SpaceId,
/// The space's rectangular footprint.
pub bounds: Rect,
/// Attenuating wall/reflector segments (reused twin geometry).
pub walls: Vec<Wall>,
/// Zone footprints within the space.
pub zones: Vec<ZoneGeometry>,
}
impl FloorPlan {
/// Validate the plan at the boundary. Never panics; returns a typed error for
/// non-finite/inverted rectangles, non-finite walls, or over-limit counts.
pub fn validate(&self) -> Result<(), PlacementError> {
if !self.bounds.is_valid() {
return Err(PlacementError::InvalidRect { what: "space bounds" });
}
if self.walls.len() > MAX_PLAN_WALLS {
return Err(PlacementError::TooManyWalls {
len: self.walls.len(),
max: MAX_PLAN_WALLS,
});
}
if self.zones.len() > MAX_ZONES {
return Err(PlacementError::TooManyZones {
len: self.zones.len(),
max: MAX_ZONES,
});
}
for wall in &self.walls {
if !wall.is_finite() {
return Err(PlacementError::NonFinite { what: "wall coordinate" });
}
}
for zone in &self.zones {
if !zone.bounds.is_valid() {
return Err(PlacementError::InvalidRect { what: "zone bounds" });
}
}
Ok(())
}
/// Resolve the rectangular region a [`Container`] targets, if present in this
/// plan. A first-class `None` (never an error) when the target is not in the
/// plan (ADR-297 rule 1 — the caller surfaces it as UNKNOWN).
#[must_use]
pub fn region_for(&self, target: &Container) -> Option<Rect> {
match target {
Container::Space { id } if id == &self.space => Some(self.bounds),
Container::Space { .. } => None,
Container::Zone { id } => self
.zones
.iter()
.find(|z| &z.id == id)
.map(|z| z.bounds),
}
}
}
/// Deterministic grid of sample points inside `rect`, at `step` metres, capped at
/// `max_points`. Points are cell centres; a rectangle smaller than one step still
/// yields its centre. No randomness; identical inputs give identical points.
#[must_use]
pub fn sample_points(rect: &Rect, step: f64, max_points: usize) -> Vec<(f64, f64)> {
if !rect.is_valid() || !(step.is_finite() && step > 0.0) || max_points == 0 {
return Vec::new();
}
let mut out = Vec::new();
let mut y = rect.min_y + step / 2.0;
while y < rect.max_y {
let mut x = rect.min_x + step / 2.0;
while x < rect.max_x {
if out.len() >= max_points {
return out;
}
out.push((x, y));
x += step;
}
y += step;
}
if out.is_empty() {
// Rectangle narrower than one step on an axis: fall back to its centre.
out.push(rect.center());
}
out
}
/// Boundary errors from validating placement inputs. Malformed input yields one of
/// these; it never panics.
#[derive(Clone, Debug, PartialEq, Eq, Error)]
pub enum PlacementError {
/// A coordinate or parameter was non-finite (`NaN`/`inf`).
#[error("non-finite value: {what}")]
NonFinite {
/// What was non-finite.
what: &'static str,
},
/// A rectangle was degenerate or inverted (`min >= max`).
#[error("invalid rectangle: {what}")]
InvalidRect {
/// Which rectangle.
what: &'static str,
},
/// More walls than [`MAX_PLAN_WALLS`].
#[error("too many walls: {len} exceeds maximum {max}")]
TooManyWalls {
/// Actual count.
len: usize,
/// The enforced maximum.
max: usize,
},
/// More zones than [`MAX_ZONES`].
#[error("too many zones: {len} exceeds maximum {max}")]
TooManyZones {
/// Actual count.
len: usize,
/// The enforced maximum.
max: usize,
},
/// More radios than the inventory limit.
#[error("too many radios: {len} exceeds maximum {max}")]
TooManyRadios {
/// Actual count.
len: usize,
/// The enforced maximum.
max: usize,
},
/// A model parameter was out of its valid domain.
#[error("invalid parameter: {what}")]
InvalidParameter {
/// Human-readable reason.
what: &'static str,
},
}
@@ -0,0 +1,87 @@
//! Hardware inventory: the radios available to place (ADR-305 §1).
//!
//! **SYNTHETIC / L0.** A coarse description of available hardware — each entry is
//! one physical radio the installer can place, with a modelled transmit power and
//! a capability-envelope label. It bounds the *count* of nodes the optimizer may
//! recommend; the scene bounds their geometry. No measurement claim.
use serde::{Deserialize, Serialize};
use crate::geometry::PlacementError;
/// Upper bound on radios accepted in one inventory. Bounds allocation and the
/// search space on untrusted input.
pub const MAX_INVENTORY: usize = 64;
/// One available radio. The `model` is a coarse capability-envelope label
/// (e.g. `"esp32-s3"`, `"mmwave"`); this crate does not interpret it beyond
/// carrying it through so a recommendation names the hardware it plans for.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RadioSpec {
/// Coarse hardware/capability label (ADR-315/ADR-317 descriptor handle).
pub model: String,
/// Modelled transmit power, dBm. A SYNTHETIC parameter of the forward model.
pub tx_power_dbm: f64,
}
impl RadioSpec {
/// Construct a radio spec.
#[must_use]
pub fn new(model: impl Into<String>, tx_power_dbm: f64) -> Self {
Self { model: model.into(), tx_power_dbm }
}
}
/// The set of radios available to place. Its length bounds the recommended node
/// count.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
pub struct Inventory {
/// Available radios, one entry per placeable unit.
pub radios: Vec<RadioSpec>,
}
impl Inventory {
/// An empty inventory.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// A homogeneous inventory of `count` radios of one `model`/power. Deterministic.
#[must_use]
pub fn homogeneous(model: impl Into<String>, tx_power_dbm: f64, count: usize) -> Self {
let model = model.into();
let radios = (0..count)
.map(|_| RadioSpec::new(model.clone(), tx_power_dbm))
.collect();
Self { radios }
}
/// Number of placeable radios.
#[must_use]
pub fn len(&self) -> usize {
self.radios.len()
}
/// True when there is nothing to place.
#[must_use]
pub fn is_empty(&self) -> bool {
self.radios.is_empty()
}
/// Validate the inventory at the boundary: bounded count, finite powers.
pub fn validate(&self) -> Result<(), PlacementError> {
if self.radios.len() > MAX_INVENTORY {
return Err(PlacementError::TooManyRadios {
len: self.radios.len(),
max: MAX_INVENTORY,
});
}
for r in &self.radios {
if !r.tx_power_dbm.is_finite() {
return Err(PlacementError::NonFinite { what: "tx_power_dbm" });
}
}
Ok(())
}
}
+554
View File
@@ -0,0 +1,554 @@
//! # `ruview-placement` — sensor placement optimizer (ADR-305, ADR-297 phase 3)
//!
//! **SYNTHETIC / L0 — a planning scaffold, not a measurement system.**
//!
//! This crate is a *research-forward primitive*: given a coarse floor plan
//! ([`FloorPlan`], an ADR-303 scene abstraction) and a hardware [`Inventory`], it
//! recommends radio-node positions by scoring candidate placements against the
//! ADR-312 RF twin's **SYNTHETIC** propagation model. Every coverage and
//! observability value it produces is a *simulation* at evidence level `L0`
//! (ADR-282), labelled `SYNTHETIC`: a **recommendation**, never a sensing claim.
//! Nothing here is a hardware, `MEASURED`, or accuracy claim, and the crate
//! asserts **no** coverage or accuracy number — a twin/optimizer *predicts*, it
//! does not *measure* (ADR-305 evidence discipline).
//!
//! Consistent with ADR-297 rule 1, *insufficient information* is a first-class
//! value ([`Observability::Unknown`]), never an error and never a confident
//! default. Consistent with rule 3, the crate reuses the canonical ontology
//! vocabulary ([`SpaceId`], [`ZoneId`], [`SensorId`], [`Container`],
//! [`EvidenceLevel`], [`SemanticProvenance`]) rather than inventing its own.
//!
//! ## What the optimizer does
//!
//! - **Predict** ([`score_placement`]): for each objective ([`Objective`]) it
//! samples the target region and scores modelled observability from
//! Fresnel-zone clearance ([`crate::fresnel`]) over well-predicted twin links,
//! reporting both a score **and** its uncertainty per target, and flagging
//! blind spots.
//! - **Search** ([`optimize`]): greedy forward selection over a **seeded**
//! candidate grid recommends a [`PlacementPlan`]; adding a radio can only
//! maintain or raise the modelled score, so the plan's score trace is
//! monotonically non-decreasing and plateaus at saturation.
//! - **Rank** ([`rank_placements`]): score and order supplied candidate
//! placements, highest modelled observability first.
//! - **Post-install compare** ([`compare_post_install`]): compare the optimizer's
//! `L0` prediction against **caller-supplied** measured observability (this
//! crate never measures) and recommend adjustments plus a coarse twin-parameter
//! residual to feed back into ADR-312.
//!
//! ## Determinism
//!
//! Everything is deterministic. Synthetic scenes are varied by an explicit
//! [`seed`](PlacementParams::seed); there is no wall-clock, no unseeded
//! randomness, and no I/O anywhere in the crate. Allocation is bounded at every
//! boundary ([`MAX_PLAN_WALLS`], [`MAX_ZONES`], [`MAX_INVENTORY`],
//! [`PlacementParams::max_sample_points`], [`PlacementParams::max_candidates`]),
//! and malformed input yields a typed [`PlacementError`] or a first-class
//! `Unknown`, never a panic.
//!
//! ```
//! use ruview_placement::*;
//!
//! // A reproducible SYNTHETIC scene, an inventory, and one objective.
//! let plan = synthetic_floorplan(7);
//! let inventory = Inventory::homogeneous("esp32-s3", 20.0, 4);
//! let objectives = vec![synthetic_objective(&plan)];
//! let params = PlacementParams::default_synthetic();
//!
//! plan.validate().unwrap();
//! inventory.validate().unwrap();
//!
//! let recommended = optimize(&plan, &inventory, &objectives, &params);
//! assert_eq!(recommended.evidence_level, EvidenceLevel::L0); // SYNTHETIC
//! // The greedy score trace never decreases.
//! for w in recommended.score_trace.windows(2) {
//! assert!(w[1] + 1e-9 >= w[0]);
//! }
//! ```
#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod compare;
mod coverage;
mod fresnel;
mod geometry;
mod inventory;
mod plan;
pub use compare::{
compare_post_install, compare_post_install_with_tolerance, Adjustment, AdjustmentAction,
AdjustmentReport, CompareVerdict, MeasuredObservability, MeasuredTarget, ResidualKind,
TargetComparison, TwinResidual, DEFAULT_COMPARE_TOLERANCE,
};
pub use coverage::{
rank_placements, score_placement, Objective, Observability, ObservabilityUnknown, Phenomenon,
PlacedRadio, Placement, PlacementParams, PlacementScore, RankedPlacement, TargetCoverage,
};
pub use fresnel::{fresnel_radius, link_clearance};
pub use geometry::{
sample_points, FloorPlan, PlacementError, Rect, ZoneGeometry, MAX_PLAN_WALLS, MAX_ZONES,
};
pub use inventory::{Inventory, RadioSpec, MAX_INVENTORY};
pub use plan::{candidate_positions, optimize, PlacementPlan};
// Re-export the canonical ontology and twin vocabulary consumers need, so they
// speak one semantics (ADR-297 rule 3).
pub use ruview_ontology::{
Container, EvidenceLevel, SemanticProvenance, SensorId, SpaceId, ZoneId,
};
pub use ruview_twin::{Point3, PropagationParams, Wall};
/// Build a deterministic **SYNTHETIC** floor plan from an explicit `seed`.
///
/// A `5 m × 4 m` space with one interior wall and two zones (a central "core"
/// zone straddling the room and a "corner" zone in the far top-right). Zone
/// footprints are jittered by a seeded `splitmix64` stream so distinct seeds give
/// distinct-but-reproducible scenes; the same seed always yields the same scene.
/// This is a simulation fixture, not a model of any real room.
#[must_use]
pub fn synthetic_floorplan(seed: u64) -> FloorPlan {
let mut state = seed;
// Deterministic jitter helper in [-0.25, 0.25] metres.
let mut jitter = || (splitmix64_unit(&mut state) - 0.5) * 0.5;
let jx = jitter();
let jy = jitter();
let space = SpaceId::new(format!("space-{seed}")).expect("static id is valid");
let bounds = Rect::new(0.0, 0.0, 5.0, 4.0);
let walls = vec![Wall {
id: "interior-wall".to_string(),
a: (2.5, 3.0),
b: (2.5, 4.0),
attenuation_db: 6.0,
}];
let core = ZoneGeometry {
id: ZoneId::new(format!("core-{seed}")).expect("static id is valid"),
// A band across the middle of the room where links crisscross.
bounds: Rect::new(
(1.5 + jx).clamp(0.5, 2.0),
(1.5 + jy).clamp(0.5, 2.0),
3.5,
2.5,
),
};
let corner = ZoneGeometry {
id: ZoneId::new(format!("corner-{seed}")).expect("static id is valid"),
// A far top-right pocket, easy to leave as a blind spot.
bounds: Rect::new(4.0, 3.2, 4.9, 3.9),
};
FloorPlan {
space,
bounds,
walls,
zones: vec![core, corner],
}
}
/// A default presence objective on the synthetic plan's central "core" zone.
#[must_use]
pub fn synthetic_objective(plan: &FloorPlan) -> Objective {
let target = plan
.zones
.first()
.map(|z| Container::Zone { id: z.id.clone() })
.unwrap_or(Container::Space { id: plan.space.clone() });
Objective::new(target, Phenomenon::Presence)
}
/// One `splitmix64` step mapped to a unit `f64` in `[0, 1)`. Deterministic; the
/// only source of scene variation in the fixtures (varied by explicit seed).
fn splitmix64_unit(state: &mut u64) -> f64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
((z >> 11) as f64) / ((1u64 << 53) as f64)
}
#[cfg(test)]
mod tests {
use super::*;
fn params() -> PlacementParams {
PlacementParams::default_synthetic()
}
/// Two radios placed on the bottom wall of a 4×4 room; links run along `y≈0`.
fn bottom_pair_plan() -> FloorPlan {
FloorPlan {
space: SpaceId::new("room").unwrap(),
bounds: Rect::new(0.0, 0.0, 4.0, 4.0),
walls: Vec::new(),
zones: vec![
// A zone straddling the link line — should be covered.
ZoneGeometry {
id: ZoneId::new("on-line").unwrap(),
bounds: Rect::new(1.0, 0.0, 3.0, 0.3),
},
// A far top zone away from the link line — a blind spot.
ZoneGeometry {
id: ZoneId::new("far-top").unwrap(),
bounds: Rect::new(1.0, 3.0, 3.0, 4.0),
},
],
}
}
fn bottom_pair_placement() -> Placement {
Placement {
radios: vec![
PlacedRadio::new(Point3::new(0.2, 0.1, 1.0), 20.0),
PlacedRadio::new(Point3::new(3.8, 0.1, 1.0), 20.0),
],
}
}
#[test]
fn better_covering_placement_ranks_higher() {
let plan = bottom_pair_plan();
let objectives = vec![Objective::new(
Container::Zone { id: ZoneId::new("on-line").unwrap() },
Phenomenon::Presence,
)];
let p = params();
// Good: radios straddle the zone so the link's Fresnel zone crosses it.
let good = bottom_pair_placement();
// Bad: both radios clustered in the far corner, link far from the zone.
let bad = Placement {
radios: vec![
PlacedRadio::new(Point3::new(0.1, 3.8, 1.0), 20.0),
PlacedRadio::new(Point3::new(0.4, 3.9, 1.0), 20.0),
],
};
let ranked = rank_placements(&plan, &[bad.clone(), good.clone()], &objectives, &p);
// The good placement (input index 1) ranks first.
assert_eq!(ranked[0].index, 1);
assert!(ranked[0].score.total_score > ranked[1].score.total_score);
// And its observability is genuinely higher.
let sg = score_placement(&plan, &good, &objectives, &p);
let sb = score_placement(&plan, &bad, &objectives, &p);
assert!(sg.total_score > sb.total_score);
}
#[test]
fn blind_spot_zone_is_flagged() {
let plan = bottom_pair_plan();
let placement = bottom_pair_placement();
let objectives = vec![
Objective::new(
Container::Zone { id: ZoneId::new("on-line").unwrap() },
Phenomenon::Presence,
),
Objective::new(
Container::Zone { id: ZoneId::new("far-top").unwrap() },
Phenomenon::Presence,
),
];
let score = score_placement(&plan, &placement, &objectives, &params());
assert!(score.has_blind_spot());
let far = Container::Zone { id: ZoneId::new("far-top").unwrap() };
assert!(score.blind_spots.contains(&far));
// The far-top zone is a blind spot; the on-line zone is not.
let on_line = score
.per_target
.iter()
.find(|t| t.target == Container::Zone { id: ZoneId::new("on-line").unwrap() })
.unwrap();
let far_top = score
.per_target
.iter()
.find(|t| t.target == far)
.unwrap();
assert!(!on_line.blind_spot);
assert!(far_top.blind_spot);
assert!(far_top.covered_fraction < on_line.covered_fraction);
}
#[test]
fn adding_a_node_improves_score_monotonically_until_saturation() {
let plan = bottom_pair_plan();
let objectives = vec![Objective::new(
Container::Zone { id: ZoneId::new("on-line").unwrap() },
Phenomenon::Presence,
)];
let p = params();
// Incrementally add radios along the bottom wall.
let positions = [
Point3::new(0.2, 0.1, 1.0),
Point3::new(3.8, 0.1, 1.0),
Point3::new(2.0, 0.1, 1.0),
Point3::new(1.0, 0.1, 1.0),
Point3::new(3.0, 0.1, 1.0),
];
let mut radios = Vec::new();
let mut prev = -1.0_f64;
let mut scores = Vec::new();
for pos in positions {
radios.push(PlacedRadio::new(pos, 20.0));
let s = score_placement(&plan, &Placement { radios: radios.clone() }, &objectives, &p)
.total_score;
// Monotone non-decreasing at every step.
assert!(s + 1e-9 >= prev, "score decreased: {prev} -> {s}");
prev = s;
scores.push(s);
}
// It strictly improved at least once early on...
assert!(scores[1] > scores[0]);
// ...and saturates: a later step adds (near-)nothing.
let last = scores.len() - 1;
assert!((scores[last] - scores[last - 1]).abs() < 1e-6);
// The greedy optimizer's own trace is also non-decreasing.
let inv = Inventory::homogeneous("esp32-s3", 20.0, 5);
let recommended = optimize(&plan, &inv, &objectives, &p);
for w in recommended.score_trace.windows(2) {
assert!(w[1] + 1e-9 >= w[0]);
}
}
#[test]
fn optimize_reports_uncertainty_and_never_a_bare_number() {
let plan = synthetic_floorplan(3);
let inv = Inventory::homogeneous("esp32-s3", 20.0, 4);
let objectives = vec![synthetic_objective(&plan)];
let recommended = optimize(&plan, &inv, &objectives, &params());
// Every known target carries BOTH a score and an uncertainty (ADR-305 §2).
let mut saw_known = false;
for t in &recommended.score.per_target {
if let Observability::Known { score, uncertainty } = t.observability {
saw_known = true;
assert!((0.0..=1.0).contains(&score));
assert!((0.0..=1.0).contains(&uncertainty));
}
}
assert!(saw_known);
assert_eq!(recommended.evidence_level, EvidenceLevel::L0);
}
#[test]
fn predicted_vs_observed_delta_yields_adjustment_suggestion() {
let plan = bottom_pair_plan();
let placement = bottom_pair_placement();
let objectives = vec![Objective::new(
Container::Zone { id: ZoneId::new("on-line").unwrap() },
Phenomenon::Presence,
)];
let predicted = score_placement(&plan, &placement, &objectives, &params());
let target = Container::Zone { id: ZoneId::new("on-line").unwrap() };
let (pred_score, _) = predicted
.per_target
.iter()
.find(|t| t.target == target)
.unwrap()
.observability
.known()
.expect("predicted score known");
// Caller supplies a much *lower* measured observability than predicted.
let measured = MeasuredObservability::new().with(target.clone(), (pred_score - 0.6).max(0.0));
let report = compare_post_install(&predicted, &measured);
assert_eq!(report.predicted_evidence_level, EvidenceLevel::L0);
assert!(report.needs_adjustment());
let adj = report.adjustments.iter().find(|a| a.target == target).unwrap();
assert!(matches!(
adj.action,
AdjustmentAction::AddNode | AdjustmentAction::MoveNode | AdjustmentAction::ReAim
));
// The residual points the twin at higher effective attenuation.
let residual = adj.twin_residual.expect("residual suggested");
assert_eq!(residual.kind, ResidualKind::EffectiveAttenuationHigher);
assert!(residual.magnitude_db > 0.0);
let cmp = report.comparisons.iter().find(|c| c.target == target).unwrap();
assert_eq!(cmp.verdict, CompareVerdict::Underperforming);
// A missing measurement is first-class UNKNOWN, not an error.
let empty = MeasuredObservability::new();
let report2 = compare_post_install(&predicted, &empty);
let cmp2 = report2.comparisons.iter().find(|c| c.target == target).unwrap();
assert_eq!(cmp2.verdict, CompareVerdict::Unknown);
assert!(!report2.needs_adjustment());
}
#[test]
fn matching_observation_needs_no_adjustment() {
let plan = bottom_pair_plan();
let placement = bottom_pair_placement();
let objectives = vec![Objective::new(
Container::Zone { id: ZoneId::new("on-line").unwrap() },
Phenomenon::Presence,
)];
let predicted = score_placement(&plan, &placement, &objectives, &params());
let target = Container::Zone { id: ZoneId::new("on-line").unwrap() };
let (pred_score, _) = predicted.per_target[0].observability.known().unwrap();
// Measured equals predicted: verdict Match, no corrective action.
let measured = MeasuredObservability::new().with(target.clone(), pred_score);
let report = compare_post_install(&predicted, &measured);
let cmp = report.comparisons.iter().find(|c| c.target == target).unwrap();
assert_eq!(cmp.verdict, CompareVerdict::Match);
assert!(!report.needs_adjustment());
}
#[test]
fn optimize_is_deterministic_and_seed_varies_the_scene() {
let inv = Inventory::homogeneous("esp32-s3", 20.0, 4);
let p = params();
// Same seed ⇒ identical plan (bit-for-bit via serde).
let plan_a = synthetic_floorplan(11);
let objectives_a = vec![synthetic_objective(&plan_a)];
let r1 = optimize(&plan_a, &inv, &objectives_a, &p);
let r2 = optimize(&plan_a, &inv, &objectives_a, &p);
assert_eq!(r1, r2);
assert_eq!(
serde_json::to_string(&r1).unwrap(),
serde_json::to_string(&r2).unwrap()
);
// Distinct seeds give distinct-but-reproducible scenes.
let plan_b = synthetic_floorplan(12);
assert_ne!(plan_a, plan_b);
// Candidate generation is seeded and deterministic.
let c1 = candidate_positions(&plan_a, &p);
let c2 = candidate_positions(&plan_a, &p);
assert_eq!(c1, c2);
let mut p_seeded = p;
p_seeded.seed = p.seed.wrapping_add(1);
let c3 = candidate_positions(&plan_a, &p_seeded);
assert_ne!(c1, c3); // a different seed shifts the grid
}
#[test]
fn serde_round_trip_is_lossless_and_labels_evidence() {
let plan = synthetic_floorplan(1);
let inv = Inventory::homogeneous("esp32-s3", 20.0, 3);
let objectives = vec![synthetic_objective(&plan)];
let recommended = optimize(&plan, &inv, &objectives, &params());
let json = serde_json::to_string_pretty(&recommended).unwrap();
let back: PlacementPlan = serde_json::from_str(&json).unwrap();
assert_eq!(recommended, back);
// Evidence discipline is on the wire: L0 / SYNTHETIC.
assert!(json.contains("\"evidence_level\": \"L0\""));
}
#[test]
fn boundary_validation_rejects_malformed_input_without_panic() {
// Inverted rectangle.
let mut plan = synthetic_floorplan(1);
plan.bounds = Rect::new(5.0, 4.0, 0.0, 0.0);
assert!(matches!(plan.validate(), Err(PlacementError::InvalidRect { .. })));
// Non-finite wall coordinate.
let mut plan = synthetic_floorplan(1);
plan.walls[0].a.0 = f64::NAN;
assert!(matches!(plan.validate(), Err(PlacementError::NonFinite { .. })));
// Too many radios.
let over = Inventory::homogeneous("x", 20.0, MAX_INVENTORY + 1);
assert!(matches!(over.validate(), Err(PlacementError::TooManyRadios { .. })));
// Non-finite tx power.
let bad_inv = Inventory { radios: vec![RadioSpec::new("x", f64::INFINITY)] };
assert!(matches!(bad_inv.validate(), Err(PlacementError::NonFinite { .. })));
// Invalid parameter.
let mut bad_params = params();
bad_params.wavelength_m = 0.0;
assert!(matches!(bad_params.validate(), Err(PlacementError::InvalidParameter { .. })));
// Objective targeting a container not in the plan ⇒ first-class UNKNOWN,
// never a panic or error.
let plan = synthetic_floorplan(1);
let placement = Placement {
radios: vec![
PlacedRadio::new(Point3::new(0.5, 0.5, 1.0), 20.0),
PlacedRadio::new(Point3::new(4.5, 3.5, 1.0), 20.0),
],
};
let ghost = Objective::new(
Container::Zone { id: ZoneId::new("ghost-zone").unwrap() },
Phenomenon::Presence,
);
let score = score_placement(&plan, &placement, &[ghost], &params());
assert!(matches!(
score.per_target[0].observability,
Observability::Unknown { reason: ObservabilityUnknown::TargetNotInPlan }
));
// A non-finite placement position is skipped, never panics: with fewer
// than two finite nodes there are no links, so the target is UNKNOWN.
let nan_placement = Placement {
radios: vec![PlacedRadio::new(Point3::new(f64::NAN, 0.0, 1.0), 20.0)],
};
let objectives = vec![synthetic_objective(&plan)];
let score = score_placement(&plan, &nan_placement, &objectives, &params());
assert!(matches!(
score.per_target[0].observability,
Observability::Unknown { reason: ObservabilityUnknown::NoLinks }
));
}
#[test]
fn marginal_case_reports_higher_uncertainty() {
// A zone squarely on the link line vs. a marginal one at the Fresnel edge:
// the marginal case is reported with higher uncertainty (ADR-305 §2, the
// model reports uncertainty rather than overstating a coarse result).
let plan = FloorPlan {
space: SpaceId::new("room").unwrap(),
bounds: Rect::new(0.0, 0.0, 4.0, 4.0),
walls: Vec::new(),
zones: vec![
ZoneGeometry {
id: ZoneId::new("on-line").unwrap(),
bounds: Rect::new(1.0, 0.0, 3.0, 0.2),
},
ZoneGeometry {
id: ZoneId::new("marginal").unwrap(),
bounds: Rect::new(1.0, 1.5, 3.0, 1.9),
},
],
};
let placement = bottom_pair_placement();
let p = params();
let on_line = score_placement(
&plan,
&placement,
&[Objective::new(
Container::Zone { id: ZoneId::new("on-line").unwrap() },
Phenomenon::Presence,
)],
&p,
);
let marginal = score_placement(
&plan,
&placement,
&[Objective::new(
Container::Zone { id: ZoneId::new("marginal").unwrap() },
Phenomenon::Presence,
)],
&p,
);
let (_, u_on) = on_line.per_target[0].observability.known().unwrap();
let (_, u_marg) = marginal.per_target[0].observability.known().unwrap();
assert!(u_marg > u_on, "marginal uncertainty {u_marg} should exceed on-line {u_on}");
}
}
+163
View File
@@ -0,0 +1,163 @@
//! Deterministic placement search: floor plan + inventory → recommended plan
//! (ADR-305 §2).
//!
//! **SYNTHETIC / L0.** The search consumes the SYNTHETIC coverage model in
//! [`crate::coverage`] and recommends radio positions that maximise modelled
//! objective observability subject to the inventory count and the scene geometry.
//! It is a *recommendation*, never a guarantee that a room is sensed (ADR-305
//! consequences). Determinism is total: candidate positions come from a seeded
//! grid ([`PlacementParams::seed`]) with **no RNG and no wall-clock**; greedy
//! forward selection then adds the best candidate one radio at a time. Because
//! point observability is a *max over links*, adding a radio can only maintain or
//! raise the score — so the recorded [`PlacementPlan::score_trace`] is
//! monotonically non-decreasing and plateaus at saturation.
use serde::{Deserialize, Serialize};
use ruview_ontology::{EvidenceLevel, SemanticProvenance};
use ruview_twin::Point3;
use crate::coverage::{
score_placement, Objective, Placement, PlacedRadio, PlacementParams, PlacementScore,
};
use crate::geometry::FloorPlan;
use crate::inventory::Inventory;
/// A recommended placement plan.
///
/// **SYNTHETIC / L0.** Carries the chosen [`Placement`], its [`PlacementScore`],
/// and the monotonic score trace of the greedy search (one entry per radio
/// added). A recommendation, never a sensing claim.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct PlacementPlan {
/// The recommended radio positions.
pub placement: Placement,
/// The score of the recommended placement.
pub score: PlacementScore,
/// Total score after each radio was added, in order — non-decreasing.
pub score_trace: Vec<f64>,
/// Number of candidate positions the search considered.
pub candidate_count: usize,
/// Evidence level of this plan. Always `L0` (SYNTHETIC).
pub evidence_level: EvidenceLevel,
/// Provenance travelling with the plan.
pub provenance: SemanticProvenance,
}
/// One `splitmix64` step mapped to `[0, 1)`. Deterministic; the only source of
/// candidate-grid variation in this crate (varied by an explicit seed, never RNG).
fn splitmix64_unit(state: &mut u64) -> f64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
((z >> 11) as f64) / ((1u64 << 53) as f64)
}
/// Generate the deterministic candidate-position grid over the plan bounds.
///
/// A seeded sub-step offset varies the grid reproducibly between seeds; the same
/// seed always yields the same candidates. Bounded by `params.max_candidates`.
#[must_use]
pub fn candidate_positions(plan: &FloorPlan, params: &PlacementParams) -> Vec<Point3> {
if !plan.bounds.is_valid() || !(params.candidate_step_m.is_finite() && params.candidate_step_m > 0.0)
{
return Vec::new();
}
let step = params.candidate_step_m;
let mut state = params.seed;
// Seeded offsets in [0, step) so distinct seeds shift the grid deterministically.
let ox = splitmix64_unit(&mut state) * step;
let oy = splitmix64_unit(&mut state) * step;
let mut out = Vec::new();
let mut y = plan.bounds.min_y + step / 2.0 + oy;
// Keep the first row inside the room if the offset pushed it past the far edge.
if y >= plan.bounds.max_y {
y = plan.bounds.center().1;
}
while y < plan.bounds.max_y {
let mut x = plan.bounds.min_x + step / 2.0 + ox;
if x >= plan.bounds.max_x {
x = plan.bounds.center().0;
}
while x < plan.bounds.max_x {
if out.len() >= params.max_candidates {
return out;
}
out.push(Point3::new(x, y, 1.0));
x += step;
}
y += step;
}
if out.is_empty() {
let (cx, cy) = plan.bounds.center();
out.push(Point3::new(cx, cy, 1.0));
}
out
}
/// Improvement below this counts as no gain (tie), so ties break deterministically
/// to the first (lowest-index) candidate.
const IMPROVEMENT_EPS: f64 = 1e-9;
/// Optimise a placement: greedily add radios from the inventory to maximise
/// modelled objective observability over the floor plan.
///
/// **SYNTHETIC / L0.** Deterministic and never panics. The number of radios is
/// bounded by the inventory; positions come from the seeded candidate grid. The
/// returned [`PlacementPlan::score_trace`] is non-decreasing by construction.
#[must_use]
pub fn optimize(
plan: &FloorPlan,
inventory: &Inventory,
objectives: &[Objective],
params: &PlacementParams,
) -> PlacementPlan {
let candidates = candidate_positions(plan, params);
let mut chosen: Vec<PlacedRadio> = Vec::new();
let mut trace: Vec<f64> = Vec::new();
for spec in &inventory.radios {
let tx = spec.tx_power_dbm;
let mut best_index: Option<usize> = None;
let mut best_score = f64::NEG_INFINITY;
for (ci, cand) in candidates.iter().enumerate() {
// Skip a position already chosen (a duplicate adds no link geometry).
if chosen.iter().any(|r| r.position == *cand) {
continue;
}
let mut trial = chosen.clone();
trial.push(PlacedRadio::new(*cand, tx));
let s = score_placement(plan, &Placement { radios: trial }, objectives, params)
.total_score;
if s > best_score + IMPROVEMENT_EPS {
best_score = s;
best_index = Some(ci);
}
}
match best_index {
Some(ci) => {
chosen.push(PlacedRadio::new(candidates[ci], tx));
trace.push(best_score.max(0.0));
}
// No usable candidate remained (e.g. all positions taken); stop.
None => break,
}
}
let placement = Placement { radios: chosen };
let score = score_placement(plan, &placement, objectives, params);
PlacementPlan {
placement,
score,
score_trace: trace,
candidate_count: candidates.len(),
evidence_level: EvidenceLevel::L0,
provenance: SemanticProvenance::declared("ruview-placement@0 (SYNTHETIC/L0)"),
}
}
+15
View File
@@ -0,0 +1,15 @@
[package]
name = "ruview-twin"
version.workspace = true
edition.workspace = true
authors.workspace = true
license.workspace = true
repository.workspace = true
[dependencies]
thiserror.workspace = true
serde = { workspace = true, features = ["derive"] }
ruview-ontology = { path = "../ruview-ontology" }
[dev-dependencies]
serde_json.workspace = true
+215
View File
@@ -0,0 +1,215 @@
//! The load-bearing operation: `delta(observed, expected)` (ADR-312 §2).
//!
//! **SYNTHETIC / L0.** A [`TwinDelta`] is a *model-relative* statement: how far a
//! supplied observation set sits from the twin's own predicted distributions,
//! measured against the twin's own modelled variance. It is **not** a detection,
//! and asserts no accuracy (ADR-312 evidence discipline, ADR-297). A change that
//! is large relative to the modelled variance is a *candidate physical change*
//! to be corroborated, never a confident claim.
//!
//! Consistent with ADR-297 rule 1, a link the twin cannot evaluate — unknown
//! prediction, or an observation for a link outside the twin — is reported as
//! [`LinkDeltaStatus::Unknown`], excluded from the aggregate magnitude, never an
//! error.
use serde::{Deserialize, Serialize};
use crate::predict::{predict_link, ExpectedDistribution, UnknownReason};
use crate::twin::{LinkId, RfTwin};
/// Default significance threshold (standard deviations). A link whose absolute
/// deviation exceeds this many modelled standard deviations is flagged as
/// deviating. `3.0` ≈ a conventional 3-sigma gate; it is a model gate, not a
/// calibrated false-alarm rate.
pub const DEFAULT_SIGNIFICANCE_THRESHOLD: f64 = 3.0;
/// One observed observable value for a link (e.g. a measured mean RSSI, dBm).
/// The value is caller-supplied; this crate never samples a clock or sensor.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LinkObservation {
/// The link this observation is for.
pub link: LinkId,
/// The observed value, in the observable's units (dBm for RSSI).
pub value: f64,
}
/// A supplied set of link observations to compare against the twin.
#[derive(Clone, Debug, PartialEq, Default, Serialize, Deserialize)]
pub struct ObservationSet {
/// The observations. Order is not significant; duplicate links use the first.
pub observations: Vec<LinkObservation>,
}
impl ObservationSet {
/// An empty observation set.
#[must_use]
pub fn new() -> Self {
Self::default()
}
/// Add one observation and return `self` for chaining.
#[must_use]
pub fn with(mut self, link: LinkId, value: f64) -> Self {
self.observations.push(LinkObservation { link, value });
self
}
/// Build the observation set that exactly reproduces a twin's own predicted
/// means — the *zero-delta* reference. Links the twin cannot predict are
/// omitted (they would only surface as UNKNOWN).
#[must_use]
pub fn from_twin_prediction(twin: &RfTwin) -> Self {
let mut observations = Vec::new();
for link in twin.links() {
if let ExpectedDistribution::Known { mean, .. } = predict_link(twin, &link) {
observations.push(LinkObservation { link, value: mean });
}
}
Self { observations }
}
}
/// Outcome for a single link in a delta computation.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum LinkDeltaStatus {
/// The link was evaluated against a known distribution.
Evaluated {
/// Supplied observed value.
observed: f64,
/// Twin's modelled mean.
expected_mean: f64,
/// Twin's modelled variance (dB²).
expected_variance: f64,
/// `observed - expected_mean`, signed.
deviation: f64,
/// `|deviation| / sqrt(variance)`, i.e. standard deviations. `None` when
/// variance is zero (significance is undefined, reported as UNKNOWN-ish
/// rather than infinite).
#[serde(skip_serializing_if = "Option::is_none")]
significance: Option<f64>,
},
/// The link could not be evaluated; first-class UNKNOWN (ADR-297 rule 1).
Unknown {
/// Why it is unknown.
reason: UnknownReason,
},
}
/// The delta for one link.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct LinkDelta {
/// The link.
pub link: LinkId,
/// Its outcome.
pub status: LinkDeltaStatus,
}
/// The typed result of `delta(observed, expected)` over an observation set.
///
/// **SYNTHETIC / L0.** `total_magnitude` and `deviating_links` are model-relative
/// summaries, not a detection or accuracy claim.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct TwinDelta {
/// Twin baseline version this delta is relative to (ADR-312 §2).
pub baseline_version: u64,
/// Significance threshold (standard deviations) used to flag deviating links.
pub significance_threshold: f64,
/// L2 norm of evaluated per-link deviations — the overall magnitude of the
/// change against the twin. `0.0` exactly when every evaluated link matches
/// its prediction.
pub total_magnitude: f64,
/// Largest per-link significance among evaluated links (`0.0` if none).
pub max_significance: f64,
/// Links whose significance meets or exceeds the threshold — *which* links
/// deviate.
pub deviating_links: Vec<LinkId>,
/// Links present in the observation set but not evaluable (UNKNOWN).
pub unknown_links: Vec<LinkId>,
/// Per-link detail for every observation supplied.
pub per_link: Vec<LinkDelta>,
}
impl TwinDelta {
/// True when no evaluated link deviates beyond the threshold. UNKNOWN links
/// do not count as changes (they are reported separately).
#[must_use]
pub fn is_zero(&self) -> bool {
self.deviating_links.is_empty() && self.total_magnitude == 0.0
}
}
/// Compute the delta of a supplied observation set against the twin's predicted
/// distributions, using [`DEFAULT_SIGNIFICANCE_THRESHOLD`].
#[must_use]
pub fn compute_delta(twin: &RfTwin, observed: &ObservationSet) -> TwinDelta {
compute_delta_with_threshold(twin, observed, DEFAULT_SIGNIFICANCE_THRESHOLD)
}
/// Compute the delta with an explicit significance threshold (standard
/// deviations). Deterministic and allocation-bounded by the observation count.
#[must_use]
pub fn compute_delta_with_threshold(
twin: &RfTwin,
observed: &ObservationSet,
significance_threshold: f64,
) -> TwinDelta {
let mut per_link = Vec::with_capacity(observed.observations.len());
let mut deviating_links = Vec::new();
let mut unknown_links = Vec::new();
let mut sum_sq = 0.0_f64;
let mut max_significance = 0.0_f64;
for obs in &observed.observations {
match predict_link(twin, &obs.link) {
ExpectedDistribution::Known {
mean, variance, ..
} => {
let deviation = obs.value - mean;
let significance = if variance > 0.0 {
let sig = deviation.abs() / variance.sqrt();
if sig > max_significance {
max_significance = sig;
}
if sig >= significance_threshold {
deviating_links.push(obs.link.clone());
}
Some(sig)
} else {
// Zero modelled variance: significance is undefined. A
// non-zero deviation still contributes to magnitude, but we
// do not fabricate an infinite significance.
None
};
sum_sq += deviation * deviation;
per_link.push(LinkDelta {
link: obs.link.clone(),
status: LinkDeltaStatus::Evaluated {
observed: obs.value,
expected_mean: mean,
expected_variance: variance,
deviation,
significance,
},
});
}
ExpectedDistribution::Unknown { reason } => {
unknown_links.push(obs.link.clone());
per_link.push(LinkDelta {
link: obs.link.clone(),
status: LinkDeltaStatus::Unknown { reason },
});
}
}
}
TwinDelta {
baseline_version: twin.version,
significance_threshold,
total_magnitude: sum_sq.sqrt(),
max_significance,
deviating_links,
unknown_links,
per_link,
}
}
+390
View File
@@ -0,0 +1,390 @@
//! # `ruview-twin` — a digital RF twin (ADR-312, ADR-297 phase 3)
//!
//! **SYNTHETIC / L0 — a simulation scaffold, not a measurement system.**
//!
//! This crate is a *research-forward primitive*: a persistent, versioned,
//! per-deployment **model** of an RF environment. A twin **predicts** an expected
//! observable; it never **measures** one. Every distribution it produces and any
//! propagation it simulates is a model at evidence level `L0` (ADR-282),
//! labelled `SYNTHETIC`. Nothing in this crate is a hardware, `MEASURED`, or
//! accuracy claim, and it asserts **no** detection-accuracy number (ADR-312
//! evidence discipline). Following ADR-297 rule 1, *insufficient information* is
//! a first-class value ([`ExpectedDistribution::Unknown`] /
//! [`LinkDeltaStatus::Unknown`]), never an error and never a confident default.
//!
//! ## What the twin holds
//!
//! - **Radio node positions** in coarse metric coordinates ([`RadioNode`],
//! [`Point3`]).
//! - **Geometry references** into the canonical ontology ([`SpaceId`],
//! [`Container`]) — the twin *annotates* the ADR-303 scene, it does not invent
//! a second geometry.
//! - A **simple documented propagation model** — log-distance path loss with
//! optional wall attenuation ([`PropagationParams`], [`crate::predict`]),
//! clearly a SYNTHETIC model, not real RF.
//! - **Recorded multipath / calibration state** ([`MultipathRecord`],
//! [`RfTwin::calibration_version`]).
//! - An **[`ExpectedDistribution`] per link** — the mean/variance of an
//! observable under the twin.
//!
//! ## The load-bearing operation
//!
//! [`RfTwin::delta`] compares a supplied observation set to the twin's
//! predictions and returns a typed [`TwinDelta`] with an overall magnitude and
//! *which* links deviate, each scored against the twin's own modelled variance.
//! A physical change becomes a *measurable delta against the twin* — a candidate
//! change to corroborate, never a confident detection.
//!
//! ## Determinism
//!
//! Everything is deterministic. Synthetic scenes are varied by an explicit
//! [`seed`](DeploymentDescription::seed) via [`synthetic_deployment`]; there is
//! no wall-clock, no unseeded randomness, and no I/O anywhere in the crate.
//!
//! ```
//! use ruview_twin::*;
//!
//! // A reproducible synthetic deployment, then its zero-delta reference.
//! let twin = RfTwin::build(synthetic_deployment(7)).unwrap();
//! let observed = ObservationSet::from_twin_prediction(&twin);
//! let delta = twin.delta(&observed);
//! assert!(delta.is_zero()); // observation matches prediction ⇒ zero delta
//! assert_eq!(twin.evidence_level, ruview_ontology::EvidenceLevel::L0);
//! ```
#![forbid(unsafe_code)]
#![warn(missing_docs)]
mod delta;
mod predict;
mod twin;
pub use delta::{
compute_delta, compute_delta_with_threshold, LinkDelta, LinkDeltaStatus, LinkObservation,
ObservationSet, TwinDelta, DEFAULT_SIGNIFICANCE_THRESHOLD,
};
pub use predict::{
path_loss_db, predict_all, predict_link, wall_attenuation_db, ExpectedDistribution, Observable,
UnknownReason,
};
pub use twin::{
DeploymentDescription, LinkId, MultipathRecord, Point3, PropagationParams, RadioNode, RfTwin,
TwinError, VersionEvent, Wall, MAX_NODES, MAX_WALLS,
};
// Re-export the canonical ontology vocabulary the twin references, so consumers
// speak one semantics (ADR-297 rule 3, ADR-303).
pub use ruview_ontology::{Container, EvidenceLevel, SensorId, SpaceId};
impl RfTwin {
/// Predict the expected distribution for a link. See [`predict_link`].
#[must_use]
pub fn predict(&self, link: &LinkId) -> ExpectedDistribution {
predict_link(self, link)
}
/// Compute the delta of a supplied observation set against this twin, using
/// the default significance threshold. See [`compute_delta`].
#[must_use]
pub fn delta(&self, observed: &ObservationSet) -> TwinDelta {
compute_delta(self, observed)
}
}
/// Build a deterministic **SYNTHETIC** deployment from an explicit `seed`.
///
/// Four radios are placed in a `5 m × 4 m` room with one interior wall. Node
/// positions are jittered by a seeded `splitmix64` stream so distinct seeds give
/// distinct-but-reproducible scenes; the same seed always yields the same scene.
/// This is a simulation fixture, not a model of any real room.
#[must_use]
pub fn synthetic_deployment(seed: u64) -> DeploymentDescription {
let mut state = seed;
// Deterministic jitter helper in [-0.5, 0.5] metres.
let jitter = |s: &mut u64| -> f64 { splitmix64_unit(s) - 0.5 };
let base = [(0.5, 0.5), (4.5, 0.5), (4.5, 3.5), (0.5, 3.5)];
let nodes: Vec<RadioNode> = base
.iter()
.enumerate()
.map(|(i, (bx, by))| {
let x = (bx + jitter(&mut state)).clamp(0.0, 5.0);
let y = (by + jitter(&mut state)).clamp(0.0, 4.0);
RadioNode {
id: SensorId::new(format!("node-{i}")).expect("static id is valid"),
position: Point3::new(x, y, 1.0),
located_in: Container::Space {
id: SpaceId::new(format!("space-{seed}")).expect("static id is valid"),
},
tx_power_dbm: 20.0,
}
})
.collect();
let walls = vec![Wall {
id: "interior-wall".to_string(),
a: (2.5, 0.0),
b: (2.5, 4.0),
attenuation_db: 6.0,
}];
DeploymentDescription {
space: SpaceId::new(format!("space-{seed}")).expect("static id is valid"),
nodes,
walls,
params: PropagationParams::default_indoor(),
multipath: Vec::new(),
calibration_version: "synthetic-cal-v0".to_string(),
seed,
}
}
/// One `splitmix64` step mapped to a unit `f64` in `[0, 1)`. Deterministic; the
/// only source of scene variation in the crate.
fn splitmix64_unit(state: &mut u64) -> f64 {
*state = state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = *state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^= z >> 31;
// Top 53 bits → [0, 1).
((z >> 11) as f64) / ((1u64 << 53) as f64)
}
#[cfg(test)]
mod tests {
use super::*;
fn sensor(id: &str) -> SensorId {
SensorId::new(id).unwrap()
}
#[test]
fn expected_distribution_is_deterministic() {
// Same seed ⇒ identical twin ⇒ identical predictions, exactly.
let a = RfTwin::build(synthetic_deployment(42)).unwrap();
let b = RfTwin::build(synthetic_deployment(42)).unwrap();
assert_eq!(a, b);
for link in a.links() {
let da = a.predict(&link);
let db = b.predict(&link);
assert_eq!(da, db);
// Every link in this fixture is predictable (SYNTHETIC/L0).
let (mean, var) = da.known().expect("known distribution");
assert!(mean.is_finite());
assert!(var > 0.0);
}
// Distinct seeds give distinct-but-reproducible scenes.
let c = RfTwin::build(synthetic_deployment(43)).unwrap();
assert_ne!(a, c);
}
#[test]
fn zero_delta_when_observation_matches_prediction() {
let twin = RfTwin::build(synthetic_deployment(1)).unwrap();
let observed = ObservationSet::from_twin_prediction(&twin);
let delta = twin.delta(&observed);
assert!(delta.is_zero());
assert_eq!(delta.total_magnitude, 0.0);
assert!(delta.deviating_links.is_empty());
assert!(delta.unknown_links.is_empty());
assert_eq!(delta.baseline_version, 1);
// Every per-link deviation is exactly zero.
for ld in &delta.per_link {
match &ld.status {
LinkDeltaStatus::Evaluated { deviation, significance, .. } => {
assert_eq!(*deviation, 0.0);
assert_eq!(*significance, Some(0.0));
}
LinkDeltaStatus::Unknown { .. } => panic!("unexpected unknown link"),
}
}
}
#[test]
fn delta_is_nonzero_and_localized_to_a_moved_node() {
// Baseline twin and its self-consistent observation set.
let twin = RfTwin::build(synthetic_deployment(2)).unwrap();
let baseline_obs = ObservationSet::from_twin_prediction(&twin);
// Build a moved-node world: shift exactly one node, predict from it, and
// treat those predictions as the "observed" set against the baseline.
let mut moved = synthetic_deployment(2);
let moved_id = moved.nodes[0].id.clone();
moved.nodes[0].position.x += 2.0; // a clear physical relocation
let moved_twin = RfTwin::build(moved).unwrap();
let observed = ObservationSet::from_twin_prediction(&moved_twin);
let delta = twin.delta(&observed);
// A physical change produces a non-zero, significant delta.
assert!(delta.total_magnitude > 0.0);
assert!(!delta.deviating_links.is_empty());
assert!(delta.max_significance >= delta.significance_threshold);
// The change is localized: every deviating link touches the moved node,
// and links not touching it match the baseline exactly.
for link in &delta.deviating_links {
assert!(link.a == moved_id || link.b == moved_id, "deviation off the moved node");
}
for ld in &delta.per_link {
let touches_moved = ld.link.a == moved_id || ld.link.b == moved_id;
if let LinkDeltaStatus::Evaluated { deviation, .. } = &ld.status {
if !touches_moved {
assert_eq!(*deviation, 0.0, "untouched link should not deviate");
}
}
}
// Sanity: the untouched baseline observations still yield zero delta.
assert!(twin.delta(&baseline_obs).is_zero());
}
#[test]
fn delta_is_localized_to_a_new_reflector() {
let twin = RfTwin::build(synthetic_deployment(3)).unwrap();
// Add a new reflector that crosses exactly the node-0 ↔ node-1 path
// (both near y≈0.5) without crossing the far links.
let mut with_reflector = synthetic_deployment(3);
let n0 = with_reflector.nodes[0].id.clone();
let n1 = with_reflector.nodes[1].id.clone();
let (x0, _) = with_reflector.nodes[0].position.xy();
let (x1, _) = with_reflector.nodes[1].position.xy();
let mid_x = (x0 + x1) / 2.0;
with_reflector.walls.push(Wall {
id: "new-reflector".into(),
a: (mid_x, 0.0),
b: (mid_x, 1.2),
attenuation_db: 12.0,
});
let reflector_twin = RfTwin::build(with_reflector).unwrap();
let observed = ObservationSet::from_twin_prediction(&reflector_twin);
let delta = twin.delta(&observed);
assert!(delta.total_magnitude > 0.0);
let target = LinkId::new(n0, n1);
// The n0-n1 link deviates; it is the crossed path.
let target_delta = delta
.per_link
.iter()
.find(|ld| ld.link == target)
.expect("target link present");
match &target_delta.status {
LinkDeltaStatus::Evaluated { deviation, .. } => assert!(deviation.abs() > 0.0),
LinkDeltaStatus::Unknown { .. } => panic!("target should be evaluable"),
}
}
#[test]
fn unknown_is_first_class_not_an_error() {
let twin = RfTwin::build(synthetic_deployment(5)).unwrap();
// Predicting a link to a node that does not exist ⇒ UNKNOWN, not panic.
let ghost = LinkId::new(sensor("node-0"), sensor("ghost"));
assert!(matches!(
twin.predict(&ghost),
ExpectedDistribution::Unknown { reason: UnknownReason::MissingNode }
));
// Observing an out-of-twin link surfaces as an unknown link in the delta.
let observed = ObservationSet::new().with(ghost.clone(), -50.0);
let delta = twin.delta(&observed);
assert_eq!(delta.unknown_links, vec![ghost]);
assert_eq!(delta.total_magnitude, 0.0);
assert!(delta.deviating_links.is_empty());
// A self-link is UNKNOWN too, never a divide-by-zero.
let self_link = LinkId::new(sensor("node-0"), sensor("node-0"));
assert!(matches!(
twin.predict(&self_link),
ExpectedDistribution::Unknown { reason: UnknownReason::SelfLink }
));
}
#[test]
fn boundary_validation_rejects_malformed_input_without_panic() {
// Non-finite coordinate.
let mut d = synthetic_deployment(9);
d.nodes[0].position.x = f64::NAN;
assert!(matches!(
RfTwin::build(d),
Err(TwinError::NonFiniteCoordinate { .. })
));
// Duplicate node id.
let mut d = synthetic_deployment(9);
let dup = d.nodes[0].id.clone();
d.nodes[1].id = dup;
assert!(matches!(RfTwin::build(d), Err(TwinError::DuplicateNode { .. })));
// Invalid propagation parameter.
let mut d = synthetic_deployment(9);
d.params.path_loss_exponent = 0.0;
assert!(matches!(
RfTwin::build(d),
Err(TwinError::InvalidParameter { .. })
));
// Too many nodes (bounded allocation). Construct a minimal over-limit
// description directly to avoid allocating a huge scene twice.
let mut nodes = Vec::new();
for i in 0..(MAX_NODES + 1) {
nodes.push(RadioNode {
id: sensor(&format!("n{i}")),
position: Point3::new(0.0, 0.0, 0.0),
located_in: Container::Space { id: SpaceId::new("s").unwrap() },
tx_power_dbm: 20.0,
});
}
let over = DeploymentDescription {
space: SpaceId::new("s").unwrap(),
nodes,
walls: Vec::new(),
params: PropagationParams::default_indoor(),
multipath: Vec::new(),
calibration_version: "v0".into(),
seed: 0,
};
assert!(matches!(RfTwin::build(over), Err(TwinError::TooManyNodes { .. })));
}
#[test]
fn versioning_advances_on_events() {
let mut twin = RfTwin::build(synthetic_deployment(11)).unwrap();
assert_eq!(twin.version, 1);
assert_eq!(twin.advance_version(VersionEvent::Calibration), 2);
assert_eq!(twin.advance_version(VersionEvent::GeometryEdit), 3);
assert_eq!(twin.advance_version(VersionEvent::AcceptedChange), 4);
assert_eq!(twin.version, 4);
}
#[test]
fn serde_round_trip_is_lossless() {
let mut twin = RfTwin::build(synthetic_deployment(13)).unwrap();
twin.multipath.push(MultipathRecord {
link: LinkId::new(sensor("node-0"), sensor("node-1")),
extra_variance_db2: 9.0,
});
let json = serde_json::to_string_pretty(&twin).unwrap();
let back: RfTwin = serde_json::from_str(&json).unwrap();
assert_eq!(twin, back);
// Evidence discipline is on the wire: L0 / SYNTHETIC.
assert!(json.contains("\"evidence_level\": \"L0\""));
// The delta result also round-trips.
let observed = ObservationSet::from_twin_prediction(&twin).with(
LinkId::new(sensor("node-0"), sensor("node-2")),
-80.0,
);
let delta = twin.delta(&observed);
let dj = serde_json::to_string(&delta).unwrap();
let back_delta: TwinDelta = serde_json::from_str(&dj).unwrap();
assert_eq!(delta, back_delta);
}
}
+205
View File
@@ -0,0 +1,205 @@
//! The forward model: expected distribution per link (ADR-312 §1).
//!
//! **SYNTHETIC / L0.** This module is a *simulation* of an observable, not a
//! measurement. It implements a deliberately simple, documented log-distance
//! path-loss model with optional wall attenuation — clearly a didactic model,
//! not real RF. Nothing here is a hardware, `MEASURED`, or accuracy claim.
//!
//! An [`ExpectedDistribution`] is the mean/variance of a modelled observable
//! under the twin. Consistent with ADR-297 rule 1, insufficient information is
//! reported as [`ExpectedDistribution::Unknown`] — a first-class value, never an
//! error or a confident default.
use serde::{Deserialize, Serialize};
use crate::twin::{LinkId, PropagationParams, RfTwin, Wall};
/// The modelled observable a distribution describes. Kept as an enum so the twin
/// can grow phenomena without changing the delta contract.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Observable {
/// Modelled received signal strength, dBm (SYNTHETIC).
Rssi,
}
/// Why a link's expected distribution is unknown. UNKNOWN is a first-class
/// output (ADR-297 rule 1), not an error.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum UnknownReason {
/// One or both endpoints are not present in the twin.
MissingNode,
/// The two endpoints are effectively coincident, so path loss is undefined.
ZeroDistance,
/// The endpoints are the same node.
SelfLink,
/// The modelled computation produced a non-finite value.
NonFinite,
}
/// The predicted distribution of an observable over a link under the twin.
///
/// **SYNTHETIC / L0.** A model-relative statement, never evidence of a physical
/// state.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum ExpectedDistribution {
/// A modelled mean and (non-negative) variance for `observable`.
Known {
/// Which observable this describes.
observable: Observable,
/// Modelled mean, in the observable's units (dBm for RSSI).
mean: f64,
/// Modelled variance, in the observable's units squared (dB²).
variance: f64,
},
/// The twin cannot predict this link; carries a first-class reason.
Unknown {
/// Why it is unknown.
reason: UnknownReason,
},
}
impl ExpectedDistribution {
/// Borrow `(mean, variance)` when known.
#[must_use]
pub fn known(&self) -> Option<(f64, f64)> {
match self {
ExpectedDistribution::Known { mean, variance, .. } => Some((*mean, *variance)),
ExpectedDistribution::Unknown { .. } => None,
}
}
}
/// Below this distance (metres) two radios are treated as coincident and the
/// path loss is left [`UnknownReason::ZeroDistance`] rather than diverging.
const MIN_DISTANCE_M: f64 = 1e-6;
/// SYNTHETIC log-distance path loss, decibels:
/// `PL(d) = PL(d0) + 10·n·log10(d / d0) + Σ wall_attenuation`.
///
/// Returns `None` if the result is non-finite. `d` must already be `>=`
/// [`MIN_DISTANCE_M`].
#[must_use]
pub fn path_loss_db(params: &PropagationParams, distance_m: f64, wall_attenuation_db: f64) -> Option<f64> {
let pl = params.reference_loss_db
+ 10.0 * params.path_loss_exponent * (distance_m / params.reference_distance_m).log10()
+ wall_attenuation_db;
pl.is_finite().then_some(pl)
}
/// Total attenuation (dB) added by every wall whose floor-plan segment the
/// link's straight path crosses. Deterministic; walls are visited in order.
#[must_use]
pub fn wall_attenuation_db(walls: &[Wall], a_xy: (f64, f64), b_xy: (f64, f64)) -> f64 {
let mut sum = 0.0;
for wall in walls {
if segments_intersect(a_xy, b_xy, wall.a, wall.b) {
sum += wall.attenuation_db;
}
}
sum
}
/// Predict the expected distribution for one link under the twin.
///
/// **SYNTHETIC / L0.** The modelled transmitter is the link's canonical `a`
/// endpoint (lower id); the mean is `tx_power - PL(d)` and the variance is the
/// base shadowing variance plus any recorded multipath variance.
#[must_use]
pub fn predict_link(twin: &RfTwin, link: &LinkId) -> ExpectedDistribution {
if link.is_self_link() {
return ExpectedDistribution::Unknown {
reason: UnknownReason::SelfLink,
};
}
let (tx, rx) = match (twin.node(&link.a), twin.node(&link.b)) {
(Some(tx), Some(rx)) => (tx, rx),
_ => {
return ExpectedDistribution::Unknown {
reason: UnknownReason::MissingNode,
}
}
};
let distance = tx.position.distance_to(&rx.position);
if distance < MIN_DISTANCE_M {
return ExpectedDistribution::Unknown {
reason: UnknownReason::ZeroDistance,
};
}
let wall_att = wall_attenuation_db(&twin.walls, tx.position.xy(), rx.position.xy());
let pl = match path_loss_db(&twin.params, distance, wall_att) {
Some(pl) => pl,
None => {
return ExpectedDistribution::Unknown {
reason: UnknownReason::NonFinite,
}
}
};
let mean = tx.tx_power_dbm - pl;
let base_var = twin.params.shadowing_sigma_db * twin.params.shadowing_sigma_db;
let variance = base_var + twin.extra_variance(link);
if !(mean.is_finite() && variance.is_finite()) {
return ExpectedDistribution::Unknown {
reason: UnknownReason::NonFinite,
};
}
ExpectedDistribution::Known {
observable: Observable::Rssi,
mean,
variance,
}
}
/// Predict every link in the twin, paired with its distribution. Deterministic
/// ordering (matches [`RfTwin::links`](crate::twin::RfTwin::links)).
#[must_use]
pub fn predict_all(twin: &RfTwin) -> Vec<(LinkId, ExpectedDistribution)> {
twin.links()
.into_iter()
.map(|link| {
let dist = predict_link(twin, &link);
(link, dist)
})
.collect()
}
/// Robust 2D segment-intersection test used for wall crossing. Pure integer-free
/// geometry with an orientation sign; deterministic and allocation-free.
fn segments_intersect(p1: (f64, f64), p2: (f64, f64), p3: (f64, f64), p4: (f64, f64)) -> bool {
let d1 = orientation(p3, p4, p1);
let d2 = orientation(p3, p4, p2);
let d3 = orientation(p1, p2, p3);
let d4 = orientation(p1, p2, p4);
if ((d1 > 0.0 && d2 < 0.0) || (d1 < 0.0 && d2 > 0.0))
&& ((d3 > 0.0 && d4 < 0.0) || (d3 < 0.0 && d4 > 0.0))
{
return true;
}
on_segment(p3, p4, p1, d1)
|| on_segment(p3, p4, p2, d2)
|| on_segment(p1, p2, p3, d3)
|| on_segment(p1, p2, p4, d4)
}
/// Signed area (twice) of triangle `(a, b, c)`: `>0` left turn, `<0` right turn.
fn orientation(a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> f64 {
(b.0 - a.0) * (c.1 - a.1) - (b.1 - a.1) * (c.0 - a.0)
}
/// True when collinear point `c` (orientation `d == 0`) lies on segment `a-b`.
fn on_segment(a: (f64, f64), b: (f64, f64), c: (f64, f64), d: f64) -> bool {
d == 0.0
&& c.0 >= a.0.min(b.0)
&& c.0 <= a.0.max(b.0)
&& c.1 >= a.1.min(b.1)
&& c.1 <= a.1.max(b.1)
}
+433
View File
@@ -0,0 +1,433 @@
//! Twin model, geometry, and construction (ADR-312 §1).
//!
//! **SYNTHETIC / L0.** Every structure here is part of a *simulation scaffold*.
//! A [`RfTwin`] is a persistent, per-deployment *model* of an RF environment; it
//! **predicts** an expected observable, it does not **measure** one. No value it
//! holds or produces is a hardware, `MEASURED`, or accuracy claim (ADR-282 L0,
//! ADR-297 evidence discipline). Coordinates are a coarse metric abstraction,
//! and the propagation model in [`crate::predict`] is a deliberately simple
//! log-distance model, not real RF.
//!
//! Geometry and radio identity are *referenced* from the canonical
//! [`ruview_ontology`] vocabulary ([`SpaceId`], [`SensorId`], [`Container`],
//! [`SemanticProvenance`], [`EvidenceLevel`]) rather than reinvented — the twin
//! annotates the ADR-303 scene with RF state (ADR-312 "annotates and persists").
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use ruview_ontology::{Container, EvidenceLevel, SemanticProvenance, SensorId, SpaceId};
/// Upper bound on radio nodes accepted in one deployment. Bounds allocation on
/// untrusted input; construction beyond this is rejected, never truncated.
pub const MAX_NODES: usize = 1024;
/// Upper bound on wall/reflector segments accepted in one deployment.
pub const MAX_WALLS: usize = 4096;
/// A point in metric coordinates (metres), in the deployment's local ENU-style
/// frame. This is a coarse abstraction, not a surveyed position.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct Point3 {
/// East / x, metres.
pub x: f64,
/// North / y, metres.
pub y: f64,
/// Up / z, metres.
pub z: f64,
}
impl Point3 {
/// Construct a point.
#[must_use]
pub const fn new(x: f64, y: f64, z: f64) -> Self {
Self { x, y, z }
}
/// True when every coordinate is finite (rejects `NaN`/`inf` at the
/// boundary).
#[must_use]
pub fn is_finite(&self) -> bool {
self.x.is_finite() && self.y.is_finite() && self.z.is_finite()
}
/// Euclidean distance to another point, in metres.
#[must_use]
pub fn distance_to(&self, other: &Point3) -> f64 {
let dx = self.x - other.x;
let dy = self.y - other.y;
let dz = self.z - other.z;
(dx * dx + dy * dy + dz * dz).sqrt()
}
/// Horizontal-plane endpoint `(x, y)` used for wall-crossing tests.
#[must_use]
pub fn xy(&self) -> (f64, f64) {
(self.x, self.y)
}
}
/// A wall or static reflector, modelled (SYNTHETIC) as a floor-plan segment that
/// adds a fixed attenuation to any link whose straight path crosses it. This is
/// a coarse stand-in for the worldgraph `Wall { rf_attenuation_db }` (ADR-303),
/// not a solved electromagnetic obstacle.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Wall {
/// Stable, caller-supplied identifier for the wall/reflector.
pub id: String,
/// One floor-plan endpoint `(x, y)`, metres.
pub a: (f64, f64),
/// The other floor-plan endpoint `(x, y)`, metres.
pub b: (f64, f64),
/// Extra one-way attenuation added to a crossing link, in decibels.
pub attenuation_db: f64,
}
impl Wall {
/// True when both endpoints and the attenuation are finite.
#[must_use]
pub fn is_finite(&self) -> bool {
self.a.0.is_finite()
&& self.a.1.is_finite()
&& self.b.0.is_finite()
&& self.b.1.is_finite()
&& self.attenuation_db.is_finite()
}
}
/// A radio placed at a metric position. Identity and containment are ontology
/// references, not new vocabulary.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RadioNode {
/// Ontology sensor identity (ADR-302 authenticated device / ADR-303 sensor).
pub id: SensorId,
/// Metric position in the deployment frame.
pub position: Point3,
/// Ontology container the radio is placed in (a `Space` or `Zone`).
pub located_in: Container,
/// Modelled transmit power, dBm. SYNTHETIC parameter of the forward model.
pub tx_power_dbm: f64,
}
/// Parameters of the SYNTHETIC log-distance path-loss model (ADR-312 §1). These
/// describe a simple didactic propagation model, **not** a calibrated RF fit.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct PropagationParams {
/// Path-loss exponent `n` (free space ≈ 2.0; indoor typically higher). Must
/// be strictly positive.
pub path_loss_exponent: f64,
/// Reference distance `d0`, metres. Must be strictly positive.
pub reference_distance_m: f64,
/// Path loss at the reference distance `PL(d0)`, decibels.
pub reference_loss_db: f64,
/// Base shadowing standard deviation, decibels. Its square is the baseline
/// variance of the expected distribution. Must be non-negative.
pub shadowing_sigma_db: f64,
}
impl PropagationParams {
/// A neutral indoor-ish default (`n = 3`, `d0 = 1 m`, `PL(d0) = 40 dB`,
/// `sigma = 4 dB`). SYNTHETIC; asserts nothing about any real environment.
#[must_use]
pub fn default_indoor() -> Self {
Self {
path_loss_exponent: 3.0,
reference_distance_m: 1.0,
reference_loss_db: 40.0,
shadowing_sigma_db: 4.0,
}
}
/// Validate the parameters at the boundary.
fn validate(&self) -> Result<(), TwinError> {
if !(self.path_loss_exponent.is_finite() && self.path_loss_exponent > 0.0) {
return Err(TwinError::InvalidParameter {
what: "path_loss_exponent must be finite and > 0",
});
}
if !(self.reference_distance_m.is_finite() && self.reference_distance_m > 0.0) {
return Err(TwinError::InvalidParameter {
what: "reference_distance_m must be finite and > 0",
});
}
if !self.reference_loss_db.is_finite() {
return Err(TwinError::InvalidParameter {
what: "reference_loss_db must be finite",
});
}
if !(self.shadowing_sigma_db.is_finite() && self.shadowing_sigma_db >= 0.0) {
return Err(TwinError::InvalidParameter {
what: "shadowing_sigma_db must be finite and >= 0",
});
}
Ok(())
}
}
/// An unordered radio-to-radio link. Endpoints are stored in a canonical order
/// (`a <= b`) so the same physical link has one key regardless of direction.
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub struct LinkId {
/// The lexicographically smaller endpoint (the modelled transmitter).
pub a: SensorId,
/// The lexicographically larger endpoint (the modelled receiver).
pub b: SensorId,
}
impl LinkId {
/// Build a canonical link key from two endpoints (self-links are rejected by
/// the caller/twin, not here).
#[must_use]
pub fn new(x: SensorId, y: SensorId) -> Self {
if x <= y {
Self { a: x, b: y }
} else {
Self { a: y, b: x }
}
}
/// True when both endpoints refer to the same node (a degenerate self-link).
#[must_use]
pub fn is_self_link(&self) -> bool {
self.a == self.b
}
}
/// Recorded multipath / calibration state for one link: an extra variance
/// (dB²) folded into that link's expected distribution. A bounded temporal
/// summary in ADR-312 terms; here a single non-negative scalar.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct MultipathRecord {
/// The link this record applies to.
pub link: LinkId,
/// Extra variance added to the link's expected distribution, dB² (>= 0).
pub extra_variance_db2: f64,
}
/// Reason a twin version was advanced (ADR-312 §2 versioning). Kept for audit;
/// the twin is always relative to a *named* baseline version.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum VersionEvent {
/// A calibration event under ADR-298 advanced the baseline.
Calibration,
/// A deliberate geometry edit advanced the baseline.
GeometryEdit,
/// An operator-accepted physical change advanced the baseline.
AcceptedChange,
}
/// The input description of a deployment, from which a [`RfTwin`] is built.
///
/// Scenes are varied deterministically by [`seed`](Self::seed): there is **no**
/// wall-clock or unseeded randomness anywhere in this crate.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct DeploymentDescription {
/// Ontology space this deployment annotates (geometry reference, not copy).
pub space: SpaceId,
/// Radio nodes and their metric positions.
pub nodes: Vec<RadioNode>,
/// Walls / reflectors modelled as attenuating floor-plan segments.
pub walls: Vec<Wall>,
/// Propagation model parameters.
pub params: PropagationParams,
/// Recorded per-link multipath / calibration variance state.
#[serde(default)]
pub multipath: Vec<MultipathRecord>,
/// Referenced calibration baseline (ADR-298), as a version handle only.
pub calibration_version: String,
/// Explicit seed identifying the synthetic scene. Deterministic.
pub seed: u64,
}
/// A persistent, versioned, per-deployment RF *model* (ADR-312).
///
/// **SYNTHETIC / L0.** The twin's expected distributions and any propagation
/// simulation are a model (ADR-282 L0), never evidence that a physical state is
/// the case. Its load-bearing output is a *delta and its significance against
/// its own modelled variance* (see [`crate::delta`]).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RfTwin {
/// Monotonic baseline version. Advanced on calibration / geometry / accepted
/// change so a delta is always relative to a named baseline.
pub version: u64,
/// Ontology space reference this twin annotates.
pub space: SpaceId,
/// Radio nodes keyed by ontology sensor id (deterministic ordering).
pub nodes: BTreeMap<SensorId, RadioNode>,
/// Walls / reflectors.
pub walls: Vec<Wall>,
/// Propagation model parameters.
pub params: PropagationParams,
/// Recorded per-link multipath / calibration variance state.
pub multipath: Vec<MultipathRecord>,
/// Referenced calibration baseline handle (ADR-298).
pub calibration_version: String,
/// Provenance travelling with the twin (ADR-303 §2).
pub provenance: SemanticProvenance,
/// Evidence level of everything the twin asserts. Always `L0` (SYNTHETIC):
/// a twin predicts, it does not measure.
pub evidence_level: EvidenceLevel,
/// The synthetic scene seed this twin was built from.
pub seed: u64,
}
impl RfTwin {
/// Build a twin from a deployment description, validating every input at the
/// boundary. Never panics on malformed input; returns a typed [`TwinError`].
///
/// The built twin is always `EvidenceLevel::L0` (SYNTHETIC) and starts at
/// baseline `version = 1`.
pub fn build(desc: DeploymentDescription) -> Result<Self, TwinError> {
if desc.nodes.len() > MAX_NODES {
return Err(TwinError::TooManyNodes {
len: desc.nodes.len(),
max: MAX_NODES,
});
}
if desc.walls.len() > MAX_WALLS {
return Err(TwinError::TooManyWalls {
len: desc.walls.len(),
max: MAX_WALLS,
});
}
desc.params.validate()?;
let mut nodes: BTreeMap<SensorId, RadioNode> = BTreeMap::new();
for node in desc.nodes {
if !node.position.is_finite() {
return Err(TwinError::NonFiniteCoordinate {
node: node.id.as_str().to_string(),
});
}
if !node.tx_power_dbm.is_finite() {
return Err(TwinError::InvalidParameter {
what: "tx_power_dbm must be finite",
});
}
if nodes.insert(node.id.clone(), node.clone()).is_some() {
return Err(TwinError::DuplicateNode {
node: node.id.as_str().to_string(),
});
}
}
for wall in &desc.walls {
if !wall.is_finite() {
return Err(TwinError::NonFiniteCoordinate {
node: format!("wall:{}", wall.id),
});
}
}
for rec in &desc.multipath {
if !(rec.extra_variance_db2.is_finite() && rec.extra_variance_db2 >= 0.0) {
return Err(TwinError::InvalidParameter {
what: "multipath extra_variance_db2 must be finite and >= 0",
});
}
if rec.link.is_self_link() {
return Err(TwinError::SelfLink {
node: rec.link.a.as_str().to_string(),
});
}
}
Ok(Self {
version: 1,
space: desc.space,
nodes,
walls: desc.walls,
params: desc.params,
multipath: desc.multipath,
calibration_version: desc.calibration_version,
provenance: SemanticProvenance::declared("ruview-twin@0 (SYNTHETIC/L0)"),
evidence_level: EvidenceLevel::L0,
seed: desc.seed,
})
}
/// Every unordered link between distinct nodes, in deterministic order.
#[must_use]
pub fn links(&self) -> Vec<LinkId> {
let ids: Vec<&SensorId> = self.nodes.keys().collect();
let mut out = Vec::new();
for i in 0..ids.len() {
for j in (i + 1)..ids.len() {
out.push(LinkId::new(ids[i].clone(), ids[j].clone()));
}
}
out
}
/// Borrow a node by id.
#[must_use]
pub fn node(&self, id: &SensorId) -> Option<&RadioNode> {
self.nodes.get(id)
}
/// The extra variance recorded for a link (0 when none is recorded).
#[must_use]
pub fn extra_variance(&self, link: &LinkId) -> f64 {
self.multipath
.iter()
.find(|r| &r.link == link)
.map_or(0.0, |r| r.extra_variance_db2)
}
/// Advance the baseline version on an auditable event and return the new
/// version. History semantics (ADR-309) live outside this crate; here we
/// simply move the named baseline forward.
pub fn advance_version(&mut self, _event: VersionEvent) -> u64 {
self.version = self.version.saturating_add(1);
self.version
}
}
/// Boundary errors from building or operating on a twin. Malformed input yields
/// one of these; it never panics.
#[derive(Clone, Debug, PartialEq, Eq, Error)]
pub enum TwinError {
/// A node or wall carried a non-finite coordinate.
#[error("non-finite coordinate on `{node}`")]
NonFiniteCoordinate {
/// Offending node id (or `wall:<id>`).
node: String,
},
/// Two nodes shared the same id.
#[error("duplicate node id `{node}`")]
DuplicateNode {
/// The duplicated node id.
node: String,
},
/// A degenerate link whose endpoints are the same node.
#[error("self-link on node `{node}`")]
SelfLink {
/// The node id.
node: String,
},
/// A model parameter was out of its valid domain.
#[error("invalid parameter: {what}")]
InvalidParameter {
/// Human-readable reason.
what: &'static str,
},
/// More nodes than [`MAX_NODES`].
#[error("too many nodes: {len} exceeds maximum {max}")]
TooManyNodes {
/// Actual count.
len: usize,
/// The enforced maximum.
max: usize,
},
/// More walls than [`MAX_WALLS`].
#[error("too many walls: {len} exceeds maximum {max}")]
TooManyWalls {
/// Actual count.
len: usize,
/// The enforced maximum.
max: usize,
},
}