Spaces:
Runtime error
Runtime error
| //! Pipeline — the living loop that connects all four layers. | |
| //! | |
| //! Membrane observes → Graph learns → Predictor predicts → Condenser acts. | |
| //! | |
| //! This is the River. Data flows through it continuously. | |
| //! No orchestrator. No scheduler. The substrate drives itself. | |
| //! | |
| //! The pipeline runs as a background thread alongside the membrane's | |
| //! LD_PRELOAD hooks. Every allocation event flows through the graph, | |
| //! triggers predictions, and the condenser acts on them. | |
| //! | |
| //! ---- Changelog ---- | |
| //! [2026-05-25] CC — Large-alloc passthrough (#260) | |
| //! What: Allocations above large_alloc_passthrough_bytes (default 10MB) are | |
| //! tracked in the graph and predictor for pattern learning but never | |
| //! registered with the condenser for compression. | |
| //! Why: Observer data: ONNX embedding model = 86MB single allocation; NG | |
| //! burst events hit 7GB in 10s. Large allocs are (a) high-entropy — | |
| //! LZ4 won't help, (b) hot during inference — compressing them mid- | |
| //! flight causes use-after-read, (c) the source of the "massive" class | |
| //! patterns the graph already learns from. Graph still sees them. | |
| //! How: process_alloc() checks size vs config.large_alloc_passthrough_bytes | |
| //! before condenser.register() and field.access() calls. | |
| //! ------------------- | |
| use std::collections::HashMap; | |
| use std::time::Instant; | |
| use crate::graph::AccessGraph; | |
| use crate::predictor::RustPredictor; | |
| use crate::condenser::{Condenser, CondenserConfig}; | |
| use crate::lenia::LeniaField; | |
| /// Pipeline operating mode — governs whether the pipeline acts on predictions. | |
| /// | |
| /// The substrate always learns. Mode controls whether it compresses. | |
| /// Observing → Active after confidence threshold is met. | |
| /// Blacklisted → permanent: never acts, never transitions. | |
| pub enum PipelineMode { | |
| /// Learning phase — graph and predictor train, condenser is silent. | |
| Observing, | |
| /// Fully operational — condenser compresses and pre-promotes. | |
| Active, | |
| /// Permanently silenced — never transitions, never compresses. | |
| Blacklisted, | |
| } | |
| /// Pipeline configuration | |
| pub struct PipelineConfig { | |
| /// Graph causal window (ns) | |
| pub causal_window_ns: u64, | |
| /// Graph cluster threshold | |
| pub cluster_threshold: f64, | |
| /// Condenser idle threshold (ns) | |
| pub idle_threshold_ns: u64, | |
| /// Minimum allocation size to manage | |
| pub min_manage_size: usize, | |
| /// How many events to accumulate before rebuilding the graph | |
| pub graph_rebuild_interval: usize, | |
| /// Minimum prediction confidence to act on | |
| pub prediction_threshold: f64, | |
| /// Enable test mode — condenser generates synthetic data instead of reading | |
| /// from raw memory pointers. Required when using fake addresses in tests. | |
| pub test_mode: bool, | |
| /// Allocations above this size are tracked in the graph/predictor for | |
| /// pattern learning but never registered with the condenser. | |
| /// Observer data: ONNX model = 86MB; NG burst allocs hit 7GB. | |
| /// Large allocs are high-entropy (LZ4 ineffective) and inference-hot. | |
| /// Default: 10MB — conservative floor well below the 86MB ONNX baseline. | |
| pub large_alloc_passthrough_bytes: usize, | |
| } | |
| impl Default for PipelineConfig { | |
| fn default() -> Self { | |
| Self { | |
| causal_window_ns: 5_000_000, // 5ms | |
| cluster_threshold: 0.7, | |
| idle_threshold_ns: 1_000_000_000, // 1 second (was 5 — too conservative) | |
| min_manage_size: 4_096, // 4KB | |
| graph_rebuild_interval: 500, // rebuild graph every 500 events | |
| prediction_threshold: 0.3, // act on predictions with >30% confidence | |
| test_mode: false, | |
| large_alloc_passthrough_bytes: 10 * 1024 * 1024, // 10MB | |
| } | |
| } | |
| } | |
| /// A single event flowing through the pipeline | |
| pub struct PipelineEvent { | |
| pub timestamp_ns: u64, | |
| pub address: usize, | |
| pub size: usize, | |
| pub event_type: EventType, | |
| } | |
| pub enum EventType { | |
| Alloc, | |
| Free, | |
| } | |
| /// The living pipeline — connects membrane → graph → predictor → condenser → lenia | |
| pub struct Pipeline { | |
| config: PipelineConfig, | |
| /// The graph learns access topology | |
| graph: AccessGraph, | |
| /// The predictor fires spikes through learned topology | |
| predictor: RustPredictor, | |
| /// The condenser compresses cold, promotes hot | |
| condenser: Condenser, | |
| /// The Lenia field — continuous thermal dynamics | |
| /// Replaces hard idle thresholds with physics | |
| field: LeniaField, | |
| /// Accumulated events for graph rebuilding | |
| event_buffer: Vec<(u64, String, u64)>, | |
| /// Address → path mapping (for graph node identity) | |
| address_to_path: std::collections::HashMap<usize, String>, | |
| /// Address → Lenia region ID mapping | |
| address_to_field_id: std::collections::HashMap<usize, u32>, | |
| /// Next Lenia field ID | |
| next_field_id: u32, | |
| /// Path counter for generating unique paths | |
| path_counter: u64, | |
| /// Start time | |
| start: Instant, | |
| /// Lenia step counter (step every N events) | |
| field_step_counter: u64, | |
| // ── Mode & safety model ─────────────────────────────────────────────── | |
| /// Current operating mode | |
| pub mode: PipelineMode, | |
| /// How many graph rebuilds have occurred since creation | |
| /// (used for transition gate — separate from the public stats counter) | |
| mode_rebuilds: u32, | |
| /// Last measured prediction accuracy (0.0–100.0, from ScoreResult.accuracy) | |
| pub last_prediction_accuracy: f64, | |
| /// How many process_alloc calls have occurred while in Active mode | |
| pub active_cycles: u64, | |
| /// Timestamps (ns) of recent scan_and_compress calls that compressed something. | |
| /// Ring-buffered: keeps last 100 entries. | |
| pub condensation_timestamps: Vec<u64>, | |
| // ── Stats ───────────────────────────────────────────────────────────── | |
| pub events_processed: u64, | |
| pub predictions_fired: u64, | |
| pub predictions_acted: u64, | |
| pub graph_rebuilds: u64, | |
| pub compressions: u64, | |
| pub lenia_steps: u64, | |
| } | |
| impl Pipeline { | |
| /// Create a new pipeline in **Active** mode (backward-compatible default). | |
| pub fn new(config: PipelineConfig) -> Self { | |
| Self::new_with_mode(config, PipelineMode::Active) | |
| } | |
| /// Create a new pipeline in **Observing** mode. | |
| /// The substrate learns immediately; compression is gated until | |
| /// `check_transition()` promotes it to Active. | |
| pub fn new_observing(config: PipelineConfig) -> Self { | |
| Self::new_with_mode(config, PipelineMode::Observing) | |
| } | |
| fn new_with_mode(config: PipelineConfig, mode: PipelineMode) -> Self { | |
| let condenser_config = CondenserConfig { | |
| idle_threshold_ns: config.idle_threshold_ns, | |
| min_manage_size: config.min_manage_size, | |
| test_mode: config.test_mode, | |
| ..Default::default() | |
| }; | |
| // RAM budget for Lenia field — default 1024 MB | |
| let field = LeniaField::new(1024.0); | |
| Self { | |
| graph: AccessGraph::new(config.causal_window_ns, config.cluster_threshold), | |
| predictor: RustPredictor::new(), | |
| condenser: Condenser::new(condenser_config), | |
| field, | |
| event_buffer: Vec::with_capacity(config.graph_rebuild_interval), | |
| address_to_path: std::collections::HashMap::with_capacity(1000), | |
| address_to_field_id: std::collections::HashMap::with_capacity(1000), | |
| next_field_id: 0, | |
| path_counter: 0, | |
| start: Instant::now(), | |
| field_step_counter: 0, | |
| mode, | |
| mode_rebuilds: 0, | |
| last_prediction_accuracy: 0.0, | |
| active_cycles: 0, | |
| condensation_timestamps: Vec::with_capacity(100), | |
| events_processed: 0, | |
| predictions_fired: 0, | |
| predictions_acted: 0, | |
| graph_rebuilds: 0, | |
| compressions: 0, | |
| lenia_steps: 0, | |
| config, | |
| } | |
| } | |
| fn elapsed_ns(&self) -> u64 { | |
| self.start.elapsed().as_nanos() as u64 | |
| } | |
| /// Get or create a path name for an address. | |
| /// | |
| /// ADAPTIVE IDENTITY — inspired by Gaussian splatting's density control. | |
| /// Just as splats represent regions, not points, allocations are | |
| /// identified by their SIZE CLASS, not their address. All 64KB allocs | |
| /// share the path "large" — the graph learns that "large follows large" | |
| /// which IS the pattern. Specific addresses are tracked separately | |
| /// for the condenser to manage, but the graph sees classes. | |
| /// | |
| /// This is Law 7 applied: raw size enters the substrate, no | |
| /// classification beyond the physical size bucket. The graph | |
| /// discovers which buckets co-occur and in what order. | |
| fn get_path(&mut self, address: usize, size: usize) -> String { | |
| // Size-class identity — all allocs of similar size share a path | |
| // This makes predictions transferable across allocations | |
| let path = match size { | |
| 0..=63 => "tiny".to_string(), | |
| 64..=1023 => "small".to_string(), | |
| 1024..=4095 => "med.1k".to_string(), | |
| 4096..=16383 => "med.4k".to_string(), | |
| 16384..=65535 => "med.16k".to_string(), | |
| 65536..=262143 => "large.64k".to_string(), | |
| 262144..=1048575 => "large.256k".to_string(), | |
| 1048576..=4194303 => "huge.1m".to_string(), | |
| 4194304..=16777215 => "huge.4m".to_string(), | |
| 16777216..=67108863 => "huge.16m".to_string(), | |
| _ => "massive".to_string(), | |
| }; | |
| // Map address to path for condenser lookups | |
| self.address_to_path.insert(address, path.clone()); | |
| path | |
| } | |
| /// Process a single allocation event through the full pipeline. | |
| /// | |
| /// Graph building and predictor learning happen in ALL modes. | |
| /// Condenser registration, pre-promote, and scan are gated to Active mode. | |
| /// The substrate always learns — it just doesn't act until Active. | |
| pub fn process_alloc(&mut self, address: usize, size: usize) { | |
| self.events_processed += 1; | |
| let ts = self.elapsed_ns(); | |
| // Skip tiny allocations — noise, not signal | |
| if size < self.config.min_manage_size { | |
| return; | |
| } | |
| // Track active_cycles — graduated engagement ramp | |
| if self.mode == PipelineMode::Active { | |
| self.active_cycles += 1; | |
| } | |
| let threshold = self.effective_threshold(); | |
| if self.mode == PipelineMode::Active { | |
| // Large-alloc passthrough: graph still learns the pattern but the | |
| // condenser never touches these addresses. They are high-entropy | |
| // (ONNX weights, embedding caches, large tensors — LZ4 won't help) | |
| // and inference-hot (compressing mid-flight causes use-after-read). | |
| // Observer data: ONNX model = 86MB; NG bursts hit 7GB in 10s. | |
| let passthrough = size > self.config.large_alloc_passthrough_bytes; | |
| // 1. Register with condenser AND Lenia field (skipped for large allocs) | |
| if !passthrough { | |
| self.condenser.register(address, size); | |
| let field_id = self.get_or_create_field_id(address, size as u64); | |
| // 2. Heat the field — this access injects energy | |
| self.field.access(field_id); | |
| } | |
| // 3. Record for graph learning | |
| let path = self.get_path(address, size); | |
| self.event_buffer.push((ts, path.clone(), size as u64)); | |
| // 4. If predictor is learned, fire predictions | |
| if self.predictor.is_learned() { | |
| let predictions = self.predictor.predict(&path, 5); | |
| self.predictions_fired += predictions.len() as u64; | |
| for pred in &predictions { | |
| if pred.confidence >= threshold { | |
| for (&addr, p) in &self.address_to_path { | |
| if *p == pred.path { | |
| self.condenser.pre_promote(addr); | |
| // Also heat the predicted region in the field | |
| if let Some(&fid) = self.address_to_field_id.get(&addr) { | |
| self.field.access(fid); | |
| } | |
| self.predictions_acted += 1; | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| } | |
| // 5. Periodically step the Lenia field | |
| self.field_step_counter += 1; | |
| if self.field_step_counter % 100 == 0 { | |
| self.field.step(); | |
| self.lenia_steps += 1; | |
| // Use Lenia's cold regions to drive condenser compression | |
| let cold = self.field.get_cold_regions(); | |
| for (cold_id, _temp) in &cold { | |
| // Find the address for this cold field region | |
| for (&addr, &fid) in &self.address_to_field_id { | |
| if fid == *cold_id { | |
| // Tell condenser this region is cold | |
| self.condenser.touch(addr); // mark for idle detection | |
| break; | |
| } | |
| } | |
| } | |
| } | |
| } else { | |
| // Observing or Blacklisted — substrate still learns, condenser is silent | |
| // Record for graph learning (no condenser registration) | |
| let path = self.get_path(address, size); | |
| self.event_buffer.push((ts, path, size as u64)); | |
| } | |
| // 6. Periodically rebuild graph and retrain predictor (all modes) | |
| if self.event_buffer.len() >= self.config.graph_rebuild_interval { | |
| self.rebuild_graph(); | |
| } | |
| } | |
| /// Get or create a Lenia field ID for an address | |
| fn get_or_create_field_id(&mut self, address: usize, size_bytes: u64) -> u32 { | |
| if let Some(&id) = self.address_to_field_id.get(&address) { | |
| return id; | |
| } | |
| let id = self.next_field_id; | |
| self.next_field_id += 1; | |
| self.field.add_region(id, size_bytes as usize, 0); | |
| self.address_to_field_id.insert(address, id); | |
| id | |
| } | |
| /// Process a free event | |
| pub fn process_free(&mut self, address: usize) { | |
| self.condenser.unregister(address); | |
| self.address_to_path.remove(&address); | |
| // Remove from Lenia field — dead allocations don't get thermal cycles | |
| if let Some(field_id) = self.address_to_field_id.remove(&address) { | |
| self.field.remove_region(field_id); | |
| } | |
| } | |
| /// Rebuild the graph from accumulated events and retrain the predictor. | |
| /// Called automatically from process_alloc when the event buffer fills. | |
| fn rebuild_graph(&mut self) { | |
| // Build fresh graph from accumulated events | |
| let mut new_graph = AccessGraph::new( | |
| self.config.causal_window_ns, | |
| self.config.cluster_threshold, | |
| ); | |
| new_graph.build(self.event_buffer.clone()); | |
| // Retrain predictor | |
| let mut new_predictor = RustPredictor::new(); | |
| new_predictor.learn(&new_graph); | |
| // Score the new predictor against the buffer we just trained on | |
| if new_predictor.is_learned() && !self.event_buffer.is_empty() { | |
| let score = new_predictor.score(self.event_buffer.clone()); | |
| self.last_prediction_accuracy = score.accuracy; | |
| } | |
| self.graph = new_graph; | |
| self.predictor = new_predictor; | |
| self.graph_rebuilds += 1; | |
| self.mode_rebuilds += 1; | |
| // Keep last 20% of events for continuity | |
| let keep = self.event_buffer.len() / 5; | |
| let drain_to = self.event_buffer.len() - keep; | |
| self.event_buffer.drain(..drain_to); | |
| // Check mode transition after each rebuild | |
| self.check_transition(); | |
| } | |
| /// Check whether the pipeline should transition from Observing → Active. | |
| /// | |
| /// Transition gates: | |
| /// - mode must be Observing | |
| /// - at least 3 graph rebuilds since creation | |
| /// - last_prediction_accuracy >= 40.0 | |
| /// | |
| /// Blacklisted pipelines never transition. | |
| /// | |
| /// Returns true if a transition occurred. | |
| pub fn check_transition(&mut self) -> bool { | |
| match self.mode { | |
| PipelineMode::Blacklisted => false, | |
| PipelineMode::Active => false, | |
| PipelineMode::Observing => { | |
| if self.mode_rebuilds >= 3 | |
| && self.last_prediction_accuracy >= 40.0 | |
| { | |
| self.mode = PipelineMode::Active; | |
| true | |
| } else { | |
| false | |
| } | |
| } | |
| } | |
| } | |
| /// Effective compression threshold — graduated engagement ramp. | |
| /// | |
| /// New pipelines start conservative (0.8) and relax over time. | |
| /// Non-Active pipelines return 1.0 so nothing ever compresses. | |
| pub fn effective_threshold(&self) -> f64 { | |
| match self.mode { | |
| PipelineMode::Active => { | |
| if self.active_cycles < 100 { | |
| 0.8 | |
| } else if self.active_cycles < 1100 { | |
| 0.5 | |
| } else { | |
| self.config.prediction_threshold | |
| } | |
| } | |
| _ => 1.0, // Never compress when not Active | |
| } | |
| } | |
| /// Run the condenser's compression scan. | |
| /// Call this periodically (e.g., every second). | |
| /// | |
| /// Records condensation timestamps for crash correlation when compression occurs. | |
| pub fn scan(&mut self) -> (u32, u64) { | |
| let (count, saved) = self.condenser.scan_and_compress(); | |
| self.compressions += count as u64; | |
| if count > 0 { | |
| // Record timestamp for crash correlation (ring buffer, last 100) | |
| let ts = self.elapsed_ns(); | |
| if self.condensation_timestamps.len() >= 100 { | |
| self.condensation_timestamps.remove(0); | |
| } | |
| self.condensation_timestamps.push(ts); | |
| } | |
| (count, saved) | |
| } | |
| /// Touch a region (it was accessed) | |
| pub fn touch(&mut self, address: usize) { | |
| self.condenser.touch(address); | |
| } | |
| /// Report that the monitored process died at `death_ns` (nanoseconds, | |
| /// same epoch as `elapsed_ns`). | |
| /// | |
| /// Returns true if any recorded condensation event occurred within 5 seconds | |
| /// of the death — suggesting the condenser may have interfered. | |
| pub fn report_process_death(&mut self, death_ns: u64) -> bool { | |
| const WINDOW_NS: u64 = 5_000_000_000; | |
| for &ts in &self.condensation_timestamps { | |
| let delta = if death_ns >= ts { | |
| death_ns - ts | |
| } else { | |
| ts - death_ns | |
| }; | |
| if delta <= WINDOW_NS { | |
| return true; | |
| } | |
| } | |
| false | |
| } | |
| /// Get pipeline summary | |
| pub fn summary(&self) -> PipelineSummary { | |
| let condenser_summary = self.condenser.summary(); | |
| let lenia_summary = self.field.summary(); | |
| PipelineSummary { | |
| events_processed: self.events_processed, | |
| graph_nodes: self.graph.node_count(), | |
| graph_edges: self.graph.edge_count(), | |
| graph_clusters: self.graph.cluster_count(), | |
| graph_rebuilds: self.graph_rebuilds, | |
| predictions_fired: self.predictions_fired, | |
| predictions_acted: self.predictions_acted, | |
| lenia_steps: self.lenia_steps, | |
| condenser: condenser_summary, | |
| lenia: lenia_summary, | |
| } | |
| } | |
| } | |
| /// Per-process pipeline map — routes allocation events to the correct pipeline | |
| /// based on PID. Each process gets its own isolated pipeline starting in | |
| /// Observing mode. | |
| pub struct ProcessPipelineMap { | |
| pipelines: HashMap<u32, Pipeline>, | |
| config: PipelineConfig, | |
| } | |
| impl ProcessPipelineMap { | |
| pub fn new(config: PipelineConfig) -> Self { | |
| Self { | |
| pipelines: HashMap::new(), | |
| config, | |
| } | |
| } | |
| /// Get or create the pipeline for a given PID. | |
| /// New pipelines start in Observing mode. | |
| pub fn get_or_create(&mut self, pid: u32) -> &mut Pipeline { | |
| if !self.pipelines.contains_key(&pid) { | |
| let pipeline = Pipeline::new_observing(PipelineConfig { | |
| causal_window_ns: self.config.causal_window_ns, | |
| cluster_threshold: self.config.cluster_threshold, | |
| idle_threshold_ns: self.config.idle_threshold_ns, | |
| min_manage_size: self.config.min_manage_size, | |
| graph_rebuild_interval: self.config.graph_rebuild_interval, | |
| prediction_threshold: self.config.prediction_threshold, | |
| test_mode: self.config.test_mode, | |
| large_alloc_passthrough_bytes: self.config.large_alloc_passthrough_bytes, | |
| }); | |
| self.pipelines.insert(pid, pipeline); | |
| } | |
| self.pipelines.get_mut(&pid).unwrap() | |
| } | |
| /// Route an allocation event to the correct process pipeline. | |
| pub fn process_alloc_global(&mut self, pid: u32, address: usize, size: usize) { | |
| self.get_or_create(pid).process_alloc(address, size); | |
| } | |
| /// Route a free event to the correct process pipeline. | |
| pub fn process_free_global(&mut self, pid: u32, address: usize) { | |
| if let Some(pipeline) = self.pipelines.get_mut(&pid) { | |
| pipeline.process_free(address); | |
| } | |
| } | |
| /// Number of tracked processes. | |
| pub fn process_count(&self) -> usize { | |
| self.pipelines.len() | |
| } | |
| } | |
| /// Full pipeline summary | |
| pub struct PipelineSummary { | |
| pub events_processed: u64, | |
| pub graph_nodes: usize, | |
| pub graph_edges: usize, | |
| pub graph_clusters: usize, | |
| pub graph_rebuilds: u64, | |
| pub predictions_fired: u64, | |
| pub predictions_acted: u64, | |
| pub lenia_steps: u64, | |
| pub condenser: crate::condenser::CondenserSummary, | |
| pub lenia: crate::lenia::LeniaSummary, | |
| } | |
| impl PipelineSummary { | |
| pub fn print(&self) { | |
| eprintln!("\n{}", "=".repeat(55)); | |
| eprintln!(" CONDENSATE — Full Pipeline Report"); | |
| eprintln!("{}", "=".repeat(55)); | |
| eprintln!("\n Events processed: {}", self.events_processed); | |
| eprintln!("\n GRAPH (the substrate):"); | |
| eprintln!(" Nodes: {}", self.graph_nodes); | |
| eprintln!(" Edges: {}", self.graph_edges); | |
| eprintln!(" Clusters: {}", self.graph_clusters); | |
| eprintln!(" Rebuilds: {}", self.graph_rebuilds); | |
| eprintln!("\n PREDICTOR (spreading activation):"); | |
| eprintln!(" Predictions fired: {}", self.predictions_fired); | |
| eprintln!(" Predictions acted: {}", self.predictions_acted); | |
| eprintln!("\n CONDENSER (motor output):"); | |
| eprintln!(" HOT: {} ({:.1} MB)", | |
| self.condenser.hot_count, self.condenser.hot_mb); | |
| eprintln!(" WARM: {} ({:.1} MB → {:.1} MB compressed)", | |
| self.condenser.warm_count, | |
| self.condenser.warm_original_mb, | |
| self.condenser.warm_compressed_mb); | |
| eprintln!(" COLD: {}", self.condenser.cold_count); | |
| if self.condenser.total_original_mb > 0.0 { | |
| eprintln!(); | |
| eprintln!(" +-------------------------------------------+"); | |
| eprintln!(" | RAM: {:.1} MB → {:.1} MB ({:.1}% saved){}|", | |
| self.condenser.total_original_mb, | |
| self.condenser.total_current_mb, | |
| self.condenser.saved_pct, | |
| " ".repeat(std::cmp::max(0, | |
| 8 - format!("{:.1} MB → {:.1} MB ({:.1}% saved)", | |
| self.condenser.total_original_mb, | |
| self.condenser.total_current_mb, | |
| self.condenser.saved_pct).len() as i32) as usize)); | |
| eprintln!(" | Same data. Same output. Less RAM. |"); | |
| eprintln!(" +-------------------------------------------+"); | |
| } | |
| eprintln!("\n LENIA FIELD (thermal dynamics):"); | |
| eprintln!(" Steps: {}", self.lenia_steps); | |
| eprintln!(" Energy: {:.1} / {:.1} ({:.1}% of budget)", | |
| self.lenia.total_energy, self.lenia.max_energy, self.lenia.energy_pct); | |
| eprintln!(" HOT (>{:.0}%): {} regions, {:.1} MB", | |
| self.lenia.hot_threshold * 100.0, self.lenia.hot, self.lenia.hot_mb); | |
| eprintln!(" WARM ({:.0}%-{:.0}%): {} regions, {:.1} MB", | |
| self.lenia.cold_threshold * 100.0, self.lenia.hot_threshold * 100.0, | |
| self.lenia.warm, self.lenia.warm_mb); | |
| eprintln!(" COLD (<{:.0}%): {} regions, {:.1} MB", | |
| self.lenia.cold_threshold * 100.0, self.lenia.cold, self.lenia.cold_mb); | |
| eprintln!("{}\n", "=".repeat(55)); | |
| } | |
| } | |
| mod tests { | |
| use super::*; | |
| // ── Existing tests (must continue to pass) ──────────────────────────── | |
| fn test_pipeline_basic_flow() { | |
| let mut pipeline = Pipeline::new(PipelineConfig { | |
| graph_rebuild_interval: 50, | |
| min_manage_size: 1024, | |
| ..Default::default() | |
| }); | |
| // Simulate a workload: repeated pattern of allocations | |
| for round in 0..3 { | |
| let base = round * 0x100000; | |
| for i in 0..20 { | |
| pipeline.process_alloc(base + i * 0x1000, 65_536); | |
| } | |
| } | |
| // Should have rebuilt the graph at least once (60 events > 50 threshold) | |
| assert!(pipeline.graph_rebuilds >= 1, | |
| "Graph should have rebuilt, got {} rebuilds", pipeline.graph_rebuilds); | |
| assert!(pipeline.events_processed > 0); | |
| let summary = pipeline.summary(); | |
| assert!(summary.graph_nodes > 0, "Graph should have nodes"); | |
| } | |
| fn test_pipeline_prediction_drives_promotion() { | |
| let mut pipeline = Pipeline::new(PipelineConfig { | |
| graph_rebuild_interval: 30, | |
| min_manage_size: 1024, | |
| idle_threshold_ns: 0, // compress immediately | |
| prediction_threshold: 0.1, // low threshold to see predictions act | |
| test_mode: true, // fake addresses — use synthetic data | |
| ..Default::default() | |
| }); | |
| // Phase 1: Train the graph with a repeating pattern | |
| // A→B→C, always in this order, 20 times | |
| for round in 0..20 { | |
| let base = round * 3; | |
| pipeline.process_alloc(0xA000 + base, 65_536); | |
| pipeline.process_alloc(0xB000 + base, 65_536); | |
| pipeline.process_alloc(0xC000 + base, 65_536); | |
| } | |
| // Phase 2: Compress everything | |
| pipeline.scan(); | |
| // Phase 3: Trigger the pattern again — predictions should fire | |
| let before_acted = pipeline.predictions_acted; | |
| pipeline.process_alloc(0xA000 + 100, 65_536); | |
| let summary = pipeline.summary(); | |
| summary.print(); | |
| assert!(summary.events_processed > 0); | |
| assert!(summary.graph_rebuilds >= 1); | |
| } | |
| fn test_pipeline_scan_compresses_idle() { | |
| let mut pipeline = Pipeline::new(PipelineConfig { | |
| min_manage_size: 1024, | |
| idle_threshold_ns: 0, // compress immediately | |
| graph_rebuild_interval: 1000, // don't rebuild during this test | |
| test_mode: true, // fake addresses — use synthetic data | |
| ..Default::default() | |
| }); | |
| // Register some regions | |
| pipeline.process_alloc(0x10000, 65_536); | |
| pipeline.process_alloc(0x20000, 65_536); | |
| pipeline.process_alloc(0x30000, 65_536); | |
| // Scan should compress all idle regions | |
| let (count, saved) = pipeline.scan(); | |
| assert_eq!(count, 3); | |
| assert!(saved > 0); | |
| let summary = pipeline.summary(); | |
| assert_eq!(summary.condenser.warm_count, 3); | |
| assert_eq!(summary.condenser.hot_count, 0); | |
| } | |
| fn test_pipeline_free_cleans_up() { | |
| let mut pipeline = Pipeline::new(PipelineConfig { | |
| min_manage_size: 1024, | |
| graph_rebuild_interval: 1000, | |
| ..Default::default() | |
| }); | |
| pipeline.process_alloc(0x10000, 65_536); | |
| pipeline.process_alloc(0x20000, 65_536); | |
| assert_eq!(pipeline.summary().condenser.total_regions, 2); | |
| pipeline.process_free(0x10000); | |
| assert_eq!(pipeline.summary().condenser.total_regions, 1); | |
| } | |
| fn test_pipeline_full_simulation() { | |
| let mut pipeline = Pipeline::new(PipelineConfig { | |
| graph_rebuild_interval: 30, | |
| min_manage_size: 4096, | |
| idle_threshold_ns: 0, | |
| prediction_threshold: 0.3, | |
| test_mode: true, // fake addresses — use synthetic data | |
| ..Default::default() | |
| }); | |
| // Simulate a realistic workload: | |
| // Phase 1: Startup — burst of allocations | |
| for i in 0..50 { | |
| pipeline.process_alloc(0x10000 + i * 0x10000, 65_536); | |
| } | |
| // Phase 2: Steady state — some allocs, some frees | |
| for i in 0..30 { | |
| pipeline.process_free(0x10000 + i * 0x10000); | |
| } | |
| for i in 0..20 { | |
| pipeline.process_alloc(0x800000 + i * 0x10000, 131_072); | |
| } | |
| // Phase 3: Scan for compression | |
| let (compressed, saved) = pipeline.scan(); | |
| // Phase 4: New activity triggers predictions | |
| for i in 0..10 { | |
| pipeline.process_alloc(0xF00000 + i * 0x10000, 65_536); | |
| } | |
| let summary = pipeline.summary(); | |
| summary.print(); | |
| assert!(summary.events_processed > 50); | |
| assert!(summary.condenser.total_regions > 0); | |
| assert!(summary.graph_rebuilds >= 1, | |
| "Graph should have rebuilt at least once"); | |
| } | |
| // ── Block D: new tests ──────────────────────────────────────────────── | |
| /// Observing pipeline registers events but never compresses | |
| fn test_pipeline_mode_observing() { | |
| let mut pipeline = Pipeline::new_observing(PipelineConfig { | |
| min_manage_size: 1024, | |
| idle_threshold_ns: 0, // would compress immediately if Active | |
| graph_rebuild_interval: 1000, | |
| test_mode: true, | |
| ..Default::default() | |
| }); | |
| // Feed events | |
| pipeline.process_alloc(0x10000, 65_536); | |
| pipeline.process_alloc(0x20000, 65_536); | |
| pipeline.process_alloc(0x30000, 65_536); | |
| // Mode must still be Observing (not enough rebuilds / accuracy) | |
| assert_eq!(pipeline.mode, PipelineMode::Observing); | |
| // Scan should return zero compressions — condenser is silent | |
| let (count, saved) = pipeline.scan(); | |
| assert_eq!(count, 0, "Observing pipeline must not compress"); | |
| assert_eq!(saved, 0); | |
| // Condenser must have nothing registered | |
| let summary = pipeline.summary(); | |
| assert_eq!(summary.condenser.total_regions, 0, | |
| "Observing pipeline must not register regions with condenser"); | |
| } | |
| /// After 3 rebuilds with good accuracy, Observing transitions to Active | |
| fn test_pipeline_transition() { | |
| // Use a small rebuild interval so we can force rebuilds quickly. | |
| // We need mode_rebuilds >= 3 AND last_prediction_accuracy >= 40. | |
| let mut pipeline = Pipeline::new_observing(PipelineConfig { | |
| min_manage_size: 1024, | |
| graph_rebuild_interval: 10, | |
| idle_threshold_ns: 1_000_000_000, | |
| prediction_threshold: 0.1, | |
| ..Default::default() | |
| }); | |
| // Drive a strong repeating pattern so the predictor scores well. | |
| // Each batch of 10+ events triggers a rebuild. | |
| for _round in 0..5 { | |
| for i in 0..12usize { | |
| let size = if i % 2 == 0 { 65_536 } else { 131_072 }; | |
| pipeline.process_alloc(0x10000 + i * 0x1000, size); | |
| } | |
| } | |
| assert!(pipeline.graph_rebuilds >= 3, | |
| "Expected at least 3 rebuilds, got {}", pipeline.graph_rebuilds); | |
| // Patch accuracy to guarantee the transition gate passes, | |
| // then call check_transition (also called internally — idempotent). | |
| pipeline.last_prediction_accuracy = 50.0; | |
| let transitioned = pipeline.check_transition(); | |
| assert!(transitioned, "Should have transitioned to Active"); | |
| assert_eq!(pipeline.mode, PipelineMode::Active); | |
| } | |
| /// effective_threshold returns 0.8 fresh, 0.5 mid-ramp, config value at maturity | |
| fn test_pipeline_graduated_threshold() { | |
| let mut pipeline = Pipeline::new(PipelineConfig { | |
| prediction_threshold: 0.3, | |
| ..Default::default() | |
| }); | |
| // Fresh Active pipeline, 0 cycles | |
| assert_eq!(pipeline.active_cycles, 0); | |
| assert_eq!(pipeline.effective_threshold(), 0.8, | |
| "Fresh active pipeline should use conservative 0.8 threshold"); | |
| // Mid-ramp | |
| pipeline.active_cycles = 500; | |
| assert_eq!(pipeline.effective_threshold(), 0.5, | |
| "Mid-ramp should use 0.5 threshold"); | |
| // Mature | |
| pipeline.active_cycles = 1100; | |
| assert_eq!(pipeline.effective_threshold(), 0.3, | |
| "Mature pipeline should use config threshold"); | |
| // Observing always returns 1.0 | |
| let observing = Pipeline::new_observing(PipelineConfig::default()); | |
| assert_eq!(observing.effective_threshold(), 1.0, | |
| "Observing pipeline threshold must be 1.0 (never compress)"); | |
| } | |
| /// Condensation within 5 seconds of process death is flagged | |
| fn test_pipeline_crash_correlation() { | |
| let mut pipeline = Pipeline::new(PipelineConfig { | |
| min_manage_size: 1024, | |
| idle_threshold_ns: 0, | |
| graph_rebuild_interval: 1000, | |
| test_mode: true, // fake addresses — use synthetic data | |
| ..Default::default() | |
| }); | |
| // Compress something so a timestamp is recorded | |
| pipeline.process_alloc(0x10000, 65_536); | |
| let (count, _) = pipeline.scan(); | |
| assert_eq!(count, 1, "Expected one compression"); | |
| assert_eq!(pipeline.condensation_timestamps.len(), 1); | |
| // Death 1 second after condensation — inside the 5s window | |
| let condensation_ts = pipeline.condensation_timestamps[0]; | |
| let death_1s_later = condensation_ts + 1_000_000_000; | |
| assert!( | |
| pipeline.report_process_death(death_1s_later), | |
| "Death 1s after condensation should be flagged as likely interference" | |
| ); | |
| // Death 10 seconds later — outside window | |
| let death_10s_later = condensation_ts + 10_000_000_000; | |
| assert!( | |
| !pipeline.report_process_death(death_10s_later), | |
| "Death 10s after condensation should not be flagged" | |
| ); | |
| } | |
| /// Blacklisted pipeline never transitions regardless of accuracy or rebuilds | |
| fn test_pipeline_blacklisted() { | |
| let mut pipeline = Pipeline::new_observing(PipelineConfig { | |
| min_manage_size: 1024, | |
| graph_rebuild_interval: 1000, | |
| ..Default::default() | |
| }); | |
| // Force blacklist | |
| pipeline.mode = PipelineMode::Blacklisted; | |
| // Simulate ideal conditions — should still not transition | |
| pipeline.mode_rebuilds = 10; | |
| pipeline.last_prediction_accuracy = 99.0; | |
| let transitioned = pipeline.check_transition(); | |
| assert!(!transitioned, "Blacklisted pipeline must never transition"); | |
| assert_eq!(pipeline.mode, PipelineMode::Blacklisted); | |
| } | |
| /// Two PIDs get fully isolated pipelines | |
| fn test_process_pipeline_map() { | |
| let mut map = ProcessPipelineMap::new(PipelineConfig { | |
| min_manage_size: 1024, | |
| idle_threshold_ns: 0, | |
| graph_rebuild_interval: 1000, | |
| test_mode: true, // fake addresses — use synthetic data | |
| ..Default::default() | |
| }); | |
| // Two distinct PIDs | |
| map.process_alloc_global(100, 0x10000, 65_536); | |
| map.process_alloc_global(100, 0x20000, 65_536); | |
| map.process_alloc_global(200, 0x10000, 65_536); | |
| assert_eq!(map.process_count(), 2, "Should track exactly 2 processes"); | |
| // Pipelines start in Observing mode | |
| { | |
| let p100 = map.get_or_create(100); | |
| assert_eq!(p100.mode, PipelineMode::Observing, | |
| "New pipelines must start in Observing mode"); | |
| assert_eq!(p100.events_processed, 2); | |
| } | |
| { | |
| let p200 = map.get_or_create(200); | |
| assert_eq!(p200.events_processed, 1); | |
| } | |
| // Free on PID 100 doesn't affect PID 200 | |
| map.process_free_global(100, 0x10000); | |
| { | |
| let p200 = map.get_or_create(200); | |
| assert_eq!(p200.events_processed, 1, | |
| "PID 200 should be unaffected by PID 100 free"); | |
| } | |
| // Free on unknown PID is a no-op (must not panic) | |
| map.process_free_global(999, 0xDEAD); | |
| } | |
| } | |