mirror of
https://github.com/ruvnet/RuView
synced 2026-08-11 20:41:44 +00:00
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:
@@ -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,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,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,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,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
@@ -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"
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(®ion, 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
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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, ¶ms);
|
||||
//! 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, ¶ms());
|
||||
|
||||
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, ¶ms());
|
||||
|
||||
// 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, ¶ms());
|
||||
|
||||
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, ¶ms());
|
||||
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, ¶ms());
|
||||
|
||||
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], ¶ms());
|
||||
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, ¶ms());
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -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)"),
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user