Skip to main content

cortiq_engine/
runtime.rs

1//! Runtime state management — model, active task, metrics.
2
3use cortiq_core::CmfModel;
4use cortiq_core::mask::{MaskCatalog, MaskDiff, TaskMask};
5#[cfg(not(target_os = "macos"))]
6use cortiq_core::types::SimdType;
7use cortiq_core::types::{ExecutionMode, LayerStats, PerformanceMetrics};
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. FFN neuron counts come from the actual
72        // gate_proj shape (the directory is the size authority) so a
73        // physically-defragged layer (spec §11) reports its true reduced
74        // count, not the nominal arch scalar; fall back to the scalar when
75        // no dense gate_proj is present (e.g. MoE router layers).
76        let ffn_neurons = |i: usize| -> usize {
77            model
78                .tensor(&format!("model.layers.{i}.mlp.gate_proj.weight"))
79                .and_then(|t| t.shape.first().copied())
80                .unwrap_or(arch.intermediate_size)
81        };
82        let layer_stats: Vec<LayerStats> = (0..n_layers)
83            .map(|i| LayerStats {
84                layer_idx: i,
85                active_neurons: ffn_neurons(i),
86                total_neurons: ffn_neurons(i),
87                active_heads: arch.num_attention_heads,
88                total_heads: arch.num_attention_heads,
89                is_alive: true,
90                placement: "gpu".to_string(),
91                avg_forward_ms: 0.0,
92            })
93            .collect();
94
95        let state = RuntimeState {
96            // The catalog's default task activates on load — that is the
97            // "one file, many specialists" contract. CMF_TASK=off starts
98            // bare instead: no mask, backbone behaviour. Needed because a
99            // per-visit mask currently has no fast inference path, and a
100            // file whose default is masked would otherwise offer no way
101            // to run at full speed at all.
102            active_task: if std::env::var("CMF_TASK").as_deref() == Ok("off") {
103                String::new()
104            } else {
105                model.masks.default_task.clone()
106            },
107            // The MASK must follow the same switch: clearing only the
108            // task name while seeding active_mask from fallback() hands
109            // generate a mask anyway — measured at 0.2 tok/s against
110            // 3.5 bare, with "Task:" printing empty the whole time.
111            active_mask: if std::env::var("CMF_TASK").as_deref() == Ok("off") {
112                None
113            } else {
114                model.masks.fallback().cloned()
115            },
116            execution_mode,
117            metrics: PerformanceMetrics::default(),
118            layer_stats,
119        };
120
121        Self {
122            model,
123            state: Arc::new(RwLock::new(state)),
124            started_at: Instant::now(),
125        }
126    }
127
128    /// Execution mode as it actually runs: the thread count is the
129    /// REAL worker-pool size (forced > CMF_THREADS > big-core
130    /// topology — the same resolution Pool::from_env applies), not
131    /// available_parallelism; SIMD follows the target arch. The old
132    /// stub reported `Avx2 · num_cpus` even on a phone and sent a
133    /// device investigation down two wrong paths (cmfmobile
134    /// TUNING.md finding 2).
135    fn detect_execution_mode() -> ExecutionMode {
136        #[cfg(target_os = "macos")]
137        {
138            ExecutionMode::AppleUnified {
139                metal_layers: vec![],
140            }
141        }
142        #[cfg(not(target_os = "macos"))]
143        {
144            #[cfg(target_arch = "aarch64")]
145            let simd_type = SimdType::Neon;
146            #[cfg(target_arch = "x86_64")]
147            let simd_type = SimdType::Avx2;
148            #[cfg(not(any(target_arch = "aarch64", target_arch = "x86_64")))]
149            let simd_type = SimdType::None;
150            ExecutionMode::CpuOnly {
151                simd_type,
152                threads: crate::pool::Pool::effective_threads(),
153            }
154        }
155    }
156
157    /// Switch to a different task mask.
158    pub async fn switch_task(&self, task_name: &str) -> Result<SwitchResult, anyhow::Error> {
159        let new_mask = self
160            .model
161            .masks
162            .get(task_name)
163            .ok_or_else(|| anyhow::anyhow!("Task mask '{}' not found", task_name))?
164            .clone();
165
166        let mut state = self.state.write().await;
167        let previous_task = state.active_task.clone();
168
169        // Spec §5.1: switching onto an unmeasured mask must be loud.
170        if new_mask.quality.is_none() {
171            tracing::warn!(
172                "task '{task_name}': mask has no measured quality — \
173                 treat outputs as unvalidated"
174            );
175        }
176
177        let switch_start = Instant::now();
178
179        // Compute diff for efficient swap
180        let diff = if let Some(ref current) = state.active_mask {
181            current.diff(&new_mask)
182        } else {
183            MaskDiff {
184                changed_layers: (0..self.model.arch().num_layers).collect(),
185                neurons_added: 0,
186                neurons_removed: 0,
187                ffn_delta: vec![],
188            }
189        };
190
191        // Update layer stats based on new mask
192        for ls in &mut state.layer_stats {
193            ls.active_neurons = new_mask.ffn_active_count(ls.layer_idx);
194            ls.active_heads = new_mask.active_head_count(ls.layer_idx);
195            ls.is_alive = new_mask.layer_alive(ls.layer_idx);
196        }
197
198        let switch_latency = switch_start.elapsed().as_secs_f64() * 1000.0;
199
200        state.active_task = task_name.to_string();
201        let sparsity = new_mask.sparsity;
202        state.active_mask = Some(new_mask);
203        state.metrics.last_switch_latency_ms = switch_latency;
204        state.metrics.total_switches += 1;
205
206        let active_params = self.estimate_active_params(&state);
207
208        Ok(SwitchResult {
209            previous_task,
210            new_task: task_name.to_string(),
211            switch_mode: "warm".to_string(),
212            switch_latency_ms: switch_latency,
213            new_sparsity: sparsity,
214            new_active_params: active_params,
215            diff,
216        })
217    }
218
219    /// Get current runtime status.
220    pub async fn status(&self) -> RuntimeStatus {
221        let state = self.state.read().await;
222        let mut metrics = state.metrics.clone();
223        metrics.uptime_seconds = self.started_at.elapsed().as_secs();
224
225        let active_sparsity = state
226            .active_mask
227            .as_ref()
228            .map(|m| m.sparsity)
229            .unwrap_or(0.0);
230
231        let active_layers = state.layer_stats.iter().filter(|l| l.is_alive).count();
232
233        RuntimeStatus {
234            model_name: self.model.arch().arch_name.clone(),
235            model_path: self.model.path.display().to_string(),
236            format: format!("CMF v{}", self.model.header.version),
237            quantization: format!("{:?}", self.model.header.quant_type),
238            execution_mode: state.execution_mode.clone(),
239            active_task: state.active_task.clone(),
240            active_sparsity,
241            active_params: self.estimate_active_params(&state),
242            active_layers,
243            total_layers: self.model.arch().num_layers * self.model.arch().num_loops,
244            performance: metrics,
245            layer_stats: state.layer_stats.clone(),
246        }
247    }
248
249    /// Get mask catalog.
250    pub fn masks(&self) -> &MaskCatalog {
251        &self.model.masks
252    }
253
254    /// Get model reference.
255    pub fn model(&self) -> &CmfModel {
256        &self.model
257    }
258
259    /// Snapshot of the currently active task mask (None = dense).
260    pub async fn active_mask(&self) -> Option<TaskMask> {
261        self.state.read().await.active_mask.clone()
262    }
263
264    /// Atomically snapshot the active task name and its mask. Request paths
265    /// must use one snapshot rather than reading these fields separately.
266    pub async fn active_selection(&self) -> (String, Option<TaskMask>) {
267        let state = self.state.read().await;
268        (state.active_task.clone(), state.active_mask.clone())
269    }
270
271    /// Record a finished generation into the performance metrics.
272    pub async fn record_generation(&self, gen_tokens: usize, elapsed_ms: f64, ttft_ms: f64) {
273        let mut state = self.state.write().await;
274        let m = &mut state.metrics;
275        let prev_total = m.tokens_generated as f64;
276        m.tokens_generated += gen_tokens as u64;
277        if elapsed_ms > 0.0 {
278            let tps = gen_tokens as f64 / (elapsed_ms / 1000.0);
279            // Running average weighted by token counts.
280            let total = prev_total + gen_tokens as f64;
281            m.avg_tokens_per_sec = if total > 0.0 {
282                (m.avg_tokens_per_sec * prev_total + tps * gen_tokens as f64) / total
283            } else {
284                tps
285            };
286        }
287        if m.avg_time_to_first_token_ms == 0.0 {
288            m.avg_time_to_first_token_ms = ttft_ms;
289        } else {
290            m.avg_time_to_first_token_ms = m.avg_time_to_first_token_ms * 0.9 + ttft_ms * 0.1;
291        }
292    }
293
294    /// Estimate active parameters string (from real tensor shapes).
295    fn estimate_active_params(&self, state: &RuntimeState) -> String {
296        let total_params = self.model.total_param_count() as f64 / 1e9;
297        let sparsity = state
298            .active_mask
299            .as_ref()
300            .map(|m| m.sparsity as f64)
301            .unwrap_or(0.0);
302        let active = total_params * (1.0 - sparsity);
303        format!("{:.2}B / {:.2}B", active, total_params)
304    }
305}
306
307#[cfg(not(target_os = "macos"))]
308fn num_cpus() -> usize {
309    std::thread::available_parallelism()
310        .map(|n| n.get())
311        .unwrap_or(4)
312}