Skip to main content

cortiq_engine/
runtime.rs

1//! Runtime state management — model, active task, metrics.
2
3use cortiq_core::mask::{MaskCatalog, MaskDiff, TaskMask};
4#[cfg(not(target_os = "macos"))]
5use cortiq_core::types::SimdType;
6use cortiq_core::types::{ExecutionMode, LayerStats, PerformanceMetrics};
7use cortiq_core::CmfModel;
8use serde::{Deserialize, Serialize};
9use std::sync::Arc;
10use std::time::Instant;
11use tokio::sync::RwLock;
12
13/// Main runtime managing model state, active task, and metrics.
14pub struct CortiqRuntime {
15    /// Loaded CMF model
16    model: Arc<CmfModel>,
17    /// Current state (behind RwLock for concurrent reads)
18    state: Arc<RwLock<RuntimeState>>,
19    /// Start time
20    started_at: Instant,
21}
22
23/// Mutable runtime state.
24#[derive(Debug)]
25struct RuntimeState {
26    active_task: String,
27    active_mask: Option<TaskMask>,
28    execution_mode: ExecutionMode,
29    metrics: PerformanceMetrics,
30    layer_stats: Vec<LayerStats>,
31}
32
33/// Response from a task switch operation.
34#[derive(Debug, Clone, Serialize, Deserialize)]
35pub struct SwitchResult {
36    pub previous_task: String,
37    pub new_task: String,
38    pub switch_mode: String,
39    pub switch_latency_ms: f64,
40    pub new_sparsity: f32,
41    pub new_active_params: String,
42    pub diff: MaskDiff,
43}
44
45/// Runtime status snapshot.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct RuntimeStatus {
48    pub model_name: String,
49    pub model_path: String,
50    pub format: String,
51    pub quantization: String,
52    pub execution_mode: ExecutionMode,
53    pub active_task: String,
54    pub active_sparsity: f32,
55    pub active_params: String,
56    pub active_layers: usize,
57    pub total_layers: usize,
58    pub performance: PerformanceMetrics,
59    pub layer_stats: Vec<LayerStats>,
60}
61
62impl CortiqRuntime {
63    /// Create a new runtime from a loaded CMF model.
64    pub fn new(model: Arc<CmfModel>) -> Self {
65        let arch = model.arch();
66        let n_layers = arch.num_layers;
67
68        // Detect execution mode
69        let execution_mode = Self::detect_execution_mode();
70
71        // Default layer stats
72        let layer_stats: Vec<LayerStats> = (0..n_layers)
73            .map(|i| LayerStats {
74                layer_idx: i,
75                active_neurons: arch.intermediate_size,
76                total_neurons: arch.intermediate_size,
77                active_heads: arch.num_attention_heads,
78                total_heads: arch.num_attention_heads,
79                is_alive: true,
80                placement: "gpu".to_string(),
81                avg_forward_ms: 0.0,
82            })
83            .collect();
84
85        let state = RuntimeState {
86            active_task: model.masks.default_task.clone(),
87            active_mask: model.masks.fallback().cloned(),
88            execution_mode,
89            metrics: PerformanceMetrics::default(),
90            layer_stats,
91        };
92
93        Self {
94            model,
95            state: Arc::new(RwLock::new(state)),
96            started_at: Instant::now(),
97        }
98    }
99
100    /// Detect optimal execution mode for current hardware.
101    fn detect_execution_mode() -> ExecutionMode {
102        // TODO: actual hardware detection (CUDA, Metal, CPU caps)
103        #[cfg(target_os = "macos")]
104        {
105            ExecutionMode::AppleUnified {
106                metal_layers: vec![],
107            }
108        }
109        #[cfg(not(target_os = "macos"))]
110        {
111            ExecutionMode::CpuOnly {
112                simd_type: SimdType::Avx2,
113                threads: num_cpus(),
114            }
115        }
116    }
117
118    /// Switch to a different task mask.
119    pub async fn switch_task(&self, task_name: &str) -> Result<SwitchResult, anyhow::Error> {
120        let new_mask = self
121            .model
122            .masks
123            .get(task_name)
124            .ok_or_else(|| anyhow::anyhow!("Task mask '{}' not found", task_name))?
125            .clone();
126
127        let mut state = self.state.write().await;
128        let previous_task = state.active_task.clone();
129
130        // Spec §5.1: switching onto an unmeasured mask must be loud.
131        if new_mask.quality.is_none() {
132            tracing::warn!(
133                "task '{task_name}': mask has no measured quality — \
134                 treat outputs as unvalidated"
135            );
136        }
137
138        let switch_start = Instant::now();
139
140        // Compute diff for efficient swap
141        let diff = if let Some(ref current) = state.active_mask {
142            current.diff(&new_mask)
143        } else {
144            MaskDiff {
145                changed_layers: (0..self.model.arch().num_layers).collect(),
146                neurons_added: 0,
147                neurons_removed: 0,
148                ffn_delta: vec![],
149            }
150        };
151
152        // Update layer stats based on new mask
153        for ls in &mut state.layer_stats {
154            ls.active_neurons = new_mask.ffn_active_count(ls.layer_idx);
155            ls.active_heads = new_mask.active_head_count(ls.layer_idx);
156            ls.is_alive = new_mask.layer_alive(ls.layer_idx);
157        }
158
159        let switch_latency = switch_start.elapsed().as_secs_f64() * 1000.0;
160
161        state.active_task = task_name.to_string();
162        let sparsity = new_mask.sparsity;
163        state.active_mask = Some(new_mask);
164        state.metrics.last_switch_latency_ms = switch_latency;
165        state.metrics.total_switches += 1;
166
167        let active_params = self.estimate_active_params(&state);
168
169        Ok(SwitchResult {
170            previous_task,
171            new_task: task_name.to_string(),
172            switch_mode: "warm".to_string(),
173            switch_latency_ms: switch_latency,
174            new_sparsity: sparsity,
175            new_active_params: active_params,
176            diff,
177        })
178    }
179
180    /// Get current runtime status.
181    pub async fn status(&self) -> RuntimeStatus {
182        let state = self.state.read().await;
183        let mut metrics = state.metrics.clone();
184        metrics.uptime_seconds = self.started_at.elapsed().as_secs();
185
186        let active_sparsity = state
187            .active_mask
188            .as_ref()
189            .map(|m| m.sparsity)
190            .unwrap_or(0.0);
191
192        let active_layers = state.layer_stats.iter().filter(|l| l.is_alive).count();
193
194        RuntimeStatus {
195            model_name: self.model.arch().arch_name.clone(),
196            model_path: self.model.path.display().to_string(),
197            format: format!("CMF v{}", self.model.header.version),
198            quantization: format!("{:?}", self.model.header.quant_type),
199            execution_mode: state.execution_mode.clone(),
200            active_task: state.active_task.clone(),
201            active_sparsity,
202            active_params: self.estimate_active_params(&state),
203            active_layers,
204            total_layers: self.model.arch().num_layers,
205            performance: metrics,
206            layer_stats: state.layer_stats.clone(),
207        }
208    }
209
210    /// Get mask catalog.
211    pub fn masks(&self) -> &MaskCatalog {
212        &self.model.masks
213    }
214
215    /// Get model reference.
216    pub fn model(&self) -> &CmfModel {
217        &self.model
218    }
219
220    /// Snapshot of the currently active task mask (None = dense).
221    pub async fn active_mask(&self) -> Option<TaskMask> {
222        self.state.read().await.active_mask.clone()
223    }
224
225    /// Record a finished generation into the performance metrics.
226    pub async fn record_generation(&self, gen_tokens: usize, elapsed_ms: f64, ttft_ms: f64) {
227        let mut state = self.state.write().await;
228        let m = &mut state.metrics;
229        let prev_total = m.tokens_generated as f64;
230        m.tokens_generated += gen_tokens as u64;
231        if elapsed_ms > 0.0 {
232            let tps = gen_tokens as f64 / (elapsed_ms / 1000.0);
233            // Running average weighted by token counts.
234            let total = prev_total + gen_tokens as f64;
235            m.avg_tokens_per_sec = if total > 0.0 {
236                (m.avg_tokens_per_sec * prev_total + tps * gen_tokens as f64) / total
237            } else {
238                tps
239            };
240        }
241        if m.avg_time_to_first_token_ms == 0.0 {
242            m.avg_time_to_first_token_ms = ttft_ms;
243        } else {
244            m.avg_time_to_first_token_ms = m.avg_time_to_first_token_ms * 0.9 + ttft_ms * 0.1;
245        }
246    }
247
248    /// Estimate active parameters string (from real tensor shapes).
249    fn estimate_active_params(&self, state: &RuntimeState) -> String {
250        let total_params = self.model.total_param_count() as f64 / 1e9;
251        let sparsity = state
252            .active_mask
253            .as_ref()
254            .map(|m| m.sparsity as f64)
255            .unwrap_or(0.0);
256        let active = total_params * (1.0 - sparsity);
257        format!("{:.2}B / {:.2}B", active, total_params)
258    }
259}
260
261#[cfg(not(target_os = "macos"))]
262fn num_cpus() -> usize {
263    std::thread::available_parallelism()
264        .map(|n| n.get())
265        .unwrap_or(4)
266}