1pub mod ollama;
11
12use serde::{Deserialize, Serialize};
13use uuid::Uuid;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct SensorReading {
19 pub sensor_id: String,
20 pub room_id: String,
21 pub value: f64,
22 pub unit: String,
23 pub timestamp_ms: u64,
24 pub normal_min: f64,
25 pub normal_max: f64,
26}
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct Tile {
30 pub id: Uuid,
31 pub room_id: String,
32 pub tile_type: TileType,
33 pub content: String,
34 pub confidence: f64,
35 pub resolved_by: ResolutionLayer,
36 pub timestamp_ms: u64,
37 pub sensor_reading: Option<SensorReading>,
38}
39
40#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
41pub enum TileType {
42 Status,
43 Alert,
44 Prediction,
45 Anomaly,
46 Coordination,
47 Escalation,
48}
49
50#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
51pub enum ResolutionLayer {
52 Algorithmic,
54 NanoModel,
56 RoomLora,
58 FleetCoord,
60 CloudEscalation,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct TileExample {
66 pub input: String,
67 pub output: String,
68 pub quality: f64, pub layer: ResolutionLayer,
70 pub timestamp_ms: u64,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct DeadbandFilter {
77 pub deadband: f64, pub last_value: Option<f64>,
79}
80
81impl DeadbandFilter {
82 pub fn new(deadband: f64) -> Self {
83 Self { deadband, last_value: None }
84 }
85
86 pub fn check(&mut self, reading: &SensorReading) -> Option<Tile> {
89 let in_range = reading.value >= reading.normal_min && reading.value <= reading.normal_max;
90
91 match self.last_value {
92 Some(prev) => {
93 let drift = (reading.value - prev).abs();
94 let in_deadband = drift <= self.deadband;
95
96 if in_range && in_deadband {
97 self.last_value = Some(reading.value);
99 Some(Tile {
100 id: Uuid::new_v4(),
101 room_id: reading.room_id.clone(),
102 tile_type: TileType::Status,
103 content: format!("{}: {:.1}{} (normal, drift {:.2})",
104 reading.sensor_id, reading.value, reading.unit, drift),
105 confidence: 1.0,
106 resolved_by: ResolutionLayer::Algorithmic,
107 timestamp_ms: reading.timestamp_ms,
108 sensor_reading: Some(reading.clone()),
109 })
110 } else {
111 self.last_value = Some(reading.value);
113 None
114 }
115 }
116 None => {
117 self.last_value = Some(reading.value);
118 if in_range {
119 Some(Tile {
120 id: Uuid::new_v4(),
121 room_id: reading.room_id.clone(),
122 tile_type: TileType::Status,
123 content: format!("{}: {:.1}{} (initial reading, normal)",
124 reading.sensor_id, reading.value, reading.unit),
125 confidence: 1.0,
126 resolved_by: ResolutionLayer::Algorithmic,
127 timestamp_ms: reading.timestamp_ms,
128 sensor_reading: Some(reading.clone()),
129 })
130 } else {
131 None
132 }
133 }
134 }
135 }
136}
137
138#[derive(Debug, Clone, Serialize, Deserialize)]
139pub struct Rule {
140 pub name: String,
141 pub condition: RuleCondition,
142 pub tile_content: String,
143}
144
145#[derive(Debug, Clone, Serialize, Deserialize)]
146pub enum RuleCondition {
147 AboveThreshold { sensor_id: String, threshold: f64 },
148 BelowThreshold { sensor_id: String, threshold: f64 },
149 RateOfChange { sensor_id: String, max_delta_per_sec: f64 },
150}
151
152impl Rule {
153 pub fn evaluate(&self, reading: &SensorReading) -> Option<Tile> {
154 match &self.condition {
155 RuleCondition::AboveThreshold { sensor_id, threshold } => {
156 if reading.sensor_id == *sensor_id && reading.value > *threshold {
157 Some(Tile {
158 id: Uuid::new_v4(),
159 room_id: reading.room_id.clone(),
160 tile_type: TileType::Alert,
161 content: self.tile_content.clone(),
162 confidence: 1.0,
163 resolved_by: ResolutionLayer::Algorithmic,
164 timestamp_ms: reading.timestamp_ms,
165 sensor_reading: Some(reading.clone()),
166 })
167 } else { None }
168 }
169 RuleCondition::BelowThreshold { sensor_id, threshold } => {
170 if reading.sensor_id == *sensor_id && reading.value < *threshold {
171 Some(Tile {
172 id: Uuid::new_v4(),
173 room_id: reading.room_id.clone(),
174 tile_type: TileType::Alert,
175 content: self.tile_content.clone(),
176 confidence: 1.0,
177 resolved_by: ResolutionLayer::Algorithmic,
178 timestamp_ms: reading.timestamp_ms,
179 sensor_reading: Some(reading.clone()),
180 })
181 } else { None }
182 }
183 RuleCondition::RateOfChange { .. } => None, }
185 }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct ModelConfig {
192 pub model_type: ModelType,
193 pub model_path: Option<String>, pub endpoint: Option<String>, pub max_tokens: usize,
196 pub temperature: f64,
197 pub confidence_threshold: f64, }
199
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
201pub enum ModelType {
202 LiquidNano350M,
204 Liquid1_2BInstruct,
206 RoomLora { base_model: String, lora_path: String, rank: usize },
208 FleetCoordinator { model_path: String },
210 CloudApi { provider: String, model: String },
212}
213
214#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct NanoModel {
218 pub config: ModelConfig,
219 pub prompt_template: String,
220 pub tiles_produced: usize,
222 pub avg_confidence: f64,
224}
225
226impl NanoModel {
227 pub fn new(config: ModelConfig, prompt_template: String) -> Self {
228 Self { config, prompt_template, tiles_produced: 0, avg_confidence: 0.5 }
229 }
230
231 pub fn infer(&mut self, reading: &SensorReading) -> Option<(Tile, f64)> {
234 let prompt = self.prompt_template
235 .replace("{sensor_id}", &reading.sensor_id)
236 .replace("{value}", &format!("{:.1}", reading.value))
237 .replace("{unit}", &reading.unit)
238 .replace("{normal_min}", &format!("{:.1}", reading.normal_min))
239 .replace("{normal_max}", &format!("{:.1}", reading.normal_max));
240
241 let range = reading.normal_max - reading.normal_min;
243 let margin = range * 0.15; let near_boundary = reading.value < reading.normal_min + margin
245 || reading.value > reading.normal_max - margin;
246
247 let (tile_type, confidence, content) = if !near_boundary {
248 (TileType::Status, 0.95,
250 format!("{}: {:.1}{} — within normal range", reading.sensor_id, reading.value, reading.unit))
251 } else if reading.value >= reading.normal_min && reading.value <= reading.normal_max {
252 (TileType::Status, 0.75,
254 format!("{}: {:.1}{} — approaching boundary of normal range",
255 reading.sensor_id, reading.value, reading.unit))
256 } else {
257 return None;
259 };
260
261 if confidence >= self.config.confidence_threshold {
262 self.tiles_produced += 1;
263 self.avg_confidence = (self.avg_confidence * (self.tiles_produced - 1) as f64
264 + confidence) / self.tiles_produced as f64;
265
266 Some((Tile {
267 id: Uuid::new_v4(),
268 room_id: reading.room_id.clone(),
269 tile_type,
270 content,
271 confidence,
272 resolved_by: ResolutionLayer::NanoModel,
273 timestamp_ms: reading.timestamp_ms,
274 sensor_reading: Some(reading.clone()),
275 }, confidence))
276 } else {
277 None
278 }
279 }
280}
281
282#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct RoomNervousSystem {
286 pub room_id: String,
287 pub room_name: String,
288
289 pub deadband_filters: Vec<DeadbandFilter>,
291 pub rules: Vec<Rule>,
292
293 pub nano_model: Option<NanoModel>,
295
296 pub room_lora_trained: bool,
298 pub room_lora_rank: usize,
299
300 pub fleet_model_available: bool,
302
303 pub tile_buffer: Vec<TileExample>,
305 pub max_tile_buffer: usize,
306
307 pub stats: NervousSystemStats,
309}
310
311#[derive(Debug, Clone, Serialize, Deserialize)]
312pub struct NervousSystemStats {
313 pub total_readings: u64,
314 pub resolved_algorithmic: u64,
315 pub resolved_nano: u64,
316 pub resolved_lora: u64,
317 pub resolved_fleet: u64,
318 pub escalated_cloud: u64,
319 pub tiles_produced: u64,
320}
321
322impl Default for NervousSystemStats {
323 fn default() -> Self {
324 Self {
325 total_readings: 0, resolved_algorithmic: 0,
326 resolved_nano: 0, resolved_lora: 0,
327 resolved_fleet: 0, escalated_cloud: 0,
328 tiles_produced: 0,
329 }
330 }
331}
332
333impl RoomNervousSystem {
334 pub fn new(room_id: &str, room_name: &str) -> Self {
335 Self {
336 room_id: room_id.to_string(),
337 room_name: room_name.to_string(),
338 deadband_filters: Vec::new(),
339 rules: Vec::new(),
340 nano_model: None,
341 room_lora_trained: false,
342 room_lora_rank: 0,
343 fleet_model_available: false,
344 tile_buffer: Vec::new(),
345 max_tile_buffer: 1000,
346 stats: NervousSystemStats::default(),
347 }
348 }
349
350 pub fn with_deadband(mut self, deadband: f64) -> Self {
352 self.deadband_filters.push(DeadbandFilter::new(deadband));
353 self
354 }
355
356 pub fn with_rule(mut self, rule: Rule) -> Self {
358 self.rules.push(rule);
359 self
360 }
361
362 pub fn with_nano_model(mut self, config: ModelConfig, prompt_template: String) -> Self {
364 self.nano_model = Some(NanoModel::new(config, prompt_template));
365 self
366 }
367
368 pub fn process(&mut self, reading: SensorReading) -> SignalResolution {
370 self.stats.total_readings += 1;
371
372 for filter in &mut self.deadband_filters {
374 if let Some(tile) = filter.check(&reading) {
375 self.stats.resolved_algorithmic += 1;
376 self.stats.tiles_produced += 1;
377 self.record_tile(&tile, 1.0);
378 return SignalResolution::Algorithmic(tile);
379 }
380 }
381
382 for rule in &self.rules {
384 if let Some(tile) = rule.evaluate(&reading) {
385 self.stats.resolved_algorithmic += 1;
386 self.stats.tiles_produced += 1;
387 self.record_tile(&tile, 1.0);
388 return SignalResolution::Algorithmic(tile);
389 }
390 }
391
392 if let Some(ref mut nano) = self.nano_model {
394 if let Some((tile, confidence)) = nano.infer(&reading) {
395 self.stats.resolved_nano += 1;
396 self.stats.tiles_produced += 1;
397 self.record_tile(&tile, confidence);
398 return SignalResolution::NanoModel(tile, confidence);
399 }
400 }
401
402 self.stats.escalated_cloud += 1;
404 let tile = Tile {
405 id: Uuid::new_v4(),
406 room_id: reading.room_id.clone(),
407 tile_type: TileType::Escalation,
408 content: format!("ESCALATED: {}={:.1}{} — all local layers insufficient",
409 reading.sensor_id, reading.value, reading.unit),
410 confidence: 0.0,
411 resolved_by: ResolutionLayer::CloudEscalation,
412 timestamp_ms: reading.timestamp_ms,
413 sensor_reading: Some(reading.clone()),
414 };
415 self.record_tile(&tile, 0.0);
416 SignalResolution::Escalated(tile, "All local layers insufficient".into())
417 }
418
419 fn record_tile(&mut self, tile: &Tile, quality: f64) {
420 let example = TileExample {
421 input: tile.sensor_reading.as_ref()
422 .map(|r| format!("{}={:.1}{}", r.sensor_id, r.value, r.unit))
423 .unwrap_or_default(),
424 output: tile.content.clone(),
425 quality,
426 layer: tile.resolved_by,
427 timestamp_ms: tile.timestamp_ms,
428 };
429 self.tile_buffer.push(example);
430 if self.tile_buffer.len() > self.max_tile_buffer {
431 self.tile_buffer.remove(0);
432 }
433 }
434
435 pub fn autonomy_level(&self) -> f64 {
437 if self.stats.total_readings == 0 { return 0.0; }
438 let local = self.stats.resolved_algorithmic
439 + self.stats.resolved_nano
440 + self.stats.resolved_lora
441 + self.stats.resolved_fleet;
442 local as f64 / self.stats.total_readings as f64
443 }
444
445 pub fn resolution_distribution(&self) -> ResolutionDistribution {
447 let total = self.stats.total_readings.max(1) as f64;
448 ResolutionDistribution {
449 algorithmic_pct: self.stats.resolved_algorithmic as f64 / total * 100.0,
450 nano_pct: self.stats.resolved_nano as f64 / total * 100.0,
451 lora_pct: self.stats.resolved_lora as f64 / total * 100.0,
452 fleet_pct: self.stats.resolved_fleet as f64 / total * 100.0,
453 cloud_pct: self.stats.escalated_cloud as f64 / total * 100.0,
454 autonomy: self.autonomy_level(),
455 }
456 }
457
458 pub fn ready_for_lora(&self) -> bool {
460 self.tile_buffer.len() >= 100
461 && self.tile_buffer.iter().filter(|t| t.quality > 0.7).count() >= 50
462 }
463
464 pub fn estimate_reduction(&self) -> f64 {
466 let current_cloud_pct = self.stats.escalated_cloud as f64
467 / self.stats.total_readings.max(1) as f64;
468 let after_nano = current_cloud_pct * 0.2;
470 let after_lora = after_nano * 0.2;
471 after_lora
472 }
473}
474
475#[derive(Debug, Clone, Serialize, Deserialize)]
476pub struct ResolutionDistribution {
477 pub algorithmic_pct: f64,
478 pub nano_pct: f64,
479 pub lora_pct: f64,
480 pub fleet_pct: f64,
481 pub cloud_pct: f64,
482 pub autonomy: f64,
483}
484
485pub enum SignalResolution {
486 Algorithmic(Tile),
487 NanoModel(Tile, f64),
488 RoomLora(Tile, f64),
489 FleetCoord(Tile, f64),
490 Escalated(Tile, String),
491}
492
493#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct DistillationRecord {
501 pub input_hash: u64,
502 pub layer_resolved: ResolutionLayer,
503 pub confidence: f64,
504 pub latency_ms: u64,
506 pub verified_correct: Option<bool>,
508 pub timestamp_ms: u64,
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize)]
512pub struct DistillationStats {
513 pub total_tiles_used: usize,
515 pub pre_distillation_accuracy: f64,
517 pub post_distillation_accuracy: f64,
519 pub distillation_cycles: usize,
521 pub cr_l0_to_l1: f64,
523 pub cr_l1_to_l2: f64,
524 pub cr_l2_to_l3: f64,
525 pub cr_l3_to_l4: f64,
526 pub cloud_reduction_pct: f64,
528}
529
530impl Default for DistillationStats {
531 fn default() -> Self {
532 Self {
533 total_tiles_used: 0,
534 pre_distillation_accuracy: 0.0,
535 post_distillation_accuracy: 0.0,
536 distillation_cycles: 0,
537 cr_l0_to_l1: 0.99, cr_l1_to_l2: 0.0, cr_l2_to_l3: 0.0,
540 cr_l3_to_l4: 0.0,
541 cloud_reduction_pct: 0.0,
542 }
543 }
544}
545
546#[derive(Debug, Clone, Serialize, Deserialize)]
547pub struct DistillationConfig {
548 pub min_tiles_for_lora: usize,
550 pub min_high_quality_tiles: usize,
552 pub lora_rank: usize,
554 pub redistillation_interval: usize,
556 pub cr_redistillation_threshold: f64,
558 pub max_epochs: usize,
560}
561
562impl Default for DistillationConfig {
563 fn default() -> Self {
564 Self {
565 min_tiles_for_lora: 100,
566 min_high_quality_tiles: 50,
567 lora_rank: 8,
568 redistillation_interval: 1000,
569 cr_redistillation_threshold: 0.85,
570 max_epochs: 10,
571 }
572 }
573}
574
575#[derive(Debug, Clone, Serialize, Deserialize)]
581pub struct RoomStateVector {
582 pub room_id: String,
583 pub state: [f32; 16],
594 pub confidence: f64,
596 pub timestamp_ms: u64,
597}
598
599impl RoomStateVector {
600 pub fn health(&self) -> f32 { self.state[0] }
601 pub fn thermal_trend(&self) -> f32 { self.state[1] }
602 pub fn vibration(&self) -> f32 { self.state[2] }
603 pub fn stress(&self) -> f32 { self.state[3] }
604 pub fn drift_rate(&self) -> f32 { self.state[4] }
605
606 pub fn is_healthy(&self) -> bool {
608 self.state[0] > 0.7 && self.state[3] < 0.3
609 }
610
611 pub fn is_anomalous(&self) -> bool {
613 self.state[0] < 0.3 || self.state[3] > 0.7
614 }
615}
616
617#[derive(Debug, Clone, Serialize, Deserialize)]
618pub struct JepaNanoConfig {
619 pub input_dim: usize,
621 pub state_dim: usize,
623 pub param_count: usize,
625 pub prediction_horizon_ms: u64,
627}
628
629impl Default for JepaNanoConfig {
630 fn default() -> Self {
631 Self {
632 input_dim: 384, state_dim: 16, param_count: 2_000_000, prediction_horizon_ms: 60_000, }
637 }
638}
639
640#[derive(Debug, Clone, Serialize, Deserialize)]
647pub struct JepaNano {
648 pub config: JepaNanoConfig,
649 pub transition_weights: Vec<Vec<f32>>,
653 pub avg_prediction_error: f64,
655 pub states_processed: u64,
657 pub last_prediction: Option<RoomStateVector>,
659}
660
661impl JepaNano {
662 pub fn new(config: JepaNanoConfig) -> Self {
663 let dim = config.state_dim;
664 let mut weights = vec![vec![0.0f32; dim]; dim];
666 for i in 0..dim {
667 weights[i][i] = 0.9; }
669 Self {
670 config,
671 transition_weights: weights,
672 avg_prediction_error: 0.0,
673 states_processed: 0,
674 last_prediction: None,
675 }
676 }
677
678 pub fn predict(&self, current: &RoomStateVector) -> RoomStateVector {
680 let dim = self.config.state_dim;
681 let mut next_state = [0.0f32; 16];
682
683 for i in 0..dim.min(16) {
684 let mut val = 0.0f32;
685 for j in 0..dim.min(16) {
686 val += self.transition_weights[i][j] * current.state[j];
687 }
688 next_state[i] = val;
689 }
690
691 RoomStateVector {
692 room_id: current.room_id.clone(),
693 state: next_state,
694 confidence: current.confidence * 0.95, timestamp_ms: current.timestamp_ms + self.config.prediction_horizon_ms,
696 }
697 }
698
699 pub fn update(&mut self, actual: &RoomStateVector) -> f64 {
702 let error = if let Some(ref predicted) = self.last_prediction {
703 let mut total_error = 0.0f64;
704 for i in 0..16 {
705 total_error += (predicted.state[i] - actual.state[i]).powi(2) as f64;
706 }
707 (total_error / 16.0).sqrt() } else {
709 0.0
710 };
711
712 self.states_processed += 1;
714 let alpha = 1.0 / self.states_processed.min(100) as f64;
715 self.avg_prediction_error = self.avg_prediction_error * (1.0 - alpha) + error * alpha;
716
717 if let Some(ref predicted) = self.last_prediction {
720 let lr = 0.01; for i in 0..self.config.state_dim.min(16) {
722 let delta = actual.state[i] - predicted.state[i];
723 for j in 0..self.config.state_dim.min(16) {
724 self.transition_weights[i][j] += lr * delta * actual.state[j];
725 }
726 }
727 }
728
729 self.last_prediction = Some(self.predict(actual));
731
732 error
733 }
734
735 pub fn is_surprised(&self, error: f64) -> bool {
738 if self.states_processed < 10 { return false; }
739 error > self.avg_prediction_error * 3.0 }
741
742 pub fn maturity(&self) -> f64 {
744 (self.states_processed as f64 / 10000.0).min(1.0)
745 }
746}
747
748#[cfg(test)]
751mod tests {
752 use super::*;
753
754 fn make_reading(sensor_id: &str, value: f64, min: f64, max: f64) -> SensorReading {
755 SensorReading {
756 sensor_id: sensor_id.to_string(),
757 room_id: "engine-room".to_string(),
758 value, unit: "units".to_string(),
759 timestamp_ms: 1000, normal_min: min, normal_max: max,
760 }
761 }
762
763 #[test]
764 fn test_deadband_normal_reading() {
765 let mut filter = DeadbandFilter::new(5.0);
766 let reading = make_reading("rpm", 1450.0, 1400.0, 1500.0);
767 let result = filter.check(&reading);
768 assert!(result.is_some());
769 assert_eq!(result.unwrap().resolved_by, ResolutionLayer::Algorithmic);
770 }
771
772 #[test]
773 fn test_deadband_small_drift() {
774 let mut filter = DeadbandFilter::new(5.0);
775 let r1 = make_reading("rpm", 1450.0, 1400.0, 1500.0);
776 filter.check(&r1);
777 let r2 = make_reading("rpm", 1453.0, 1400.0, 1500.0);
778 let result = filter.check(&r2);
779 assert!(result.is_some()); }
781
782 #[test]
783 fn test_deadband_large_drift() {
784 let mut filter = DeadbandFilter::new(5.0);
785 let r1 = make_reading("rpm", 1450.0, 1400.0, 1500.0);
786 filter.check(&r1);
787 let r2 = make_reading("rpm", 1460.0, 1400.0, 1500.0);
788 let result = filter.check(&r2);
789 assert!(result.is_none()); }
791
792 #[test]
793 fn test_deadband_out_of_range() {
794 let mut filter = DeadbandFilter::new(5.0);
795 let reading = make_reading("coolant", 228.0, 140.0, 210.0);
796 let result = filter.check(&reading);
797 assert!(result.is_none()); }
799
800 #[test]
801 fn test_rule_above_threshold() {
802 let rule = Rule {
803 name: "high_coolant".to_string(),
804 condition: RuleCondition::AboveThreshold {
805 sensor_id: "coolant".to_string(), threshold: 210.0,
806 },
807 tile_content: "Coolant above 210F".to_string(),
808 };
809 let reading = make_reading("coolant", 215.0, 140.0, 210.0);
810 let result = rule.evaluate(&reading);
811 assert!(result.is_some());
812 assert_eq!(result.unwrap().tile_type, TileType::Alert);
813 }
814
815 #[test]
816 fn test_rule_below_threshold() {
817 let rule = Rule {
818 name: "low_oil".to_string(),
819 condition: RuleCondition::BelowThreshold {
820 sensor_id: "oil".to_string(), threshold: 35.0,
821 },
822 tile_content: "Oil below 35 PSI".to_string(),
823 };
824 let reading = make_reading("oil", 28.0, 35.0, 80.0);
825 let result = rule.evaluate(&reading);
826 assert!(result.is_some());
827 }
828
829 #[test]
830 fn test_rule_no_match() {
831 let rule = Rule {
832 name: "high_coolant".to_string(),
833 condition: RuleCondition::AboveThreshold {
834 sensor_id: "coolant".to_string(), threshold: 210.0,
835 },
836 tile_content: "Coolant above 210F".to_string(),
837 };
838 let reading = make_reading("coolant", 195.0, 140.0, 210.0);
839 let result = rule.evaluate(&reading);
840 assert!(result.is_none());
841 }
842
843 #[test]
844 fn test_nano_model_normal_reading() {
845 let config = ModelConfig {
846 model_type: ModelType::LiquidNano350M,
847 model_path: None, endpoint: None,
848 max_tokens: 32, temperature: 0.0,
849 confidence_threshold: 0.7,
850 };
851 let mut nano = NanoModel::new(config,
852 "{sensor_id}={value}{unit} normal:{normal_min}-{normal_max}".to_string());
853
854 let reading = make_reading("rpm", 1450.0, 1400.0, 1500.0);
855 let result = nano.infer(&reading);
856 assert!(result.is_some());
857 let (_, conf) = result.unwrap();
858 assert!(conf >= 0.7);
859 }
860
861 #[test]
862 fn test_nano_model_boundary_reading() {
863 let config = ModelConfig {
864 model_type: ModelType::LiquidNano350M,
865 model_path: None, endpoint: None,
866 max_tokens: 32, temperature: 0.0,
867 confidence_threshold: 0.7,
868 };
869 let mut nano = NanoModel::new(config, "".to_string());
870
871 let reading = make_reading("rpm", 1493.0, 1400.0, 1500.0);
873 let result = nano.infer(&reading);
874 assert!(result.is_some());
875 let (_, conf) = result.unwrap();
876 assert!(conf < 0.95); }
878
879 #[test]
880 fn test_nano_model_out_of_range() {
881 let config = ModelConfig {
882 model_type: ModelType::LiquidNano350M,
883 model_path: None, endpoint: None,
884 max_tokens: 32, temperature: 0.0,
885 confidence_threshold: 0.7,
886 };
887 let mut nano = NanoModel::new(config, "".to_string());
888
889 let reading = make_reading("rpm", 1650.0, 1400.0, 1500.0);
890 let result = nano.infer(&reading);
891 assert!(result.is_none()); }
893
894 #[test]
895 fn test_full_signal_chain_mostly_algorithmic() {
896 let mut ns = RoomNervousSystem::new("engine-room", "Engine Room");
897 ns.deadband_filters.push(DeadbandFilter::new(10.0));
898 ns.rules.push(Rule {
899 name: "high_coolant".to_string(),
900 condition: RuleCondition::AboveThreshold {
901 sensor_id: "coolant".to_string(), threshold: 210.0,
902 },
903 tile_content: "Coolant above 210F!".to_string(),
904 });
905
906 for i in 0..100 {
908 let reading = SensorReading {
909 sensor_id: "rpm".to_string(),
910 room_id: "engine-room".to_string(),
911 value: 1450.0 + (i as f64 * 0.1).sin() * 5.0, unit: "rpm".to_string(),
913 timestamp_ms: i * 1000,
914 normal_min: 1400.0, normal_max: 1500.0,
915 };
916 ns.process(reading.clone());
917 }
918
919 assert!(ns.autonomy_level() > 0.9);
921 assert_eq!(ns.stats.escalated_cloud, 0);
922 }
923
924 #[test]
925 fn test_full_signal_chain_with_anomaly() {
926 let mut ns = RoomNervousSystem::new("engine-room", "Engine Room");
927 ns.deadband_filters.push(DeadbandFilter::new(10.0));
928
929 for i in 0..10 {
931 let reading = SensorReading {
932 sensor_id: "coolant".to_string(),
933 room_id: "engine-room".to_string(),
934 value: 195.0,
935 unit: "F".to_string(),
936 timestamp_ms: i * 1000,
937 normal_min: 140.0, normal_max: 210.0,
938 };
939 ns.process(reading.clone());
940 }
941
942 let anomaly = SensorReading {
944 sensor_id: "coolant".to_string(),
945 room_id: "engine-room".to_string(),
946 value: 228.0, unit: "F".to_string(),
948 timestamp_ms: 10000,
949 normal_min: 140.0, normal_max: 210.0,
950 };
951 let result = ns.process(anomaly);
952
953 match result {
955 SignalResolution::Escalated(tile, _) => {
956 assert_eq!(tile.tile_type, TileType::Escalation);
957 }
958 _ => panic!("Expected escalation for out-of-range reading"),
959 }
960 }
961
962 #[test]
963 fn test_tile_buffer_fills() {
964 let mut ns = RoomNervousSystem::new("room", "Test Room");
965 ns.max_tile_buffer = 10;
966 ns.deadband_filters.push(DeadbandFilter::new(100.0));
967
968 for i in 0..15 {
969 let reading = make_reading("temp", 20.0 + i as f64, 0.0, 100.0);
970 ns.process(reading.clone());
971 }
972
973 assert_eq!(ns.tile_buffer.len(), 10); }
975
976 #[test]
977 fn test_ready_for_lora() {
978 let mut ns = RoomNervousSystem::new("room", "Test Room");
979 ns.max_tile_buffer = 200;
980 ns.deadband_filters.push(DeadbandFilter::new(100.0));
981
982 for i in 0..50 {
984 let reading = make_reading("temp", 20.0, 0.0, 100.0);
985 ns.process(reading.clone());
986 }
987 assert!(!ns.ready_for_lora());
988
989 for i in 0..100 {
991 let reading = make_reading("temp", 20.0 + i as f64 * 0.01, 0.0, 100.0);
992 ns.process(reading.clone());
993 }
994 assert!(ns.ready_for_lora());
995 }
996
997 #[test]
998 fn test_autonomy_level_calculation() {
999 let mut ns = RoomNervousSystem::new("room", "Test Room");
1000 ns.deadband_filters.push(DeadbandFilter::new(100.0));
1001
1002 for _ in 0..10 {
1004 ns.process(make_reading("x", 50.0, 0.0, 100.0));
1005 }
1006 assert_eq!(ns.autonomy_level(), 1.0); ns.process(make_reading("x", 150.0, 0.0, 100.0));
1010 assert!(ns.autonomy_level() < 1.0);
1011 assert!(ns.autonomy_level() > 0.9); }
1013
1014 #[test]
1015 fn test_resolution_distribution() {
1016 let mut ns = RoomNervousSystem::new("room", "Test Room");
1017 ns.deadband_filters.push(DeadbandFilter::new(100.0));
1018
1019 for _ in 0..8 { ns.process(make_reading("x", 50.0, 0.0, 100.0)); }
1020 for _ in 0..2 { ns.process(make_reading("x", 150.0, 0.0, 100.0)); } let dist = ns.resolution_distribution();
1023 assert!((dist.algorithmic_pct - 80.0).abs() < 1.0);
1024 assert!((dist.cloud_pct - 20.0).abs() < 1.0);
1025 assert!((dist.autonomy - 0.8).abs() < 0.01);
1026 }
1027
1028 #[test]
1031 fn test_distillation_config_defaults() {
1032 let config = DistillationConfig::default();
1033 assert_eq!(config.min_tiles_for_lora, 100);
1034 assert_eq!(config.lora_rank, 8);
1035 assert!(config.cr_redistillation_threshold > 0.0);
1036 }
1037
1038 #[test]
1039 fn test_distillation_stats_defaults() {
1040 let stats = DistillationStats::default();
1041 assert_eq!(stats.distillation_cycles, 0);
1042 assert!(stats.cr_l0_to_l1 > 0.9); }
1044
1045 fn make_state(room_id: &str, health: f32, thermal: f32, stress: f32) -> RoomStateVector {
1048 let mut state = [0.0f32; 16];
1049 state[0] = health;
1050 state[1] = thermal;
1051 state[3] = stress;
1052 RoomStateVector {
1053 room_id: room_id.to_string(),
1054 state, confidence: 0.9, timestamp_ms: 1000,
1055 }
1056 }
1057
1058 #[test]
1059 fn test_jepa_nano_creation() {
1060 let jepa = JepaNano::new(JepaNanoConfig::default());
1061 assert_eq!(jepa.states_processed, 0);
1062 assert_eq!(jepa.avg_prediction_error, 0.0);
1063 assert!(jepa.last_prediction.is_none());
1064 }
1065
1066 #[test]
1067 fn test_jepa_prediction() {
1068 let jepa = JepaNano::new(JepaNanoConfig::default());
1069 let current = make_state("engine-room", 0.8, 0.1, 0.2);
1070 let predicted = jepa.predict(¤t);
1071
1072 assert!((predicted.state[0] - 0.8 * 0.9).abs() < 0.01);
1074 assert!((predicted.state[1] - 0.1 * 0.9).abs() < 0.01);
1075 }
1076
1077 #[test]
1078 fn test_jepa_learning() {
1079 let mut jepa = JepaNano::new(JepaNanoConfig::default());
1080
1081 for i in 0..50 {
1083 let state = make_state("room", 0.8, 0.1 + i as f32 * 0.001, 0.2);
1084 jepa.update(&state);
1085 }
1086
1087 assert!(jepa.states_processed == 50);
1088 assert!(jepa.avg_prediction_error < 1.0); assert!(jepa.last_prediction.is_some());
1090 }
1091
1092 #[test]
1093 fn test_jepa_anomaly_detection() {
1094 let mut jepa = JepaNano::new(JepaNanoConfig::default());
1095
1096 for _ in 0..100 {
1098 let state = make_state("room", 0.8, 0.1, 0.2);
1099 jepa.update(&state);
1100 }
1101
1102 let normal = make_state("room", 0.8, 0.1, 0.2);
1104 let normal_error = jepa.update(&normal);
1105 assert!(!jepa.is_surprised(normal_error));
1106
1107 let anomaly = make_state("room", 0.1, 0.9, 0.95);
1109 let anomaly_error = jepa.update(&anomaly);
1110 assert!(jepa.is_surprised(anomaly_error));
1111 }
1112
1113 #[test]
1114 fn test_jepa_maturity() {
1115 let mut jepa = JepaNano::new(JepaNanoConfig::default());
1116 assert_eq!(jepa.maturity(), 0.0); for _ in 0..5000 {
1119 let state = make_state("room", 0.8, 0.1, 0.2);
1120 jepa.update(&state);
1121 }
1122 assert!(jepa.maturity() > 0.4);
1123 assert!(jepa.maturity() < 1.0);
1124
1125 for _ in 0..5000 {
1126 let state = make_state("room", 0.8, 0.1, 0.2);
1127 jepa.update(&state);
1128 }
1129 assert!((jepa.maturity() - 1.0).abs() < 0.01); }
1131
1132 #[test]
1133 fn test_room_state_vector_accessors() {
1134 let mut sv = make_state("room", 0.8, 0.3, 0.2);
1135 sv.state[2] = 0.4; sv.state[4] = 0.1; assert!((sv.health() - 0.8).abs() < 0.01);
1139 assert!((sv.thermal_trend() - 0.3).abs() < 0.01);
1140 assert!((sv.vibration() - 0.4).abs() < 0.01);
1141 assert!((sv.stress() - 0.2).abs() < 0.01);
1142 assert!((sv.drift_rate() - 0.1).abs() < 0.01);
1143 assert!(sv.is_healthy());
1144 assert!(!sv.is_anomalous());
1145 }
1146
1147 #[test]
1148 fn test_room_state_vector_anomalous() {
1149 let sv = make_state("room", 0.1, 0.3, 0.9); assert!(!sv.is_healthy());
1151 assert!(sv.is_anomalous());
1152 }
1153
1154 #[test]
1155 fn test_full_signal_chain_with_jepa() {
1156 let mut ns = RoomNervousSystem::new("engine-room", "Engine Room");
1158 ns.deadband_filters.push(DeadbandFilter::new(10.0));
1159
1160 let mut jepa = JepaNano::new(JepaNanoConfig::default());
1161
1162 for i in 0..200 {
1164 let reading = SensorReading {
1165 sensor_id: "rpm".to_string(),
1166 room_id: "engine-room".to_string(),
1167 value: 1450.0 + (i as f64 * 0.1).sin() * 5.0,
1168 unit: "rpm".to_string(),
1169 timestamp_ms: i * 1000,
1170 normal_min: 1400.0, normal_max: 1500.0,
1171 };
1172 ns.process(reading.clone());
1173
1174 let mut state = [0.0f32; 16];
1176 state[0] = 0.85; state[1] = (reading.value as f32 - 1450.0) / 50.0; jepa.update(&RoomStateVector {
1179 room_id: "engine-room".to_string(),
1180 state, confidence: 0.9, timestamp_ms: reading.timestamp_ms,
1181 });
1182 }
1183
1184 assert!(ns.autonomy_level() > 0.9);
1186 assert!(jepa.maturity() > 0.01);
1187 assert!(jepa.avg_prediction_error < 1.0);
1188
1189 let mut anomaly_state = [0.0f32; 16];
1191 anomaly_state[0] = 0.2; anomaly_state[1] = 0.8; anomaly_state[3] = 0.9; let anomaly_error = jepa.update(&RoomStateVector {
1195 room_id: "engine-room".to_string(),
1196 state: anomaly_state, confidence: 0.5, timestamp_ms: 200000,
1197 });
1198
1199 assert!(jepa.is_surprised(anomaly_error));
1201 }
1202}