Files
ruvnet--RuView/v2/crates/ruview-unified/src/control.rs
T
rUv 2e018f4f19 feat(ruview-unified): Unified RF spatial world model — ADR-273..282 (#1437)
Native frame contract, universal RF encoder, RF-aware Gaussian spatial memory, physics-guided synthetic RF worlds, edge sensing control plane, BLE-CS + factorized pose. All 10 ADRs (273-282) fully implemented and tested (99 tests); ADR-278 (radar inverse rendering) honestly gated with zero code as a future research program.

Deep-reviewed and hardware-tested against a live ESP32-C6 CSI node before merge: fixed a reachable panic, a silent NaN-corruption path, a cross-entity Gaussian conflation bug, and a wrong-center-frequency bug in the WiFi adapter (confirmed live: was misreporting channel 4 as 2437 MHz, now correctly reports 2427 MHz matching the hardware parser exactly). Added a standing hardware-in-the-loop test (examples/esp32_live_hardware_test.rs). Also fixed unrelated pre-existing issues surfaced during validation (wifi-densepose-core clippy warnings, a ruview-auth Windows build break, a sensing-server test flake).

Full review: https://gist.github.com/ruvnet/89795f3c4b8ea166cff5ac35ae4c7651
2026-07-26 14:37:56 -07:00

718 lines
28 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Programmable perception — the active sensing control plane (ADR-280).
//!
//! The shift this module implements: from *passive* sensing (accept
//! whatever measurements arrive) to *programmable* perception (the system
//! chooses where, when, how, and at what fidelity to sense, then resolves
//! uncertainty deliberately). Five contracts:
//!
//! 1. [`SensingTask`] — the evidence-aware task contract (ETSI ISAC
//! sensing-task vocabulary: purpose, area, resolution, latency,
//! confidence, retention, consumers, consent).
//! 2. [`SensingAction`] + [`InformationGoal`] — a request to actively
//! gather evidence against a hypothesis, bounded by latency, energy,
//! and a privacy ceiling.
//! 3. [`ActiveSensingPlanner`] over [`SpatialStateFreshness`] — age-of-
//! information scheduling: refresh what is stale, changing, and
//! important, not everything uniformly.
//! 4. [`CoherentSensorGroup`] — distributed-aperture fusion is allowed
//! **only** when time, phase, and geometry compatibility is proven;
//! out-of-bounds members fail closed (the dominant failure mode of
//! emerging systems is hidden synchronization/calibration dependence).
//! 5. [`FieldActuator`] + [`ActuationReceipt`] — programmable radio
//! environments (RIS, movable antennas) are actuators whose state
//! changes alter *who is observable*, so actuation demands the same
//! policy authorization and auditability as sensing itself.
//!
//! Plus [`TaskSufficientRepresentation`] — semantic, task-scoped
//! compression whose leakage rules are validated, not assumed.
use serde::{Deserialize, Serialize};
use crate::policy::{PolicyEngine, SensingPurpose};
use crate::tensor::RfModality;
use crate::{Result, UnifiedError};
/// RuField-aligned privacy classes (ADR-262 §3.3 vocabulary).
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
pub enum PrivacyClass {
/// Raw signal — never leaves the trust boundary.
P0,
/// Heavily aggregated, non-personal.
P1,
/// Anonymous presence/occupancy grade.
P2,
/// Behavioral inference grade.
P3,
/// Derived personal inference grade.
P4,
/// Identity-bound grade.
P5,
}
/// Axis-aligned spatial zone in the building frame.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct SpatialZone {
/// Zone identifier (matches ADR-277 `PrivacyZone` ids).
pub id: String,
/// Minimum corner, metres.
pub min_m: [f64; 3],
/// Maximum corner, metres.
pub max_m: [f64; 3],
}
impl SpatialZone {
/// Whether a point lies inside the zone.
#[must_use]
pub fn contains(&self, p: [f64; 3]) -> bool {
(0..3).all(|k| p[k] >= self.min_m[k] && p[k] <= self.max_m[k])
}
}
/// The evidence-aware sensing task contract (ADR-280 §2). Enforced
/// *before capture begins*, not applied later as metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SensingTask {
/// Task identifier.
pub task_id: u128,
/// Purpose (drives ADR-277 zone authorization).
pub purpose: SensingPurpose,
/// Target area.
pub target_area: SpatialZone,
/// Modalities the task may use.
pub modalities: Vec<RfModality>,
/// Requested spatial resolution, metres.
pub requested_resolution_m: f64,
/// Maximum acceptable result latency, ms.
pub maximum_latency_ms: u32,
/// Minimum confidence below which results become *no decision*.
pub minimum_confidence: f64,
/// Raw (P0) retention bound, seconds — local only.
pub raw_retention_seconds: u64,
/// Result retention bound, seconds.
pub result_retention_seconds: u64,
/// Principals allowed to consume results.
pub authorized_consumers: Vec<String>,
/// Consent reference, when the purpose requires one.
pub consent_reference: Option<String>,
/// Requested raw export. Kept in the contract for ISAC-vocabulary
/// compatibility, but see [`PolicyEngine`]-backed admission: ADR-277's
/// structural rule means this is **always refused** today.
pub raw_export_allowed: bool,
}
/// Admits a sensing task against the ADR-277 policy engine. Fail-closed:
/// unknown zone, ungranted purpose, identity single-gate, raw export, and
/// missing-consent identity tasks all deny.
pub fn admit_task(engine: &PolicyEngine, task: &SensingTask) -> Result<()> {
if task.raw_export_allowed {
return Err(UnifiedError::PolicyDenied(
"raw RF export is structurally disabled (ADR-277 §2.1); \
the contract field exists for ISAC vocabulary compatibility only"
.into(),
));
}
if !(task.minimum_confidence.is_finite() && (0.0..=1.0).contains(&task.minimum_confidence)) {
return Err(UnifiedError::InvalidInput("minimum_confidence must be in [0,1]".into()));
}
if !(task.requested_resolution_m.is_finite() && task.requested_resolution_m > 0.0) {
return Err(UnifiedError::InvalidInput("requested_resolution_m must be finite and > 0".into()));
}
if task.maximum_latency_ms == 0 {
return Err(UnifiedError::InvalidInput("maximum_latency_ms must be > 0".into()));
}
if task.modalities.is_empty() {
return Err(UnifiedError::InvalidInput("a sensing task must declare at least one modality".into()));
}
if task.purpose == SensingPurpose::IdentityRecognition && task.consent_reference.is_none() {
return Err(UnifiedError::PolicyDenied(
"identity recognition tasks require a consent reference".into(),
));
}
engine.authorize(&task.target_area.id, task.purpose)
}
/// What an active sensing request is trying to learn.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InformationGoal {
/// Human-readable hypothesis under test.
pub hypothesis: String,
/// Current uncertainty in `[0, 1]`.
pub current_uncertainty: f64,
/// Target uncertainty in `[0, 1]` (must be below current).
pub target_uncertainty: f64,
/// Expected information gain of the action (heuristic units).
pub expected_information_gain: f64,
}
/// A deliberate act of sensing (ADR-280 §3).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SensingAction {
/// Action identifier.
pub action_id: String,
/// Region to observe.
pub target_region: SpatialZone,
/// Modality to use.
pub modality: RfModality,
/// Goal that justifies the action.
pub desired_information: InformationGoal,
/// Latency budget, ms.
pub maximum_latency_ms: u32,
/// Energy budget, joules.
pub energy_budget_j: f64,
/// Highest privacy class the action may produce.
pub privacy_ceiling: PrivacyClass,
}
/// Freshness state of one spatial region (age-of-information model,
/// ADR-280 §4).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SpatialStateFreshness {
/// Region identifier.
pub region_id: String,
/// Region geometry.
pub region: SpatialZone,
/// Last observation, ns since epoch.
pub last_observed_ns: u64,
/// Expected change rate (events/s scale factor).
pub expected_change_rate: f64,
/// Uncertainty growth per second of staleness.
pub uncertainty_growth_rate: f64,
/// Business criticality weight (≥ 0).
pub business_criticality: f64,
/// Cost of sensing this region (energy/traffic units, > 0).
pub sensing_cost: f64,
}
impl SpatialStateFreshness {
/// Uncertainty accumulated since the last observation, capped at 1.
#[must_use]
pub fn uncertainty_at(&self, now_ns: u64) -> f64 {
let age_s = now_ns.saturating_sub(self.last_observed_ns) as f64 / 1e9;
(self.uncertainty_growth_rate * age_s).min(1.0)
}
/// Refresh priority: `uncertainty × change rate × criticality ÷ cost`.
#[must_use]
pub fn priority(&self, now_ns: u64) -> f64 {
self.uncertainty_at(now_ns) * self.expected_change_rate * self.business_criticality
/ self.sensing_cost.max(1e-9)
}
}
/// Age-of-information sensing scheduler: refreshes regions in priority
/// order instead of uniformly.
#[derive(Debug, Default)]
pub struct ActiveSensingPlanner {
regions: Vec<SpatialStateFreshness>,
/// Priority below which a region is not worth sensing this cycle.
pub priority_threshold: f64,
}
impl ActiveSensingPlanner {
/// New planner with a priority threshold.
#[must_use]
pub fn new(priority_threshold: f64) -> Self {
Self { regions: Vec::new(), priority_threshold }
}
/// Registers or replaces a region.
pub fn upsert_region(&mut self, region: SpatialStateFreshness) {
if let Some(r) = self.regions.iter_mut().find(|r| r.region_id == region.region_id) {
*r = region;
} else {
self.regions.push(region);
}
}
/// Marks a region observed at `now_ns`.
pub fn mark_observed(&mut self, region_id: &str, now_ns: u64) {
if let Some(r) = self.regions.iter_mut().find(|r| r.region_id == region_id) {
r.last_observed_ns = now_ns;
}
}
/// Highest-priority region above the threshold, as a concrete
/// [`SensingAction`]; `None` when nothing is worth sensing.
#[must_use]
pub fn next_action(&self, now_ns: u64, modality: RfModality) -> Option<SensingAction> {
let best = self
.regions
.iter()
.map(|r| (r.priority(now_ns), r))
.filter(|(p, _)| *p >= self.priority_threshold)
.max_by(|a, b| a.0.partial_cmp(&b.0).unwrap_or(std::cmp::Ordering::Equal))?;
let (priority, region) = best;
let uncertainty = region.uncertainty_at(now_ns);
Some(SensingAction {
action_id: format!("aoi-{}-{now_ns}", region.region_id),
target_region: region.region.clone(),
modality,
desired_information: InformationGoal {
hypothesis: format!("state of region {} is stale", region.region_id),
current_uncertainty: uncertainty,
target_uncertainty: (uncertainty * 0.2).min(0.05),
expected_information_gain: priority,
},
maximum_latency_ms: 500,
energy_budget_j: region.sensing_cost,
privacy_ceiling: PrivacyClass::P2,
})
}
}
/// Clock/phase/geometry sync state reported by one member of a
/// distributed aperture.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemberSyncState {
/// Member identifier.
pub member_id: String,
/// Measured time error vs the group reference, ns.
pub time_error_ns: f64,
/// Measured phase error vs the group reference, rad.
pub phase_error_rad: f64,
/// Hash of the member's calibrated baseline geometry.
pub geometry_hash: u64,
}
/// A coherent sensing group (ADR-280 §5): no coherent fusion unless
/// time, phase, and geometry compatibility is *proven*.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoherentSensorGroup {
/// Group identifier.
pub group_id: String,
/// Member identifiers.
pub members: Vec<String>,
/// Maximum tolerated time error, ns.
pub maximum_time_error_ns: f64,
/// Maximum tolerated phase error, rad.
pub maximum_phase_error_rad: f64,
/// Required baseline geometry hash (all members must match).
pub baseline_geometry_hash: u64,
}
impl CoherentSensorGroup {
/// Fail-closed fusion gate: every group member must report, be within
/// time and phase bounds, and match the baseline geometry hash.
/// Unknown reporters, missing members, or any out-of-bounds member
/// deny fusion with a typed error.
pub fn can_fuse(&self, states: &[MemberSyncState]) -> Result<()> {
for member in &self.members {
let Some(s) = states.iter().find(|s| &s.member_id == member) else {
return Err(UnifiedError::PolicyDenied(format!(
"coherent fusion denied: member {member:?} did not report sync state"
)));
};
if !s.time_error_ns.is_finite() || s.time_error_ns.abs() > self.maximum_time_error_ns {
return Err(UnifiedError::PolicyDenied(format!(
"coherent fusion denied: {member:?} time error {} ns exceeds {} ns",
s.time_error_ns, self.maximum_time_error_ns
)));
}
if !s.phase_error_rad.is_finite()
|| s.phase_error_rad.abs() > self.maximum_phase_error_rad
{
return Err(UnifiedError::PolicyDenied(format!(
"coherent fusion denied: {member:?} phase error {} rad exceeds {} rad",
s.phase_error_rad, self.maximum_phase_error_rad
)));
}
if s.geometry_hash != self.baseline_geometry_hash {
return Err(UnifiedError::PolicyDenied(format!(
"coherent fusion denied: {member:?} geometry hash mismatch"
)));
}
}
for s in states {
if !self.members.contains(&s.member_id) {
return Err(UnifiedError::PolicyDenied(format!(
"coherent fusion denied: {:?} is not a group member",
s.member_id
)));
}
}
Ok(())
}
}
/// Kind of radio-environment actuator.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ActuatorKind {
/// Reconfigurable intelligent surface.
Ris,
/// Mechanically movable antenna.
MovableAntenna,
/// Fluid antenna.
FluidAntenna,
}
/// A programmable radio-environment actuator (ADR-280 §6).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FieldActuator {
/// Actuator identifier.
pub actuator_id: String,
/// Kind.
pub kind: ActuatorKind,
/// Pose in the building frame.
pub pose_m: [f64; 3],
/// Named states the actuator supports.
pub supported_states: Vec<String>,
/// Zone whose observability this actuator changes.
pub affected_zone_id: String,
}
/// Audit receipt for an applied actuation. Constructed only by
/// [`request_actuation`] — there is no other way to obtain one, so every
/// state change that alters observability is policy-checked and logged.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ActuationReceipt {
/// State that was requested.
pub requested_state: String,
/// State actually applied.
pub applied_state: String,
/// Application time, ns.
pub applied_ns: u64,
/// Controller identity.
pub controller_id: String,
/// Purpose under which the actuation was authorized.
pub purpose: SensingPurpose,
}
/// Requests an actuator state change. Denied unless (a) the actuator
/// supports the state and (b) the affected zone grants the purpose under
/// the ADR-277 engine — changing an RIS configuration can change *which
/// rooms and people are observable*, so it is governed like sensing.
pub fn request_actuation(
engine: &PolicyEngine,
actuator: &FieldActuator,
state: &str,
purpose: SensingPurpose,
controller_id: &str,
now_ns: u64,
) -> Result<ActuationReceipt> {
if !actuator.supported_states.iter().any(|s| s == state) {
return Err(UnifiedError::InvalidInput(format!(
"actuator {:?} does not support state {state:?}",
actuator.actuator_id
)));
}
engine.authorize(&actuator.affected_zone_id, purpose)?;
Ok(ActuationReceipt {
requested_state: state.to_string(),
applied_state: state.to_string(),
applied_ns: now_ns,
controller_id: controller_id.to_string(),
purpose,
})
}
/// A task-scoped semantic compression of observations (ADR-280 §7):
/// transmit only the information the current physical task needs.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskSufficientRepresentation {
/// Task this representation serves.
pub task_id: u128,
/// Source frame receipt ids (lineage).
pub source_receipts: Vec<u128>,
/// Compressed semantic state.
pub semantic_state: Vec<f32>,
/// Claimed information bound, bits.
pub information_bound_bits: f64,
/// Information classes *explicitly* excluded (e.g. `"identity"`,
/// `"vitals"`, `"trajectory-history"`).
pub excluded_information: Vec<String>,
/// Privacy class of the representation.
pub privacy_class: PrivacyClass,
}
/// Purpose-scoped leakage validation: compression must remain task
/// scoped. A representation sufficient for anonymous occupancy must not
/// retain identity information; each purpose has a privacy-class ceiling
/// and a set of information classes it must exclude.
pub fn validate_representation(
rep: &TaskSufficientRepresentation,
purpose: SensingPurpose,
) -> Result<()> {
let (ceiling, must_exclude): (PrivacyClass, &[&str]) = match purpose {
SensingPurpose::Presence | SensingPurpose::ChannelDiagnostics => {
(PrivacyClass::P2, &["identity", "vitals"])
}
SensingPurpose::Activity | SensingPurpose::Localization => {
(PrivacyClass::P3, &["identity"])
}
SensingPurpose::Vitals | SensingPurpose::PoseTracking => (PrivacyClass::P4, &["identity"]),
SensingPurpose::IdentityRecognition => (PrivacyClass::P5, &[]),
};
if rep.privacy_class > ceiling {
return Err(UnifiedError::PolicyDenied(format!(
"representation class {:?} exceeds ceiling {ceiling:?} for purpose {purpose:?}",
rep.privacy_class
)));
}
for class in must_exclude {
if !rep.excluded_information.iter().any(|e| e == class) {
return Err(UnifiedError::PolicyDenied(format!(
"purpose {purpose:?} requires the representation to explicitly exclude {class:?}"
)));
}
}
if rep.source_receipts.is_empty() {
return Err(UnifiedError::InvalidInput(
"task-sufficient representation must carry source lineage".into(),
));
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::policy::PrivacyZone;
fn zone(id: &str) -> SpatialZone {
SpatialZone { id: id.into(), min_m: [0.0; 3], max_m: [5.0, 4.0, 3.0] }
}
fn engine_with(purposes: &[SensingPurpose]) -> PolicyEngine {
let mut e = PolicyEngine::new();
e.upsert_zone(PrivacyZone {
id: "lab".into(),
allowed_purposes: purposes.iter().copied().collect(),
retention_s: 3600,
identity_explicitly_enabled: false,
});
e
}
fn task(purpose: SensingPurpose, raw_export: bool) -> SensingTask {
SensingTask {
task_id: 1,
purpose,
target_area: zone("lab"),
modalities: vec![RfModality::WifiCsi],
requested_resolution_m: 0.5,
maximum_latency_ms: 100,
minimum_confidence: 0.8,
raw_retention_seconds: 60,
result_retention_seconds: 3600,
authorized_consumers: vec!["ha-bridge".into()],
consent_reference: None,
raw_export_allowed: raw_export,
}
}
#[test]
fn task_admission_is_fail_closed() {
let engine = engine_with(&[SensingPurpose::Presence]);
assert!(admit_task(&engine, &task(SensingPurpose::Presence, false)).is_ok());
// Raw export is refused regardless of any other grant.
assert!(matches!(
admit_task(&engine, &task(SensingPurpose::Presence, true)),
Err(UnifiedError::PolicyDenied(_))
));
// Ungranted purpose denied.
assert!(admit_task(&engine, &task(SensingPurpose::Localization, false)).is_err());
// Identity without consent denied before even reaching the zone check.
assert!(admit_task(&engine, &task(SensingPurpose::IdentityRecognition, false)).is_err());
}
#[test]
fn planner_prioritizes_stale_critical_regions() {
let mut planner = ActiveSensingPlanner::new(0.01);
let mk = |id: &str, change: f64, crit: f64, cost: f64| SpatialStateFreshness {
region_id: id.into(),
region: zone(id),
last_observed_ns: 0,
expected_change_rate: change,
uncertainty_growth_rate: 0.05,
business_criticality: crit,
sensing_cost: cost,
};
planner.upsert_region(mk("server-room", 0.1, 5.0, 1.0));
planner.upsert_region(mk("emergency-exit", 0.5, 8.0, 1.0));
planner.upsert_region(mk("storage", 0.01, 0.5, 1.0));
let now = 10_000_000_000; // 10 s of staleness everywhere
let action = planner.next_action(now, RfModality::WifiCsi).expect("something stale");
assert_eq!(action.target_region.id, "emergency-exit", "highest priority wins");
// After observing it, the next-highest region is selected.
planner.mark_observed("emergency-exit", now);
let action = planner.next_action(now, RfModality::WifiCsi).expect("next region");
assert_eq!(action.target_region.id, "server-room");
}
#[test]
fn planner_reduces_sensing_traffic_versus_uniform_refresh() {
// 20 regions, one hot (changes often, critical), the rest cold.
let mut planner = ActiveSensingPlanner::new(0.05);
for i in 0..20 {
let hot = i == 0;
planner.upsert_region(SpatialStateFreshness {
region_id: format!("r{i}"),
region: zone("lab"),
last_observed_ns: 0,
expected_change_rate: if hot { 1.0 } else { 0.01 },
uncertainty_growth_rate: 0.2,
business_criticality: if hot { 5.0 } else { 0.5 },
sensing_cost: 1.0,
});
}
// Simulate 100 scheduling ticks, 1 s apart. Uniform refresh would
// sense 20 regions × 100 ticks = 2000 observations; the planner
// senses at most one region per tick and only above threshold.
let mut actions = 0;
for tick in 1..=100u64 {
let now = tick * 1_000_000_000;
if let Some(a) = planner.next_action(now, RfModality::WifiCsi) {
planner.mark_observed(&a.target_region.id, now);
actions += 1;
}
}
let uniform = 20 * 100;
let reduction = 1.0 - actions as f64 / uniform as f64;
println!("AoI planner: {actions} observations vs {uniform} uniform ({reduction:.2} reduction)");
assert!(
reduction >= 0.70,
"planner must cut sensing traffic by >= 70 % in sparse environments, got {reduction:.2}"
);
assert!(actions > 0, "the hot region must still be observed");
}
#[test]
fn coherent_fusion_fails_closed() {
let group = CoherentSensorGroup {
group_id: "aisle-3".into(),
members: vec!["ap-1".into(), "ap-2".into()],
maximum_time_error_ns: 50.0,
maximum_phase_error_rad: 0.2,
baseline_geometry_hash: 0xBEEF,
};
let ok = |id: &str| MemberSyncState {
member_id: id.into(),
time_error_ns: 10.0,
phase_error_rad: 0.05,
geometry_hash: 0xBEEF,
};
assert!(group.can_fuse(&[ok("ap-1"), ok("ap-2")]).is_ok());
// Missing member ⇒ deny.
assert!(group.can_fuse(&[ok("ap-1")]).is_err());
// Clock out of bounds ⇒ deny.
let mut drift = ok("ap-2");
drift.time_error_ns = 400.0;
assert!(group.can_fuse(&[ok("ap-1"), drift]).is_err());
// Phase out of bounds ⇒ deny.
let mut phase = ok("ap-2");
phase.phase_error_rad = 1.0;
assert!(group.can_fuse(&[ok("ap-1"), phase]).is_err());
// Geometry changed since calibration ⇒ deny.
let mut moved = ok("ap-2");
moved.geometry_hash = 0xDEAD;
assert!(group.can_fuse(&[ok("ap-1"), moved]).is_err());
// A non-member reporting in ⇒ deny.
assert!(group.can_fuse(&[ok("ap-1"), ok("ap-2"), ok("rogue")]).is_err());
}
#[test]
fn actuation_requires_policy_authorization() {
let engine = engine_with(&[SensingPurpose::Presence]);
let ris = FieldActuator {
actuator_id: "ris-7".into(),
kind: ActuatorKind::Ris,
pose_m: [2.0, 0.0, 2.5],
supported_states: vec!["beam-east".into(), "beam-west".into()],
affected_zone_id: "lab".into(),
};
// Authorized purpose + supported state ⇒ receipt.
let receipt =
request_actuation(&engine, &ris, "beam-east", SensingPurpose::Presence, "ctl-1", 99)
.expect("authorized actuation");
assert_eq!(receipt.applied_state, "beam-east");
assert_eq!(receipt.purpose, SensingPurpose::Presence);
// Unsupported state ⇒ deny.
assert!(request_actuation(&engine, &ris, "beam-up", SensingPurpose::Presence, "c", 0)
.is_err());
// Purpose not granted in the affected zone ⇒ deny (an RIS cannot be
// steered to observe a zone for a purpose the zone never granted).
assert!(request_actuation(&engine, &ris, "beam-east", SensingPurpose::Vitals, "c", 0)
.is_err());
}
#[test]
fn task_sufficient_representation_is_leakage_checked() {
let rep = |class: PrivacyClass, excluded: &[&str]| TaskSufficientRepresentation {
task_id: 5,
source_receipts: vec![1, 2],
semantic_state: vec![0.1, 0.9],
information_bound_bits: 8.0,
excluded_information: excluded.iter().map(|s| (*s).to_string()).collect(),
privacy_class: class,
};
// Occupancy-grade representation excluding identity + vitals: fine.
assert!(validate_representation(
&rep(PrivacyClass::P2, &["identity", "vitals"]),
SensingPurpose::Presence
)
.is_ok());
// Same purpose but the representation forgot to exclude identity: deny.
assert!(validate_representation(
&rep(PrivacyClass::P2, &["vitals"]),
SensingPurpose::Presence
)
.is_err());
// Class above the purpose ceiling: deny.
assert!(validate_representation(
&rep(PrivacyClass::P4, &["identity", "vitals"]),
SensingPurpose::Presence
)
.is_err());
// No lineage: deny.
let mut orphan = rep(PrivacyClass::P2, &["identity", "vitals"]);
orphan.source_receipts.clear();
assert!(validate_representation(&orphan, SensingPurpose::Presence).is_err());
}
/// Only `Presence` was ever exercised above; the other three
/// ceiling/exclusion-set branches (Activity/Localization at P3,
/// Vitals/PoseTracking at P4, IdentityRecognition at P5) had zero test
/// coverage — a bug in any of them would go undetected.
#[test]
fn task_sufficient_representation_covers_every_purpose_branch() {
let rep = |class: PrivacyClass, excluded: &[&str]| TaskSufficientRepresentation {
task_id: 6,
source_receipts: vec![1],
semantic_state: vec![0.2],
information_bound_bits: 4.0,
excluded_information: excluded.iter().map(|s| (*s).to_string()).collect(),
privacy_class: class,
};
for purpose in [SensingPurpose::Activity, SensingPurpose::Localization] {
// P3 ceiling excluding identity: fine.
assert!(validate_representation(&rep(PrivacyClass::P3, &["identity"]), purpose).is_ok());
// Forgot to exclude identity: deny.
assert!(validate_representation(&rep(PrivacyClass::P3, &[]), purpose).is_err());
// Above the P3 ceiling: deny.
assert!(validate_representation(&rep(PrivacyClass::P4, &["identity"]), purpose).is_err());
}
for purpose in [SensingPurpose::Vitals, SensingPurpose::PoseTracking] {
// P4 ceiling excluding identity: fine.
assert!(validate_representation(&rep(PrivacyClass::P4, &["identity"]), purpose).is_ok());
// Forgot to exclude identity: deny.
assert!(validate_representation(&rep(PrivacyClass::P4, &[]), purpose).is_err());
// Above the P4 ceiling: deny.
assert!(validate_representation(&rep(PrivacyClass::P5, &["identity"]), purpose).is_err());
}
// IdentityRecognition: P5 ceiling, nothing required to be excluded.
assert!(validate_representation(&rep(PrivacyClass::P5, &[]), SensingPurpose::IdentityRecognition)
.is_ok());
// Still bounded — no class exceeds P5, so exercise the lineage guard instead.
let mut orphan = rep(PrivacyClass::P5, &[]);
orphan.source_receipts.clear();
assert!(validate_representation(&orphan, SensingPurpose::IdentityRecognition).is_err());
}
}