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}