Files
ruvnet--RuView/v2/crates/ruview-active/src/policy.rs
T
Claude e46fcc6862 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
2026-08-11 13:13:04 +00:00

378 lines
14 KiB
Rust

//! 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))
}