Skip to main content

cortiq_core/
mask.rs

1//! Task mask management — per-task neuron/head/layer masks.
2//!
3//! A mask selects an active subset of the shared weights (weights are
4//! never modified — VMF principle: a skill is a regular core of the
5//! condensate). Bit order is LSB-first: neuron `i` = bit `i % 8` of
6//! byte `i / 8`; bit 1 = active. Tail bits beyond the dimension MUST
7//! be zero (otherwise popcount sees phantom neurons/heads).
8
9use crate::types::ModelArch;
10use serde::{Deserialize, Serialize};
11
12/// Held-out quality contract for a mask. `None` means "not measured" —
13/// the format forbids declaring quality without a measurement.
14#[derive(Debug, Clone, Serialize, Deserialize)]
15pub struct Quality {
16    /// e.g. "heldout_ppl_ratio", "heldout_acc"
17    pub metric: String,
18    pub value: f32,
19    #[serde(default, skip_serializing_if = "Option::is_none")]
20    pub baseline_dense: Option<f32>,
21    #[serde(default, skip_serializing_if = "Option::is_none")]
22    pub n_samples: Option<u32>,
23    #[serde(default, skip_serializing_if = "Option::is_none")]
24    pub dataset_sha256: Option<String>,
25}
26
27/// A single task mask defining which neurons/heads/layers are active.
28#[derive(Debug, Clone, Serialize, Deserialize)]
29pub struct TaskMask {
30    /// Unique task identifier
31    pub task_id: u32,
32    /// Human-readable task name
33    pub name: String,
34    /// Optional description
35    pub description: Option<String>,
36    /// Overall sparsity (0.0 = no pruning, 1.0 = fully pruned)
37    pub sparsity: f32,
38    /// Held-out quality (None = not measured)
39    #[serde(default)]
40    pub quality: Option<Quality>,
41    /// Per-layer FFN neuron masks (bitfield: 1 = active)
42    pub ffn_masks: Vec<Vec<u8>>,
43    /// Per-layer attention head masks (bitfield: 1 = active)
44    pub head_masks: Vec<Vec<u8>>,
45    /// Per-layer alive flags
46    pub layer_gates: Vec<bool>,
47    /// Parent mask name (for delta-coded masks)
48    pub parent: Option<String>,
49    /// Whether this mask has a precompiled hot-pack
50    pub has_hot_pack: bool,
51    /// Priority level for this mask
52    pub priority: MaskPriority,
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
56pub enum MaskPriority {
57    Fallback,
58    Normal,
59    Primary,
60}
61
62impl TaskMask {
63    /// Count active neurons in a specific layer's FFN.
64    pub fn ffn_active_count(&self, layer_idx: usize) -> usize {
65        self.ffn_masks
66            .get(layer_idx)
67            .map(|m| m.iter().map(|b| b.count_ones() as usize).sum())
68            .unwrap_or(0)
69    }
70
71    /// Check if a specific layer is alive (not pruned).
72    pub fn layer_alive(&self, layer_idx: usize) -> bool {
73        self.layer_gates.get(layer_idx).copied().unwrap_or(false)
74    }
75
76    /// Count total active layers.
77    pub fn active_layer_count(&self) -> usize {
78        self.layer_gates.iter().filter(|&&alive| alive).count()
79    }
80
81    /// Get active neuron indices for a layer (for sparse gather).
82    pub fn ffn_active_indices(&self, layer_idx: usize) -> Vec<u16> {
83        let Some(mask) = self.ffn_masks.get(layer_idx) else {
84            return vec![];
85        };
86        let mut indices = Vec::new();
87        for (byte_idx, &byte) in mask.iter().enumerate() {
88            for bit in 0..8 {
89                if byte & (1 << bit) != 0 {
90                    indices.push((byte_idx * 8 + bit) as u16);
91                }
92            }
93        }
94        indices
95    }
96
97    /// Count active attention heads in a layer.
98    pub fn active_head_count(&self, layer_idx: usize) -> usize {
99        self.head_masks
100            .get(layer_idx)
101            .map(|m| m.iter().map(|b| b.count_ones() as usize).sum())
102            .unwrap_or(0)
103    }
104
105    /// Active head flags for a layer (true = head is alive).
106    pub fn head_flags(&self, layer_idx: usize, num_heads: usize) -> Vec<bool> {
107        let mut flags = vec![true; num_heads];
108        if let Some(mask) = self.head_masks.get(layer_idx) {
109            for (h, flag) in flags.iter_mut().enumerate() {
110                *flag = mask
111                    .get(h / 8)
112                    .map(|b| b & (1 << (h % 8)) != 0)
113                    .unwrap_or(false);
114            }
115        }
116        flags
117    }
118
119    /// Average active neurons across all alive layers.
120    pub fn avg_active_neurons(&self) -> f64 {
121        let alive_layers: Vec<_> = (0..self.layer_gates.len())
122            .filter(|&i| self.layer_alive(i))
123            .collect();
124        if alive_layers.is_empty() {
125            return 0.0;
126        }
127        let total: usize = alive_layers.iter().map(|&i| self.ffn_active_count(i)).sum();
128        total as f64 / alive_layers.len() as f64
129    }
130
131    /// Compute union of two masks (more neurons = higher quality, less speed).
132    pub fn union(&self, other: &TaskMask) -> TaskMask {
133        let mut result = self.clone();
134        result.name = format!("{}+{}", self.name, other.name);
135        result.task_id = u32::MAX; // composite
136        result.parent = None;
137        result.has_hot_pack = false;
138        result.quality = None; // union quality is not measured
139
140        for (li, gate) in result.layer_gates.iter_mut().enumerate() {
141            *gate = self.layer_alive(li) || other.layer_alive(li);
142        }
143
144        for (li, mask) in result.ffn_masks.iter_mut().enumerate() {
145            if let Some(om) = other.ffn_masks.get(li) {
146                for (byte, &ob) in mask.iter_mut().zip(om) {
147                    *byte |= ob;
148                }
149            }
150        }
151
152        for (li, mask) in result.head_masks.iter_mut().enumerate() {
153            if let Some(om) = other.head_masks.get(li) {
154                for (byte, &ob) in mask.iter_mut().zip(om) {
155                    *byte |= ob;
156                }
157            }
158        }
159
160        // Recalculate sparsity
161        let total_neurons: usize = result.ffn_masks.iter().map(|m| m.len() * 8).sum();
162        let active: usize = (0..result.layer_gates.len())
163            .map(|i| result.ffn_active_count(i))
164            .sum();
165        result.sparsity = 1.0 - (active as f32 / total_neurons.max(1) as f32);
166
167        result
168    }
169
170    /// Bitwise diff between current and new mask (for hot-swap).
171    /// Compares the actual bits (XOR), not per-layer counters: two masks
172    /// with equal counts but different neurons produce a full delta.
173    pub fn diff(&self, other: &TaskMask) -> MaskDiff {
174        let n_layers = self.layer_gates.len().max(other.layer_gates.len());
175        let mut changed_layers = Vec::new();
176        let mut neurons_added = 0usize;
177        let mut neurons_removed = 0usize;
178        let mut ffn_delta = Vec::with_capacity(n_layers);
179
180        let empty: Vec<u8> = Vec::new();
181        for li in 0..n_layers {
182            let a = self.ffn_masks.get(li).unwrap_or(&empty);
183            let b = other.ffn_masks.get(li).unwrap_or(&empty);
184            let len = a.len().max(b.len());
185            let mut delta = vec![0u8; len];
186            let mut layer_changed = self.layer_alive(li) != other.layer_alive(li);
187
188            for bi in 0..len {
189                let av = a.get(bi).copied().unwrap_or(0);
190                let bv = b.get(bi).copied().unwrap_or(0);
191                let x = av ^ bv;
192                delta[bi] = x;
193                if x != 0 {
194                    layer_changed = true;
195                    neurons_added += (bv & !av).count_ones() as usize;
196                    neurons_removed += (av & !bv).count_ones() as usize;
197                }
198            }
199
200            // Head bits count toward "changed" too.
201            let ha = self.head_masks.get(li).unwrap_or(&empty);
202            let hb = other.head_masks.get(li).unwrap_or(&empty);
203            if ha.len() != hb.len() || ha.iter().zip(hb).any(|(x, y)| x != y) {
204                layer_changed = true;
205            }
206
207            if layer_changed {
208                changed_layers.push(li);
209            }
210            ffn_delta.push(delta);
211        }
212
213        MaskDiff {
214            changed_layers,
215            neurons_added,
216            neurons_removed,
217            ffn_delta,
218        }
219    }
220
221    /// Zero tail bits beyond the real dimensions (defensive normalization).
222    pub fn normalize_tail_bits(&mut self, arch: &ModelArch) {
223        for row in &mut self.ffn_masks {
224            zero_tail_bits(row, arch.intermediate_size);
225        }
226        for row in &mut self.head_masks {
227            zero_tail_bits(row, arch.num_attention_heads);
228        }
229    }
230}
231
232/// Zero all bits at positions >= `n_bits` in a bitfield.
233pub fn zero_tail_bits(bits: &mut [u8], n_bits: usize) {
234    let full_bytes = n_bits / 8;
235    let rem = n_bits % 8;
236    if full_bytes < bits.len() {
237        if rem > 0 {
238            bits[full_bytes] &= (1u8 << rem) - 1;
239            for b in &mut bits[full_bytes + 1..] {
240                *b = 0;
241            }
242        } else {
243            for b in &mut bits[full_bytes..] {
244                *b = 0;
245            }
246        }
247    }
248}
249
250/// Result of diffing two masks (used for efficient hot-swap).
251#[derive(Debug, Clone, Serialize, Deserialize)]
252pub struct MaskDiff {
253    pub changed_layers: Vec<usize>,
254    pub neurons_added: usize,
255    pub neurons_removed: usize,
256    /// Per-layer XOR bitfields — exactly which neurons flipped.
257    #[serde(skip)]
258    pub ffn_delta: Vec<Vec<u8>>,
259}
260
261/// Catalog of all masks in a CMF model file.
262#[derive(Debug, Clone, Serialize, Deserialize)]
263pub struct MaskCatalog {
264    pub masks: Vec<TaskMask>,
265    pub default_task: String,
266}
267
268impl MaskCatalog {
269    pub fn empty() -> Self {
270        Self {
271            masks: vec![],
272            default_task: "general".to_string(),
273        }
274    }
275
276    /// Find mask by name.
277    pub fn get(&self, name: &str) -> Option<&TaskMask> {
278        self.masks.iter().find(|m| m.name == name)
279    }
280
281    /// Get fallback mask.
282    pub fn fallback(&self) -> Option<&TaskMask> {
283        self.masks
284            .iter()
285            .find(|m| m.priority == MaskPriority::Fallback)
286            .or(self.masks.first())
287    }
288
289    /// List all task names.
290    pub fn task_names(&self) -> Vec<&str> {
291        self.masks.iter().map(|m| m.name.as_str()).collect()
292    }
293}
294
295// ───────────────────── binary masks section (§5 of the spec) ─────────────────────
296
297/// JSON metadata part of the masks section.
298#[derive(Debug, Clone, Serialize, Deserialize)]
299struct MasksMeta {
300    default_task: String,
301    masks: Vec<MaskMeta>,
302}
303
304#[derive(Debug, Clone, Serialize, Deserialize)]
305struct MaskMeta {
306    task_id: u32,
307    name: String,
308    #[serde(default)]
309    description: Option<String>,
310    sparsity: f32,
311    #[serde(default)]
312    quality: Option<Quality>,
313    #[serde(default)]
314    parent: Option<String>,
315    priority: MaskPriority,
316    #[serde(default)]
317    has_hot_pack: bool,
318    /// Blob offset relative to the start of the masks section.
319    blob_off: u64,
320    blob_len: u64,
321}
322
323/// Encode a catalog into the binary masks section:
324/// `[u32 n_masks][u32 meta_len][meta JSON][blobs, each 8-aligned]`.
325/// Blob: `[n_layers × ffn_bytes][n_layers × head_bytes][gates_bytes]`.
326pub fn encode_masks_section(catalog: &MaskCatalog, arch: &ModelArch) -> Result<Vec<u8>, String> {
327    let ffn_b = arch.ffn_mask_bytes();
328    let head_b = arch.head_mask_bytes();
329    let gates_b = arch.gates_mask_bytes();
330    let blob_len = arch.mask_blob_len();
331
332    // Build blobs first to know sizes (all blobs are equal-length by arch).
333    let mut blobs: Vec<Vec<u8>> = Vec::with_capacity(catalog.masks.len());
334    for m in &catalog.masks {
335        let mut blob = Vec::with_capacity(blob_len);
336        for li in 0..arch.num_layers {
337            let mut row = vec![0u8; ffn_b];
338            if let Some(src) = m.ffn_masks.get(li) {
339                let n = src.len().min(ffn_b);
340                row[..n].copy_from_slice(&src[..n]);
341            }
342            zero_tail_bits(&mut row, arch.intermediate_size);
343            blob.extend_from_slice(&row);
344        }
345        for li in 0..arch.num_layers {
346            let mut row = vec![0u8; head_b];
347            if let Some(src) = m.head_masks.get(li) {
348                let n = src.len().min(head_b);
349                row[..n].copy_from_slice(&src[..n]);
350            }
351            zero_tail_bits(&mut row, arch.num_attention_heads);
352            blob.extend_from_slice(&row);
353        }
354        let mut gates = vec![0u8; gates_b];
355        for li in 0..arch.num_layers {
356            if m.layer_alive(li) {
357                gates[li / 8] |= 1 << (li % 8);
358            }
359        }
360        blob.extend_from_slice(&gates);
361        debug_assert_eq!(blob.len(), blob_len);
362        blobs.push(blob);
363    }
364
365    // Two-pass meta serialization is fragile (JSON length depends on
366    // offsets). Instead: compute meta with placeholder offsets of the
367    // final width by serializing once, then patching is avoided by
368    // computing the blobs area start from the meta length iteratively.
369    let build_meta = |blobs_start: u64| -> MasksMeta {
370        let mut metas = Vec::with_capacity(catalog.masks.len());
371        let mut off = blobs_start;
372        for m in &catalog.masks {
373            off = (off + 7) / 8 * 8; // 8-align each blob
374            metas.push(MaskMeta {
375                task_id: m.task_id,
376                name: m.name.clone(),
377                description: m.description.clone(),
378                sparsity: m.sparsity,
379                quality: m.quality.clone(),
380                parent: m.parent.clone(),
381                priority: m.priority,
382                has_hot_pack: m.has_hot_pack,
383                blob_off: off,
384                blob_len: blob_len as u64,
385            });
386            off += blob_len as u64;
387        }
388        MasksMeta {
389            default_task: catalog.default_task.clone(),
390            masks: metas,
391        }
392    };
393
394    // Iterate until meta length stabilizes (offsets can change digit count).
395    let mut meta_len = 0usize;
396    let mut meta_json;
397    loop {
398        let blobs_start = 8 + meta_len as u64;
399        meta_json = serde_json::to_vec(&build_meta(blobs_start))
400            .map_err(|e| format!("serialize masks meta: {e}"))?;
401        if meta_json.len() == meta_len {
402            break;
403        }
404        meta_len = meta_json.len();
405    }
406
407    let meta = build_meta(8 + meta_len as u64);
408    let mut out = Vec::new();
409    out.extend_from_slice(&(catalog.masks.len() as u32).to_le_bytes());
410    out.extend_from_slice(&(meta_len as u32).to_le_bytes());
411    out.extend_from_slice(&meta_json);
412    for (mm, blob) in meta.masks.iter().zip(&blobs) {
413        while (out.len() as u64) < mm.blob_off {
414            out.push(0);
415        }
416        debug_assert_eq!(out.len() as u64, mm.blob_off);
417        out.extend_from_slice(blob);
418    }
419    Ok(out)
420}
421
422/// Decode the binary masks section into a catalog.
423pub fn decode_masks_section(bytes: &[u8], arch: &ModelArch) -> Result<MaskCatalog, String> {
424    if bytes.len() < 8 {
425        return Err("masks section too short".into());
426    }
427    let n_masks = u32::from_le_bytes(bytes[0..4].try_into().unwrap()) as usize;
428    let meta_len = u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize;
429    if 8 + meta_len > bytes.len() {
430        return Err("masks meta out of bounds".into());
431    }
432    let meta: MasksMeta = serde_json::from_slice(&bytes[8..8 + meta_len])
433        .map_err(|e| format!("masks meta JSON: {e}"))?;
434    if meta.masks.len() != n_masks {
435        return Err(format!(
436            "masks count mismatch: envelope {} vs meta {}",
437            n_masks,
438            meta.masks.len()
439        ));
440    }
441
442    let ffn_b = arch.ffn_mask_bytes();
443    let head_b = arch.head_mask_bytes();
444    let expected_blob = arch.mask_blob_len() as u64;
445
446    let mut masks = Vec::with_capacity(n_masks);
447    for mm in &meta.masks {
448        if mm.blob_len != expected_blob {
449            return Err(format!(
450                "mask '{}': blob_len {} != expected {} for arch",
451                mm.name, mm.blob_len, expected_blob
452            ));
453        }
454        let start = mm.blob_off as usize;
455        let end = start + mm.blob_len as usize;
456        if end > bytes.len() {
457            return Err(format!("mask '{}': blob out of bounds", mm.name));
458        }
459        let blob = &bytes[start..end];
460
461        let mut ffn_masks = Vec::with_capacity(arch.num_layers);
462        for li in 0..arch.num_layers {
463            ffn_masks.push(blob[li * ffn_b..(li + 1) * ffn_b].to_vec());
464        }
465        let heads_base = arch.num_layers * ffn_b;
466        let mut head_masks = Vec::with_capacity(arch.num_layers);
467        for li in 0..arch.num_layers {
468            head_masks.push(blob[heads_base + li * head_b..heads_base + (li + 1) * head_b].to_vec());
469        }
470        let gates_base = heads_base + arch.num_layers * head_b;
471        let gates = &blob[gates_base..];
472        let layer_gates: Vec<bool> = (0..arch.num_layers)
473            .map(|li| gates[li / 8] & (1 << (li % 8)) != 0)
474            .collect();
475
476        masks.push(TaskMask {
477            task_id: mm.task_id,
478            name: mm.name.clone(),
479            description: mm.description.clone(),
480            sparsity: mm.sparsity,
481            quality: mm.quality.clone(),
482            ffn_masks,
483            head_masks,
484            layer_gates,
485            parent: mm.parent.clone(),
486            has_hot_pack: mm.has_hot_pack,
487            priority: mm.priority,
488        });
489    }
490
491    Ok(MaskCatalog {
492        masks,
493        default_task: meta.default_task,
494    })
495}