Skip to main content

ri_esp_proof/
lib.rs

1#![no_std]
2
3use heapless::String;
4use ri_esp_core::EnvironmentReading;
5use ri_esp_local_language::DEFAULT_LOCAL_LANGUAGE_MODEL;
6use ri_esp_policy::{PolicyContext, PolicyReason, PromptId, SensorPolicy};
7use ri_esp_tiered::FeatureTransfer;
8
9pub const PROOF_RECEIPT_SCHEMA: &str = "ri_esp_proof_receipt_v1";
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
12pub enum ProofDecision {
13    LocalOnly,
14    ForwardToAi,
15}
16
17impl ProofDecision {
18    pub const fn as_str(self) -> &'static str {
19        match self {
20            Self::LocalOnly => "local_only",
21            Self::ForwardToAi => "forward_to_ai",
22        }
23    }
24}
25
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum RouteReason {
28    LocalConfident,
29    LowConfidence,
30    SensorMissing,
31    StaleReading,
32    TemperatureAndHumidityOutOfRange,
33    TemperatureOutOfRange,
34    HumidityOutOfRange,
35    UnsupportedColdOrDry,
36    OperatorRequest,
37    ScheduleTick,
38    AnomalyMatch,
39}
40
41impl RouteReason {
42    pub const fn as_str(self) -> &'static str {
43        match self {
44            Self::LocalConfident => "local_confident",
45            Self::LowConfidence => "low_confidence",
46            Self::SensorMissing => "sensor_missing",
47            Self::StaleReading => "stale_reading",
48            Self::TemperatureAndHumidityOutOfRange => "temperature_and_humidity_out_of_range",
49            Self::TemperatureOutOfRange => "temperature_out_of_range",
50            Self::HumidityOutOfRange => "humidity_out_of_range",
51            Self::UnsupportedColdOrDry => "unsupported_cold_or_dry",
52            Self::OperatorRequest => "operator_request",
53            Self::ScheduleTick => "schedule_tick",
54            Self::AnomalyMatch => "anomaly_match",
55        }
56    }
57
58    pub const fn decision(self) -> ProofDecision {
59        match self {
60            Self::LocalConfident | Self::UnsupportedColdOrDry => ProofDecision::LocalOnly,
61            _ => ProofDecision::ForwardToAi,
62        }
63    }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq)]
67pub struct ProofConfig {
68    pub confidence_threshold: f32,
69    pub min_temperature_c: f32,
70    pub max_temperature_c: f32,
71    pub min_humidity_pct: f32,
72    pub max_humidity_pct: f32,
73}
74
75impl Default for ProofConfig {
76    fn default() -> Self {
77        Self {
78            confidence_threshold: 0.75,
79            min_temperature_c: 15.0,
80            max_temperature_c: 30.0,
81            min_humidity_pct: 20.0,
82            max_humidity_pct: 75.0,
83        }
84    }
85}
86
87#[derive(Debug, Clone, PartialEq)]
88pub struct ProofReceipt {
89    pub schema_version: &'static str,
90    pub event_id: u64,
91    pub device_id: String<48>,
92    pub timestamp_ms: u64,
93    pub sensor: EnvironmentReading,
94    pub sentinel_confidence: f32,
95    pub decision: ProofDecision,
96    pub route_reason: RouteReason,
97    pub reason: String<48>,
98    pub ai_route: String<64>,
99    pub local_language_model: String<48>,
100    pub local_language_prompt: String<96>,
101}
102
103impl ProofReceipt {
104    pub fn to_feature_transfer<const N: usize>(&self) -> Option<FeatureTransfer<N>> {
105        let mut transfer = FeatureTransfer::new(
106            self.sensor.temperature_c.unwrap_or(0.0),
107            self.sentinel_confidence,
108            self.timestamp_ms,
109        );
110
111        if let Some(v) = self.sensor.temperature_c {
112            transfer.features.push(v).ok()?;
113        }
114        if let Some(v) = self.sensor.humidity_pct {
115            transfer.features.push(v).ok()?;
116        }
117        if let Some(v) = self.sensor.heat_index_c {
118            transfer.features.push(v).ok()?;
119        }
120
121        Some(transfer)
122    }
123
124    pub fn to_json<const N: usize>(&self) -> Option<String<N>> {
125        let mut out = String::<N>::new();
126        push_str(&mut out, "{")?;
127        push_json_str(&mut out, "schema", self.schema_version, false)?;
128        push_json_u64(&mut out, "event_id", self.event_id, false)?;
129        push_json_str(&mut out, "device_id", self.device_id.as_str(), false)?;
130        push_json_u64(&mut out, "timestamp_ms", self.timestamp_ms, false)?;
131        push_json_str(&mut out, "decision", self.decision.as_str(), false)?;
132        push_json_str(&mut out, "reason", self.reason.as_str(), false)?;
133        push_json_str(&mut out, "route_reason", self.route_reason.as_str(), false)?;
134        push_json_f32(
135            &mut out,
136            "sentinel_confidence",
137            self.sentinel_confidence,
138            false,
139        )?;
140        push_json_str(&mut out, "ai_route", self.ai_route.as_str(), false)?;
141        push_json_str(
142            &mut out,
143            "local_language_model",
144            self.local_language_model.as_str(),
145            false,
146        )?;
147        push_json_str(
148            &mut out,
149            "local_language_prompt",
150            self.local_language_prompt.as_str(),
151            false,
152        )?;
153        push_str(&mut out, "\"sensor\":{")?;
154        push_json_opt_f32(&mut out, "temperature_c", self.sensor.temperature_c, false)?;
155        push_json_opt_f32(&mut out, "humidity_pct", self.sensor.humidity_pct, false)?;
156        push_json_opt_f32(&mut out, "heat_index_c", self.sensor.heat_index_c, true)?;
157        push_str(&mut out, "}}")?;
158        Some(out)
159    }
160}
161
162#[derive(Debug, Clone, PartialEq)]
163pub struct ProofEngine {
164    config: ProofConfig,
165    next_event_id: u64,
166}
167
168struct BuildReceiptInput<'a> {
169    device_id: &'a str,
170    timestamp_ms: u64,
171    sensor: EnvironmentReading,
172    sentinel_confidence: f32,
173    ai_route: &'a str,
174    route_reason: RouteReason,
175    local_language_model: &'a str,
176    local_language_prompt: &'a str,
177}
178
179impl ProofEngine {
180    pub const fn new(config: ProofConfig) -> Self {
181        Self {
182            config,
183            next_event_id: 1,
184        }
185    }
186
187    pub fn evaluate(
188        &mut self,
189        device_id: &str,
190        timestamp_ms: u64,
191        sensor: EnvironmentReading,
192        sentinel_confidence: f32,
193        ai_route: &str,
194    ) -> ProofReceipt {
195        let reason = self.reason(sensor, sentinel_confidence);
196        self.build_receipt(
197            device_id,
198            timestamp_ms,
199            sensor,
200            sentinel_confidence,
201            ai_route,
202            reason,
203        )
204    }
205
206    pub fn evaluate_with_route_reason(
207        &mut self,
208        device_id: &str,
209        timestamp_ms: u64,
210        sensor: EnvironmentReading,
211        sentinel_confidence: f32,
212        ai_route: &str,
213        route_reason: RouteReason,
214    ) -> ProofReceipt {
215        self.build_receipt(
216            device_id,
217            timestamp_ms,
218            sensor,
219            sentinel_confidence,
220            ai_route,
221            route_reason,
222        )
223    }
224
225    pub fn evaluate_with_policy(
226        &mut self,
227        device_id: &str,
228        ctx: PolicyContext<'_>,
229        ai_route: &str,
230    ) -> ProofReceipt {
231        let reading = ctx.reading;
232        let uptime_ms = ctx.uptime_ms;
233        let policy_decision = SensorPolicy::default().evaluate(ctx);
234        let route_reason = route_reason_from_policy(policy_decision.reason);
235        self.build_receipt_with_language(BuildReceiptInput {
236            device_id,
237            timestamp_ms: uptime_ms,
238            sensor: reading,
239            sentinel_confidence: policy_decision.confidence,
240            ai_route,
241            route_reason,
242            local_language_model: DEFAULT_LOCAL_LANGUAGE_MODEL,
243            local_language_prompt: policy_decision.prompt(),
244        })
245    }
246
247    fn build_receipt(
248        &mut self,
249        device_id: &str,
250        timestamp_ms: u64,
251        sensor: EnvironmentReading,
252        sentinel_confidence: f32,
253        ai_route: &str,
254        route_reason: RouteReason,
255    ) -> ProofReceipt {
256        let prompt_id = default_prompt_for_route_reason(route_reason);
257        self.build_receipt_with_language(BuildReceiptInput {
258            device_id,
259            timestamp_ms,
260            sensor,
261            sentinel_confidence,
262            ai_route,
263            route_reason,
264            local_language_model: DEFAULT_LOCAL_LANGUAGE_MODEL,
265            local_language_prompt: prompt_id.prompt_text(),
266        })
267    }
268
269    fn build_receipt_with_language(&mut self, input: BuildReceiptInput<'_>) -> ProofReceipt {
270        let event_id = self.next_event_id;
271        self.next_event_id = self.next_event_id.saturating_add(1);
272
273        let mut device = String::<48>::new();
274        let _ = device.push_str(input.device_id);
275        let mut route = String::<64>::new();
276        let _ = route.push_str(input.ai_route);
277        let mut reason_s = String::<48>::new();
278        let _ = reason_s.push_str(input.route_reason.as_str());
279        let mut language_model = String::<48>::new();
280        let _ = language_model.push_str(input.local_language_model);
281        let mut language_prompt = String::<96>::new();
282        let _ = language_prompt.push_str(input.local_language_prompt);
283
284        ProofReceipt {
285            schema_version: PROOF_RECEIPT_SCHEMA,
286            event_id,
287            device_id: device,
288            timestamp_ms: input.timestamp_ms,
289            sensor: input.sensor,
290            sentinel_confidence: input.sentinel_confidence,
291            decision: input.route_reason.decision(),
292            route_reason: input.route_reason,
293            reason: reason_s,
294            ai_route: route,
295            local_language_model: language_model,
296            local_language_prompt: language_prompt,
297        }
298    }
299
300    fn reason(&self, sensor: EnvironmentReading, sentinel_confidence: f32) -> RouteReason {
301        if sentinel_confidence < self.config.confidence_threshold {
302            return RouteReason::LowConfidence;
303        }
304        match sensor.temperature_c {
305            Some(v) if v < self.config.min_temperature_c || v > self.config.max_temperature_c => {
306                return RouteReason::TemperatureOutOfRange;
307            }
308            None => return RouteReason::SensorMissing,
309            _ => {}
310        }
311        match sensor.humidity_pct {
312            Some(v) if v < self.config.min_humidity_pct || v > self.config.max_humidity_pct => {
313                return RouteReason::HumidityOutOfRange;
314            }
315            None => return RouteReason::SensorMissing,
316            _ => {}
317        }
318        RouteReason::LocalConfident
319    }
320}
321
322fn route_reason_from_policy(reason: PolicyReason) -> RouteReason {
323    match reason {
324        PolicyReason::ProofPromptOverride => RouteReason::OperatorRequest,
325        PolicyReason::SensorMissing => RouteReason::SensorMissing,
326        PolicyReason::StaleReading => RouteReason::StaleReading,
327        PolicyReason::HotHumid => RouteReason::TemperatureAndHumidityOutOfRange,
328        PolicyReason::HotRoom => RouteReason::TemperatureOutOfRange,
329        PolicyReason::HumidRoom => RouteReason::HumidityOutOfRange,
330        PolicyReason::UnsupportedColdOrDry => RouteReason::UnsupportedColdOrDry,
331        PolicyReason::Normal => RouteReason::LocalConfident,
332    }
333}
334
335fn default_prompt_for_route_reason(route_reason: RouteReason) -> PromptId {
336    match route_reason {
337        RouteReason::SensorMissing => PromptId::MissingSensor,
338        RouteReason::StaleReading => PromptId::StaleData,
339        RouteReason::TemperatureAndHumidityOutOfRange => PromptId::HighHeatHumidity,
340        RouteReason::TemperatureOutOfRange => PromptId::HotRoom,
341        RouteReason::HumidityOutOfRange => PromptId::HumidRoom,
342        RouteReason::UnsupportedColdOrDry => PromptId::SafeAction,
343        RouteReason::LocalConfident => PromptId::NormalRoom,
344        RouteReason::LowConfidence
345        | RouteReason::OperatorRequest
346        | RouteReason::ScheduleTick
347        | RouteReason::AnomalyMatch => PromptId::NormalRoom,
348    }
349}
350
351fn push_str<const N: usize>(out: &mut String<N>, s: &str) -> Option<()> {
352    out.push_str(s).ok()
353}
354
355fn push_json_str<const N: usize>(
356    out: &mut String<N>,
357    key: &str,
358    value: &str,
359    last: bool,
360) -> Option<()> {
361    push_str(out, "\"")?;
362    push_str(out, key)?;
363    push_str(out, "\":\"")?;
364    push_str(out, value)?;
365    push_str(out, "\"")?;
366    if !last {
367        push_str(out, ",")?;
368    }
369    Some(())
370}
371
372fn push_json_u64<const N: usize>(
373    out: &mut String<N>,
374    key: &str,
375    value: u64,
376    last: bool,
377) -> Option<()> {
378    push_str(out, "\"")?;
379    push_str(out, key)?;
380    push_str(out, "\":")?;
381    push_u64(out, value)?;
382    if !last {
383        push_str(out, ",")?;
384    }
385    Some(())
386}
387
388fn push_json_f32<const N: usize>(
389    out: &mut String<N>,
390    key: &str,
391    value: f32,
392    last: bool,
393) -> Option<()> {
394    push_str(out, "\"")?;
395    push_str(out, key)?;
396    push_str(out, "\":")?;
397    push_f32(out, value)?;
398    if !last {
399        push_str(out, ",")?;
400    }
401    Some(())
402}
403
404fn push_json_opt_f32<const N: usize>(
405    out: &mut String<N>,
406    key: &str,
407    value: Option<f32>,
408    last: bool,
409) -> Option<()> {
410    push_str(out, "\"")?;
411    push_str(out, key)?;
412    push_str(out, "\":")?;
413    match value {
414        Some(v) => push_f32(out, v)?,
415        None => push_str(out, "null")?,
416    }
417    if !last {
418        push_str(out, ",")?;
419    }
420    Some(())
421}
422
423fn push_u64<const N: usize>(out: &mut String<N>, mut value: u64) -> Option<()> {
424    if value == 0 {
425        return out.push('0').ok();
426    }
427    let mut buf = [0u8; 20];
428    let mut len = 0;
429    while value > 0 {
430        buf[len] = b'0' + (value % 10) as u8;
431        value /= 10;
432        len += 1;
433    }
434    while len > 0 {
435        len -= 1;
436        out.push(buf[len] as char).ok()?;
437    }
438    Some(())
439}
440
441fn push_i32<const N: usize>(out: &mut String<N>, value: i32) -> Option<()> {
442    if value < 0 {
443        out.push('-').ok()?;
444        push_u64(out, value.unsigned_abs() as u64)
445    } else {
446        push_u64(out, value as u64)
447    }
448}
449
450fn push_f32<const N: usize>(out: &mut String<N>, value: f32) -> Option<()> {
451    if value.is_nan() {
452        return push_str(out, "null");
453    }
454    let scaled = (value * 10.0) as i32;
455    let whole = scaled / 10;
456    let frac = (scaled % 10).abs();
457    push_i32(out, whole)?;
458    if frac != 0 {
459        out.push('.').ok()?;
460        out.push((b'0' + frac as u8) as char).ok()?;
461    }
462    Some(())
463}
464
465#[cfg(test)]
466mod tests {
467    use super::*;
468
469    #[test]
470    fn event_ids_increment() {
471        let mut engine = ProofEngine::new(ProofConfig::default());
472        let sensor = EnvironmentReading::new(22.0, 45.0, None);
473        let a = engine.evaluate("node", 1, sensor, 0.9, "gpu");
474        let b = engine.evaluate("node", 2, sensor, 0.9, "gpu");
475        assert_eq!(a.event_id, 1);
476        assert_eq!(b.event_id, 2);
477    }
478
479    #[test]
480    fn operator_reason_forces_forward_route() {
481        let mut engine = ProofEngine::new(ProofConfig::default());
482        let sensor = EnvironmentReading::new(22.0, 45.0, None);
483        let r = engine.evaluate_with_route_reason(
484            "node",
485            1,
486            sensor,
487            0.99,
488            "gemma4:12b",
489            RouteReason::OperatorRequest,
490        );
491        assert_eq!(r.decision, ProofDecision::ForwardToAi);
492        assert_eq!(r.reason.as_str(), "operator_request");
493    }
494}