Skip to main content

plato_nervous/
lib.rs

1//! Plato Nervous System — Room-Specific Model Distillation
2//!
3//! Each room starts with big LLM calls. As tiles accumulate, tiny distilled models
4//! take over. Eventually each room trains its own LoRA — the irreducible intelligence
5//! that can't be pure algorithm.
6//!
7//! Signal chain: Sensor → Deadband → Nano → LoRA → Fleet → Cloud
8//! Each layer resolves what it can and passes the rest up.
9
10pub mod ollama;
11
12use serde::{Deserialize, Serialize};
13use uuid::Uuid;
14
15// ── Core Types ────────────────────────────────────────────────────────
16
17#[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    /// Layer 0: Pure algorithmic (deadband, thresholds)
53    Algorithmic,
54    /// Layer 1: Nano-model (350M, anomaly detection)
55    NanoModel,
56    /// Layer 2: Room LoRA (350M + LoRA, room-specific reasoning)
57    RoomLora,
58    /// Layer 3: Fleet coordinator (1.2B, cross-room)
59    FleetCoord,
60    /// Layer 4: Cloud LLM (API call, novel situations)
61    CloudEscalation,
62}
63
64#[derive(Debug, Clone, Serialize, Deserialize)]
65pub struct TileExample {
66    pub input: String,
67    pub output: String,
68    pub quality: f64,       // 0-1, was this tile useful?
69    pub layer: ResolutionLayer,
70    pub timestamp_ms: u64,
71}
72
73// ── Layer 0: Algorithmic Filters ─────────────────────────────────────
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
76pub struct DeadbandFilter {
77    pub deadband: f64,      // spectral gap width
78    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    /// Returns Some(tile) if the reading is within deadband (predictable = algorithmic)
87    /// Returns None if the reading drifts outside deadband (needs model)
88    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                    // Fully predictable — algorithmic resolution
98                    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                    // Drifted outside deadband or out of range — needs model
112                    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, // Needs history tracking
184        }
185    }
186}
187
188// ── Layer 1-2: Model Configuration ───────────────────────────────────
189
190#[derive(Debug, Clone, Serialize, Deserialize)]
191pub struct ModelConfig {
192    pub model_type: ModelType,
193    pub model_path: Option<String>,    // Path to GGUF file
194    pub endpoint: Option<String>,      // API endpoint
195    pub max_tokens: usize,
196    pub temperature: f64,
197    pub confidence_threshold: f64,     // Below this, pass to next layer
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
201pub enum ModelType {
202    /// Liquid LFM2.5-350M — edge anomaly detection
203    LiquidNano350M,
204    /// Liquid LFM2.5-1.2B Instruct — room reasoning
205    Liquid1_2BInstruct,
206    /// Room-specific LoRA on 350M base
207    RoomLora { base_model: String, lora_path: String, rank: usize },
208    /// Fleet coordinator
209    FleetCoordinator { model_path: String },
210    /// Cloud API fallback
211    CloudApi { provider: String, model: String },
212}
213
214// ── Layer 1: Nano Model (simulated for now) ──────────────────────────
215
216#[derive(Debug, Clone, Serialize, Deserialize)]
217pub struct NanoModel {
218    pub config: ModelConfig,
219    pub prompt_template: String,
220    /// Number of tiles this model has produced
221    pub tiles_produced: usize,
222    /// Running confidence average
223    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    /// Process a reading through the nano model
232    /// In production, this calls llama.cpp/ollama. Here we simulate.
233    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        // Simulation: check if value is near the boundary of normal
242        let range = reading.normal_max - reading.normal_min;
243        let margin = range * 0.15; // 15% margin
244        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            // Well within normal range — high confidence, no alert needed
249            (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            // Near boundary but still normal — moderate confidence
253            (TileType::Status, 0.75,
254             format!("{}: {:.1}{} — approaching boundary of normal range", 
255                reading.sensor_id, reading.value, reading.unit))
256        } else {
257            // Outside normal — low confidence, should escalate
258            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// ── Signal Chain (the full nervous system) ────────────────────────────
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct RoomNervousSystem {
286    pub room_id: String,
287    pub room_name: String,
288    
289    // Layer 0
290    pub deadband_filters: Vec<DeadbandFilter>,
291    pub rules: Vec<Rule>,
292    
293    // Layer 1
294    pub nano_model: Option<NanoModel>,
295    
296    // Layer 2 (placeholder — LoRA training not yet implemented)
297    pub room_lora_trained: bool,
298    pub room_lora_rank: usize,
299    
300    // Layer 3 (placeholder)
301    pub fleet_model_available: bool,
302    
303    // Training data
304    pub tile_buffer: Vec<TileExample>,
305    pub max_tile_buffer: usize,
306    
307    // Statistics
308    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    /// Add a deadband filter for a sensor
351    pub fn with_deadband(mut self, deadband: f64) -> Self {
352        self.deadband_filters.push(DeadbandFilter::new(deadband));
353        self
354    }
355
356    /// Add an algorithmic rule
357    pub fn with_rule(mut self, rule: Rule) -> Self {
358        self.rules.push(rule);
359        self
360    }
361
362    /// Enable nano model (Layer 1)
363    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    /// Process a sensor reading through the full signal chain
369    pub fn process(&mut self, reading: SensorReading) -> SignalResolution {
370        self.stats.total_readings += 1;
371
372        // Layer 0: Deadband filters
373        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        // Layer 0: Rules
383        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        // Layer 1: Nano model
393        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        // Layer 4: Cloud escalation (no Layer 2/3 implemented yet)
403        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    /// How autonomous is this room? (0.0 = fully cloud, 1.0 = fully local)
436    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    /// Get the resolution distribution
446    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    /// Is the room ready for LoRA training?
459    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    /// Estimate cloud call reduction after distillation
465    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        // LoRA typically absorbs 80% of what nano can't handle
469        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// ── Distillation Pipeline ─────────────────────────────────────────────
494// Each layer is both a RESOLVER and a DISTILLER. When a lower layer resolves
495// a reading, it produces a training example for the layer above. When a
496// higher layer resolves what a lower layer couldn't, its response becomes
497// training data for the lower layer's LoRA.
498
499#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct DistillationRecord {
501    pub input_hash: u64,
502    pub layer_resolved: ResolutionLayer,
503    pub confidence: f64,
504    /// Time taken to resolve (ms)
505    pub latency_ms: u64,
506    /// Was this resolved correctly? (determined by downstream verification)
507    pub verified_correct: Option<bool>,
508    pub timestamp_ms: u64,
509}
510
511#[derive(Debug, Clone, Serialize, Deserialize)]
512pub struct DistillationStats {
513    /// How many tiles have been used for LoRA training so far
514    pub total_tiles_used: usize,
515    /// Accuracy of the nano model BEFORE last distillation
516    pub pre_distillation_accuracy: f64,
517    /// Accuracy AFTER last distillation
518    pub post_distillation_accuracy: f64,
519    /// Number of distillation cycles completed
520    pub distillation_cycles: usize,
521    /// Conservation ratio at each layer transition
522    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    /// Cloud call reduction after each cycle
527    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, // Algorithmic is near-perfect
538            cr_l1_to_l2: 0.0,  // No LoRA yet
539            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    /// Minimum tiles before first distillation
549    pub min_tiles_for_lora: usize,
550    /// Minimum HIGH-QUALITY tiles (quality > 0.7) needed
551    pub min_high_quality_tiles: usize,
552    /// LoRA rank for room-specific adapter
553    pub lora_rank: usize,
554    /// How often to re-distill (in readings processed)
555    pub redistillation_interval: usize,
556    /// CR threshold below which re-distillation triggers
557    pub cr_redistillation_threshold: f64,
558    /// Maximum LoRA training epochs
559    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// ── JEPA-like Room Perception (The Irreducible Core) ──────────────────
576// After weeks of operation, each room develops a self-model — a compressed
577// representation of its own state that captures holistic patterns no single
578// sensor reading can express. This is the JEPA nano-model.
579
580#[derive(Debug, Clone, Serialize, Deserialize)]
581pub struct RoomStateVector {
582    pub room_id: String,
583    /// Compressed state of the room (16 dimensions)
584    /// Each dimension captures a holistic aspect:
585    ///   [0] - overall health (0 = critical, 1 = perfect)
586    ///   [1] - thermal trend (negative = cooling, positive = heating)
587    ///   [2] - vibration signature (higher = more vibration)
588    ///   [3] - stress level (cognitive load on the room)
589    ///   [4] - drift rate (how fast things are changing)
590    ///   [5-7] - cross-sensor correlations (RPM↔coolant, RPM↔oil, coolant↔oil)
591    ///   [8-11] - temporal patterns (hourly, daily, weekly, seasonal)
592    ///   [12-15] - reserved for room-specific dimensions
593    pub state: [f32; 16],
594    /// Confidence in the state vector (how well the JEPA model knows this state)
595    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    /// Is the room in a known-good state? (health > 0.7, low stress)
607    pub fn is_healthy(&self) -> bool {
608        self.state[0] > 0.7 && self.state[3] < 0.3
609    }
610    
611    /// Is the room in an anomalous state? (low health OR high stress)
612    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    /// Input embedding dimension (from the 350M model's output)
620    pub input_dim: usize,
621    /// Room state vector dimension
622    pub state_dim: usize,
623    /// Model size in parameters (1M-10M for edge deployment)
624    pub param_count: usize,
625    /// Prediction horizon (how far ahead the JEPA model predicts)
626    pub prediction_horizon_ms: u64,
627}
628
629impl Default for JepaNanoConfig {
630    fn default() -> Self {
631        Self {
632            input_dim: 384,   // Standard small embedding dim
633            state_dim: 16,    // Room state vector
634            param_count: 2_000_000, // 2M params — fits in ~4MB
635            prediction_horizon_ms: 60_000, // Predict 1 minute ahead
636        }
637    }
638}
639
640/// The JEPA (Joint Embedding Predictive Architecture) nano-model
641/// This is the irreducible core — the room's self-model.
642/// It doesn't process language. It processes embeddings.
643/// Its job: predict the NEXT room state from the current state.
644/// When predictions diverge from reality, that's anomaly detection
645/// that no threshold or rule could ever catch.
646#[derive(Debug, Clone, Serialize, Deserialize)]
647pub struct JepaNano {
648    pub config: JepaNanoConfig,
649    /// The learned state transition matrix (state_dim × state_dim)
650    /// In a real implementation, this would be a small neural net.
651    /// Here it's a simple linear model for demonstration.
652    pub transition_weights: Vec<Vec<f32>>,
653    /// Running average prediction error (used for anomaly detection)
654    pub avg_prediction_error: f64,
655    /// Number of states processed
656    pub states_processed: u64,
657    /// Last predicted state
658    pub last_prediction: Option<RoomStateVector>,
659}
660
661impl JepaNano {
662    pub fn new(config: JepaNanoConfig) -> Self {
663        let dim = config.state_dim;
664        // Initialize with identity-like weights (predict current = next)
665        let mut weights = vec![vec![0.0f32; dim]; dim];
666        for i in 0..dim {
667            weights[i][i] = 0.9; // Slight decay toward zero
668        }
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    /// Predict the next room state from the current state
679    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, // Confidence decays
695            timestamp_ms: current.timestamp_ms + self.config.prediction_horizon_ms,
696        }
697    }
698    
699    /// Update the model with an actual observation
700    /// Returns the prediction error (how surprised the model was)
701    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() // RMSE
708        } else {
709            0.0
710        };
711        
712        // Update running average
713        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        // Online learning: nudge weights toward the actual transition
718        // (In production, this would be a proper gradient step)
719        if let Some(ref predicted) = self.last_prediction {
720            let lr = 0.01; // Learning rate
721            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        // Set up next prediction
730        self.last_prediction = Some(self.predict(actual));
731        
732        error
733    }
734    
735    /// Is the current prediction error anomalously high?
736    /// This is the JEPA anomaly detection — when the model is surprised.
737    pub fn is_surprised(&self, error: f64) -> bool {
738        if self.states_processed < 10 { return false; }
739        error > self.avg_prediction_error * 3.0 // 3σ threshold
740    }
741    
742    /// How "well-trained" is this model? (0 = newborn, 1 = fully trained)
743    pub fn maturity(&self) -> f64 {
744        (self.states_processed as f64 / 10000.0).min(1.0)
745    }
746}
747
748// ── Tests ─────────────────────────────────────────────────────────────
749
750#[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()); // 3.0 drift < 5.0 deadband
780    }
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()); // 10.0 drift > 5.0 deadband → needs model
790    }
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()); // Out of normal range → needs model
798    }
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        // Value near boundary (within 15% margin)
872        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); // Should be less confident near boundary
877    }
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()); // Out of range → can't handle → pass up
892    }
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        // Feed 100 normal readings
907        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, // Gentle oscillation
912                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        // Most should be resolved algorithmically
920        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        // Feed normal, then anomaly
930        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        // Now send an anomaly
943        let anomaly = SensorReading {
944            sensor_id: "coolant".to_string(),
945            room_id: "engine-room".to_string(),
946            value: 228.0, // Way above normal max
947            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        // Should be escalated (no rules, no nano model configured)
954        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); // Capped at max
974    }
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        // Not ready with few tiles
983        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        // Ready after 100+ tiles with good quality
990        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        // 10 normal readings
1003        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); // All algorithmic
1007
1008        // 1 anomaly (out of range)
1009        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); // 10/11 resolved locally
1012    }
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)); } // escalated
1021        
1022        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    // ── Distillation Pipeline Tests ────────────────────────────────
1029
1030    #[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); // Algorithmic layer is near-perfect
1043    }
1044
1045    // ── JEPA Nano-Model Tests ──────────────────────────────────────
1046
1047    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(&current);
1071        
1072        // Diagonal weights are 0.9, so prediction should be close to current
1073        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        // Feed stable states — model should learn the pattern
1082        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); // Should be learning
1089        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        // Train on stable states
1097        for _ in 0..100 {
1098            let state = make_state("room", 0.8, 0.1, 0.2);
1099            jepa.update(&state);
1100        }
1101        
1102        // Normal reading — should not be surprised
1103        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        // Anomalous reading — should be surprised
1108        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); // Newborn
1117        
1118        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); // Fully mature
1130    }
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; // vibration
1136        sv.state[4] = 0.1; // drift rate
1137        
1138        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); // Low health, high stress
1150        assert!(!sv.is_healthy());
1151        assert!(sv.is_anomalous());
1152    }
1153
1154    #[test]
1155    fn test_full_signal_chain_with_jepa() {
1156        // Simulate the full lifecycle: deadband → nano → cloud → JEPA
1157        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        // Phase 1: Normal operation — deadband catches most, JEPA learns the pattern
1163        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            // Feed to JEPA as well
1175            let mut state = [0.0f32; 16];
1176            state[0] = 0.85; // healthy
1177            state[1] = (reading.value as f32 - 1450.0) / 50.0; // normalized thermal
1178            jepa.update(&RoomStateVector {
1179                room_id: "engine-room".to_string(),
1180                state, confidence: 0.9, timestamp_ms: reading.timestamp_ms,
1181            });
1182        }
1183        
1184        // Most resolved by deadband, JEPA has learned the pattern
1185        assert!(ns.autonomy_level() > 0.9);
1186        assert!(jepa.maturity() > 0.01);
1187        assert!(jepa.avg_prediction_error < 1.0);
1188        
1189        // Phase 2: Anomaly — JEPA should be surprised
1190        let mut anomaly_state = [0.0f32; 16];
1191        anomaly_state[0] = 0.2; // Low health
1192        anomaly_state[1] = 0.8; // High thermal trend
1193        anomaly_state[3] = 0.9; // High stress
1194        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        // JEPA should be surprised by the anomaly
1200        assert!(jepa.is_surprised(anomaly_error));
1201    }
1202}