Skip to main content

ri_esp_policy/
lib.rs

1#![no_std]
2
3//! Deterministic sensor-policy crate for the ESP32/S3 physical-AI endpoint stack.
4//!
5//! Inspects a normalized `ri_esp_core::EnvironmentReading`, checks freshness,
6//! chooses a canonical `PromptId`, and returns a `PolicyDecision` with reason,
7//! confidence, route hint, and exact prompt text.
8//!
9//! It does not run language generation and does not claim model correctness.
10
11use ri_esp_core::{EnvironmentReading, ReadingStatus};
12
13/// Canonical prompt identifiers matching the 2026-07-01 hard test.
14#[derive(Debug, Clone, Copy, PartialEq, Eq)]
15pub enum PromptId {
16    HighHeatHumidity,
17    HotRoom,
18    HumidRoom,
19    MissingSensor,
20    NormalRoom,
21    SafeAction,
22    StaleData,
23    LocalFirstMeans,
24}
25
26impl PromptId {
27    /// Returns the canonical prompt text used to seed the S3 local-language model.
28    pub const fn prompt_text(self) -> &'static str {
29        match self {
30            PromptId::HighHeatHumidity => "high heat and humidity. action is ",
31            PromptId::HotRoom => "hot room. action is ",
32            PromptId::HumidRoom => "humid room. action is ",
33            PromptId::MissingSensor => "missing sensor. action is ",
34            PromptId::NormalRoom => "normal room. action is ",
35            PromptId::SafeAction => "safe action is ",
36            PromptId::StaleData => "stale data. action is ",
37            PromptId::LocalFirstMeans => "local first means ",
38        }
39    }
40}
41
42/// Why the policy chose this prompt.
43#[derive(Debug, Clone, Copy, PartialEq, Eq)]
44pub enum PolicyReason {
45    ProofPromptOverride,
46    SensorMissing,
47    StaleReading,
48    HotHumid,
49    HotRoom,
50    HumidRoom,
51    UnsupportedColdOrDry,
52    Normal,
53}
54
55/// Input context for a policy evaluation.
56#[derive(Debug, Clone, Copy, PartialEq)]
57pub struct PolicyContext<'a> {
58    pub reading: EnvironmentReading,
59    pub uptime_ms: u64,
60    pub last_read_ms: u64,
61    pub proof_prompt_override: Option<&'a str>,
62}
63
64impl<'a> PolicyContext<'a> {
65    pub fn new(reading: EnvironmentReading, uptime_ms: u64) -> Self {
66        Self {
67            reading,
68            uptime_ms,
69            last_read_ms: 0,
70            proof_prompt_override: None,
71        }
72    }
73
74    pub fn with_last_read_ms(mut self, ms: u64) -> Self {
75        self.last_read_ms = ms;
76        self
77    }
78
79    pub fn with_proof_prompt_override(mut self, prompt: &'a str) -> Self {
80        self.proof_prompt_override = Some(prompt);
81        self
82    }
83}
84
85/// The result of a policy evaluation.
86#[derive(Debug, Clone, Copy, PartialEq)]
87pub struct PolicyDecision {
88    pub prompt_id: PromptId,
89    pub reason: PolicyReason,
90    pub ai_route: bool,
91    pub confidence: f32,
92    prompt_text: &'static str,
93}
94
95impl PolicyDecision {
96    /// Returns the canonical prompt text for this decision.
97    pub fn prompt(&self) -> &'static str {
98        self.prompt_text
99    }
100}
101
102/// Thresholds for sensor policy decisions.
103pub struct SensorPolicy {
104    pub stale_ms: u64,
105    pub hot_f: f32,
106    pub cold_f: f32,
107    pub humid_pct: f32,
108    pub dry_pct: f32,
109}
110
111impl Default for SensorPolicy {
112    fn default() -> Self {
113        Self {
114            stale_ms: 120_000,
115            hot_f: 82.0,
116            cold_f: 60.0,
117            humid_pct: 65.0,
118            dry_pct: 25.0,
119        }
120    }
121}
122
123fn match_prompt_id(s: &str) -> PromptId {
124    match s {
125        "high heat and humidity. action is " => PromptId::HighHeatHumidity,
126        "hot room. action is " => PromptId::HotRoom,
127        "humid room. action is " => PromptId::HumidRoom,
128        "missing sensor. action is " => PromptId::MissingSensor,
129        "normal room. action is " => PromptId::NormalRoom,
130        "safe action is " => PromptId::SafeAction,
131        "stale data. action is " => PromptId::StaleData,
132        "local first means " => PromptId::LocalFirstMeans,
133        _ => PromptId::NormalRoom,
134    }
135}
136
137const fn ai_route_for(id: PromptId) -> bool {
138    match id {
139        PromptId::HighHeatHumidity => true,
140        PromptId::HotRoom => true,
141        PromptId::HumidRoom => true,
142        PromptId::MissingSensor => true,
143        PromptId::StaleData => true,
144        PromptId::NormalRoom => false,
145        PromptId::SafeAction => false,
146        PromptId::LocalFirstMeans => false,
147    }
148}
149
150fn decision(id: PromptId, reason: PolicyReason) -> PolicyDecision {
151    let conf = match id {
152        PromptId::HighHeatHumidity | PromptId::HotRoom | PromptId::HumidRoom => 0.9,
153        PromptId::MissingSensor => 0.0,
154        PromptId::StaleData => 0.3,
155        PromptId::NormalRoom => 1.0,
156        PromptId::SafeAction => 0.5,
157        PromptId::LocalFirstMeans => 0.8,
158    };
159    PolicyDecision {
160        prompt_id: id,
161        reason,
162        ai_route: ai_route_for(id),
163        confidence: conf,
164        prompt_text: id.prompt_text(),
165    }
166}
167
168impl SensorPolicy {
169    pub fn evaluate(&self, ctx: PolicyContext) -> PolicyDecision {
170        // 1. Explicit override — operator forces a specific prompt.
171        if let Some(override_prompt) = ctx.proof_prompt_override {
172            let id = match_prompt_id(override_prompt);
173            return PolicyDecision {
174                prompt_id: id,
175                reason: PolicyReason::ProofPromptOverride,
176                ai_route: ai_route_for(id),
177                confidence: 1.0,
178                prompt_text: id.prompt_text(),
179            };
180        }
181
182        let r = &ctx.reading;
183
184        // 2. Sensor missing or not connected.
185        if r.status == ReadingStatus::SensorReadFailed || r.status == ReadingStatus::NotConnected {
186            return decision(PromptId::MissingSensor, PolicyReason::SensorMissing);
187        }
188
189        // 3. Explicit stale status from the reading itself.
190        if r.status == ReadingStatus::Stale {
191            return decision(PromptId::StaleData, PolicyReason::StaleReading);
192        }
193
194        // 4. Timing-based staleness.
195        if ctx.uptime_ms.saturating_sub(ctx.last_read_ms) > self.stale_ms {
196            return decision(PromptId::StaleData, PolicyReason::StaleReading);
197        }
198
199        // 5-9. Temperature and humidity thresholds.
200        let temp_c = r.temperature_c.unwrap_or(25.0);
201        let humidity = r.humidity_pct.unwrap_or(50.0);
202        let temp_f = temp_c * 9.0 / 5.0 + 32.0;
203
204        if temp_f >= self.hot_f && humidity >= self.humid_pct {
205            return decision(PromptId::HighHeatHumidity, PolicyReason::HotHumid);
206        }
207
208        if temp_f >= self.hot_f {
209            return decision(PromptId::HotRoom, PolicyReason::HotRoom);
210        }
211
212        if humidity >= self.humid_pct {
213            return decision(PromptId::HumidRoom, PolicyReason::HumidRoom);
214        }
215
216        if temp_f <= self.cold_f || humidity <= self.dry_pct {
217            return decision(PromptId::SafeAction, PolicyReason::UnsupportedColdOrDry);
218        }
219
220        decision(PromptId::NormalRoom, PolicyReason::Normal)
221    }
222}