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    /// Per-layer MoE expert masks (bitfield: 1 = routable). Empty =
48    /// no expert restriction (all experts routable) — the state of
49    /// every pre-expert-mask file. Spec §5: an optional area after
50    /// the layer gates, present when the meta flag is set.
51    #[serde(default)]
52    pub expert_masks: Vec<Vec<u8>>,
53    /// Parent mask name (for delta-coded masks)
54    pub parent: Option<String>,
55    /// Whether this mask has a precompiled hot-pack
56    pub has_hot_pack: bool,
57    /// Priority level for this mask
58    pub priority: MaskPriority,
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
62pub enum MaskPriority {
63    Fallback,
64    Normal,
65    Primary,
66}
67
68impl TaskMask {
69    /// Count active neurons in a specific layer's FFN.
70    pub fn ffn_active_count(&self, layer_idx: usize) -> usize {
71        self.ffn_masks
72            .get(layer_idx)
73            .map(|m| m.iter().map(|b| b.count_ones() as usize).sum())
74            .unwrap_or(0)
75    }
76
77    /// Check if a specific layer is alive (not pruned).
78    pub fn layer_alive(&self, layer_idx: usize) -> bool {
79        self.layer_gates.get(layer_idx).copied().unwrap_or(false)
80    }
81
82    /// Count total active layers.
83    pub fn active_layer_count(&self) -> usize {
84        self.layer_gates.iter().filter(|&&alive| alive).count()
85    }
86
87    /// Get active neuron indices for a layer (for sparse gather).
88    pub fn ffn_active_indices(&self, layer_idx: usize) -> Vec<u16> {
89        let Some(mask) = self.ffn_masks.get(layer_idx) else {
90            return vec![];
91        };
92        let mut indices = Vec::new();
93        for (byte_idx, &byte) in mask.iter().enumerate() {
94            for bit in 0..8 {
95                if byte & (1 << bit) != 0 {
96                    indices.push((byte_idx * 8 + bit) as u16);
97                }
98            }
99        }
100        indices
101    }
102
103    /// Count active attention heads in a layer.
104    pub fn active_head_count(&self, layer_idx: usize) -> usize {
105        self.head_masks
106            .get(layer_idx)
107            .map(|m| m.iter().map(|b| b.count_ones() as usize).sum())
108            .unwrap_or(0)
109    }
110
111    /// Expert routability flags for a layer (true = routable), or None
112    /// when the mask carries no expert restriction.
113    pub fn expert_flags(&self, layer_idx: usize, num_experts: usize) -> Option<Vec<bool>> {
114        let mask = self.expert_masks.get(layer_idx)?;
115        if mask.is_empty() {
116            return None;
117        }
118        Some(
119            (0..num_experts)
120                .map(|e| mask.get(e / 8).map(|b| b & (1 << (e % 8)) != 0).unwrap_or(false))
121                .collect(),
122        )
123    }
124
125    /// Active head flags for a layer (true = head is alive).
126    pub fn head_flags(&self, layer_idx: usize, num_heads: usize) -> Vec<bool> {
127        let mut flags = vec![true; num_heads];
128        if let Some(mask) = self.head_masks.get(layer_idx) {
129            for (h, flag) in flags.iter_mut().enumerate() {
130                *flag = mask
131                    .get(h / 8)
132                    .map(|b| b & (1 << (h % 8)) != 0)
133                    .unwrap_or(false);
134            }
135        }
136        flags
137    }
138
139    /// Average active neurons across all alive layers.
140    pub fn avg_active_neurons(&self) -> f64 {
141        let alive_layers: Vec<_> = (0..self.layer_gates.len())
142            .filter(|&i| self.layer_alive(i))
143            .collect();
144        if alive_layers.is_empty() {
145            return 0.0;
146        }
147        let total: usize = alive_layers.iter().map(|&i| self.ffn_active_count(i)).sum();
148        total as f64 / alive_layers.len() as f64
149    }
150
151    /// Compute union of two masks (more neurons = higher quality, less speed).
152    pub fn union(&self, other: &TaskMask) -> TaskMask {
153        let mut result = self.clone();
154        result.name = format!("{}+{}", self.name, other.name);
155        result.task_id = u32::MAX; // composite
156        result.parent = None;
157        result.has_hot_pack = false;
158        result.quality = None; // union quality is not measured
159
160        for (li, gate) in result.layer_gates.iter_mut().enumerate() {
161            *gate = self.layer_alive(li) || other.layer_alive(li);
162        }
163
164        for (li, mask) in result.ffn_masks.iter_mut().enumerate() {
165            if let Some(om) = other.ffn_masks.get(li) {
166                for (byte, &ob) in mask.iter_mut().zip(om) {
167                    *byte |= ob;
168                }
169            }
170        }
171
172        for (li, mask) in result.head_masks.iter_mut().enumerate() {
173            if let Some(om) = other.head_masks.get(li) {
174                for (byte, &ob) in mask.iter_mut().zip(om) {
175                    *byte |= ob;
176                }
177            }
178        }
179
180        // Expert fields: empty = unrestricted, and unrestricted wins a
181        // union; otherwise OR the routable sets.
182        if result.expert_masks.is_empty() || other.expert_masks.is_empty() {
183            result.expert_masks = Vec::new();
184        } else {
185            for (li, mask) in result.expert_masks.iter_mut().enumerate() {
186                match other.expert_masks.get(li) {
187                    Some(om) if !om.is_empty() && !mask.is_empty() => {
188                        for (byte, &ob) in mask.iter_mut().zip(om) {
189                            *byte |= ob;
190                        }
191                    }
192                    _ => mask.clear(),
193                }
194            }
195        }
196
197        // Recalculate sparsity
198        let total_neurons: usize = result.ffn_masks.iter().map(|m| m.len() * 8).sum();
199        let active: usize = (0..result.layer_gates.len())
200            .map(|i| result.ffn_active_count(i))
201            .sum();
202        result.sparsity = 1.0 - (active as f32 / total_neurons.max(1) as f32);
203
204        result
205    }
206
207    /// Bitwise diff between current and new mask (for hot-swap).
208    /// Compares the actual bits (XOR), not per-layer counters: two masks
209    /// with equal counts but different neurons produce a full delta.
210    pub fn diff(&self, other: &TaskMask) -> MaskDiff {
211        let n_layers = self.layer_gates.len().max(other.layer_gates.len());
212        let mut changed_layers = Vec::new();
213        let mut neurons_added = 0usize;
214        let mut neurons_removed = 0usize;
215        let mut ffn_delta = Vec::with_capacity(n_layers);
216
217        let empty: Vec<u8> = Vec::new();
218        for li in 0..n_layers {
219            let a = self.ffn_masks.get(li).unwrap_or(&empty);
220            let b = other.ffn_masks.get(li).unwrap_or(&empty);
221            let len = a.len().max(b.len());
222            let mut delta = vec![0u8; len];
223            let mut layer_changed = self.layer_alive(li) != other.layer_alive(li);
224
225            for (bi, d) in delta.iter_mut().enumerate() {
226                let av = a.get(bi).copied().unwrap_or(0);
227                let bv = b.get(bi).copied().unwrap_or(0);
228                let x = av ^ bv;
229                *d = x;
230                if x != 0 {
231                    layer_changed = true;
232                    neurons_added += (bv & !av).count_ones() as usize;
233                    neurons_removed += (av & !bv).count_ones() as usize;
234                }
235            }
236
237            // Head bits count toward "changed" too.
238            let ha = self.head_masks.get(li).unwrap_or(&empty);
239            let hb = other.head_masks.get(li).unwrap_or(&empty);
240            if ha.len() != hb.len() || ha.iter().zip(hb).any(|(x, y)| x != y) {
241                layer_changed = true;
242            }
243
244            if layer_changed {
245                changed_layers.push(li);
246            }
247            ffn_delta.push(delta);
248        }
249
250        MaskDiff {
251            changed_layers,
252            neurons_added,
253            neurons_removed,
254            ffn_delta,
255        }
256    }
257
258    /// Zero tail bits beyond the real dimensions (defensive normalization).
259    pub fn normalize_tail_bits(&mut self, arch: &ModelArch) {
260        for row in &mut self.ffn_masks {
261            zero_tail_bits(row, arch.intermediate_size);
262        }
263        for row in &mut self.head_masks {
264            zero_tail_bits(row, arch.num_attention_heads);
265        }
266    }
267}
268
269/// Zero all bits at positions >= `n_bits` in a bitfield.
270pub fn zero_tail_bits(bits: &mut [u8], n_bits: usize) {
271    let full_bytes = n_bits / 8;
272    let rem = n_bits % 8;
273    if full_bytes < bits.len() {
274        if rem > 0 {
275            bits[full_bytes] &= (1u8 << rem) - 1;
276            for b in &mut bits[full_bytes + 1..] {
277                *b = 0;
278            }
279        } else {
280            for b in &mut bits[full_bytes..] {
281                *b = 0;
282            }
283        }
284    }
285}
286
287/// Result of diffing two masks (used for efficient hot-swap).
288#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct MaskDiff {
290    pub changed_layers: Vec<usize>,
291    pub neurons_added: usize,
292    pub neurons_removed: usize,
293    /// Per-layer XOR bitfields — exactly which neurons flipped.
294    #[serde(skip)]
295    pub ffn_delta: Vec<Vec<u8>>,
296}
297
298/// Catalog of all masks in a CMF model file.
299#[derive(Debug, Clone, Serialize, Deserialize)]
300pub struct MaskCatalog {
301    pub masks: Vec<TaskMask>,
302    pub default_task: String,
303}
304
305impl MaskCatalog {
306    pub fn empty() -> Self {
307        Self {
308            masks: vec![],
309            default_task: "general".to_string(),
310        }
311    }
312
313    /// Find mask by name.
314    pub fn get(&self, name: &str) -> Option<&TaskMask> {
315        self.masks.iter().find(|m| m.name == name)
316    }
317
318    /// Get fallback mask.
319    pub fn fallback(&self) -> Option<&TaskMask> {
320        self.masks
321            .iter()
322            .find(|m| m.priority == MaskPriority::Fallback)
323            .or(self.masks.first())
324    }
325
326    /// List all task names.
327    pub fn task_names(&self) -> Vec<&str> {
328        self.masks.iter().map(|m| m.name.as_str()).collect()
329    }
330}
331
332// ───────────────────── binary masks section (§5 of the spec) ─────────────────────
333
334/// JSON metadata part of the masks section.
335#[derive(Debug, Clone, Serialize, Deserialize)]
336struct MasksMeta {
337    default_task: String,
338    masks: Vec<MaskMeta>,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize)]
342struct MaskMeta {
343    task_id: u32,
344    name: String,
345    #[serde(default)]
346    description: Option<String>,
347    sparsity: f32,
348    #[serde(default)]
349    quality: Option<Quality>,
350    #[serde(default)]
351    parent: Option<String>,
352    priority: MaskPriority,
353    #[serde(default)]
354    has_hot_pack: bool,
355    /// Spec §5: the blob carries the optional per-layer MoE-expert
356    /// bitfield area after the layer gates. Old readers that ignore
357    /// this flag simply never look past the gates — additive.
358    #[serde(default)]
359    has_expert_fields: bool,
360    /// Blob offset relative to the start of the masks section.
361    blob_off: u64,
362    blob_len: u64,
363}
364
365/// Encode a catalog into the binary masks section:
366/// `[u32 n_masks][u32 meta_len][meta JSON][blobs, each 8-aligned]`.
367/// Blob: `[n_layers × ffn_bytes][n_layers × head_bytes][gates_bytes]`.
368pub fn encode_masks_section(catalog: &MaskCatalog, arch: &ModelArch) -> Result<Vec<u8>, String> {
369    let ffn_b = arch.ffn_mask_bytes();
370    let head_b = arch.head_mask_bytes();
371    let gates_b = arch.gates_mask_bytes();
372    let expert_b = arch.expert_mask_bytes();
373    let blob_len = arch.mask_blob_len();
374
375    // Build blobs first to know sizes. Blob lengths are per-mask now:
376    // base, or base + the optional expert area (spec §5).
377    let mut blobs: Vec<Vec<u8>> = Vec::with_capacity(catalog.masks.len());
378    for m in &catalog.masks {
379        let has_experts = expert_b > 0 && m.expert_masks.iter().any(|e| !e.is_empty());
380        let mut blob = Vec::with_capacity(if has_experts {
381            arch.mask_blob_len_with_experts()
382        } else {
383            blob_len
384        });
385        for li in 0..arch.num_layers {
386            let mut row = vec![0u8; ffn_b];
387            if let Some(src) = m.ffn_masks.get(li) {
388                let n = src.len().min(ffn_b);
389                row[..n].copy_from_slice(&src[..n]);
390            }
391            zero_tail_bits(&mut row, arch.intermediate_size);
392            blob.extend_from_slice(&row);
393        }
394        for li in 0..arch.num_layers {
395            let mut row = vec![0u8; head_b];
396            if let Some(src) = m.head_masks.get(li) {
397                let n = src.len().min(head_b);
398                row[..n].copy_from_slice(&src[..n]);
399            }
400            zero_tail_bits(&mut row, arch.num_attention_heads);
401            blob.extend_from_slice(&row);
402        }
403        let mut gates = vec![0u8; gates_b];
404        for li in 0..arch.num_layers {
405            if m.layer_alive(li) {
406                gates[li / 8] |= 1 << (li % 8);
407            }
408        }
409        blob.extend_from_slice(&gates);
410        if has_experts {
411            let ne = arch.moe.as_ref().map(|c| c.num_experts).unwrap_or(0);
412            for li in 0..arch.num_layers {
413                let mut row = vec![0u8; expert_b];
414                match m.expert_masks.get(li) {
415                    Some(src) if !src.is_empty() => {
416                        let n = src.len().min(expert_b);
417                        row[..n].copy_from_slice(&src[..n]);
418                        zero_tail_bits(&mut row, ne);
419                    }
420                    // A layer with no restriction inside a masked file:
421                    // all experts routable (all-ones up to num_experts).
422                    _ => {
423                        for e in 0..ne {
424                            row[e / 8] |= 1 << (e % 8);
425                        }
426                    }
427                }
428                blob.extend_from_slice(&row);
429            }
430        }
431        blobs.push(blob);
432    }
433
434    // Two-pass meta serialization is fragile (JSON length depends on
435    // offsets). Instead: compute meta with placeholder offsets of the
436    // final width by serializing once, then patching is avoided by
437    // computing the blobs area start from the meta length iteratively.
438    let build_meta = |blobs_start: u64| -> MasksMeta {
439        let mut metas = Vec::with_capacity(catalog.masks.len());
440        let mut off = blobs_start;
441        for (m, blob) in catalog.masks.iter().zip(&blobs) {
442            off = off.div_ceil(8) * 8; // 8-align each blob
443            metas.push(MaskMeta {
444                task_id: m.task_id,
445                name: m.name.clone(),
446                description: m.description.clone(),
447                sparsity: m.sparsity,
448                quality: m.quality.clone(),
449                parent: m.parent.clone(),
450                priority: m.priority,
451                has_hot_pack: m.has_hot_pack,
452                has_expert_fields: expert_b > 0 && blob.len() > blob_len,
453                blob_off: off,
454                blob_len: blob.len() as u64,
455            });
456            off += blob.len() as u64;
457        }
458        MasksMeta {
459            default_task: catalog.default_task.clone(),
460            masks: metas,
461        }
462    };
463
464    // Iterate until meta length stabilizes (offsets can change digit count).
465    let mut meta_len = 0usize;
466    let mut meta_json;
467    loop {
468        let blobs_start = 8 + meta_len as u64;
469        meta_json = serde_json::to_vec(&build_meta(blobs_start))
470            .map_err(|e| format!("serialize masks meta: {e}"))?;
471        if meta_json.len() == meta_len {
472            break;
473        }
474        meta_len = meta_json.len();
475    }
476
477    let meta = build_meta(8 + meta_len as u64);
478    let mut out = Vec::new();
479    out.extend_from_slice(&(catalog.masks.len() as u32).to_le_bytes());
480    out.extend_from_slice(&(meta_len as u32).to_le_bytes());
481    out.extend_from_slice(&meta_json);
482    for (mm, blob) in meta.masks.iter().zip(&blobs) {
483        while (out.len() as u64) < mm.blob_off {
484            out.push(0);
485        }
486        debug_assert_eq!(out.len() as u64, mm.blob_off);
487        out.extend_from_slice(blob);
488    }
489    Ok(out)
490}
491
492/// Decode the binary masks section into a catalog.
493pub fn decode_masks_section(bytes: &[u8], arch: &ModelArch) -> Result<MaskCatalog, String> {
494    if bytes.len() < 8 {
495        return Err("masks section too short".into());
496    }
497    let n_masks = u32::from_le_bytes(bytes[0..4].try_into().unwrap()) as usize;
498    let meta_len = u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize;
499    if 8 + meta_len > bytes.len() {
500        return Err("masks meta out of bounds".into());
501    }
502    let meta: MasksMeta = serde_json::from_slice(&bytes[8..8 + meta_len])
503        .map_err(|e| format!("masks meta JSON: {e}"))?;
504    if meta.masks.len() != n_masks {
505        return Err(format!(
506            "masks count mismatch: envelope {} vs meta {}",
507            n_masks,
508            meta.masks.len()
509        ));
510    }
511
512    let ffn_b = arch.ffn_mask_bytes();
513    let head_b = arch.head_mask_bytes();
514    let expert_b = arch.expert_mask_bytes();
515    let expected_blob = arch.mask_blob_len() as u64;
516    let expected_with_experts = arch.mask_blob_len_with_experts() as u64;
517
518    let mut masks = Vec::with_capacity(n_masks);
519    for mm in &meta.masks {
520        let expected = if mm.has_expert_fields {
521            expected_with_experts
522        } else {
523            expected_blob
524        };
525        if mm.has_expert_fields && expert_b == 0 {
526            return Err(format!(
527                "mask '{}': expert fields flagged but the arch has no MoE block",
528                mm.name
529            ));
530        }
531        if mm.blob_len != expected {
532            return Err(format!(
533                "mask '{}': blob_len {} != expected {} for arch",
534                mm.name, mm.blob_len, expected
535            ));
536        }
537        let start = usize::try_from(mm.blob_off)
538            .map_err(|_| format!("mask '{}': blob offset does not fit usize", mm.name))?;
539        let blob_len = usize::try_from(mm.blob_len)
540            .map_err(|_| format!("mask '{}': blob length does not fit usize", mm.name))?;
541        let end = start
542            .checked_add(blob_len)
543            .ok_or_else(|| format!("mask '{}': blob range overflows", mm.name))?;
544        if end > bytes.len() {
545            return Err(format!("mask '{}': blob out of bounds", mm.name));
546        }
547        let blob = &bytes[start..end];
548
549        let mut ffn_masks = Vec::with_capacity(arch.num_layers);
550        for li in 0..arch.num_layers {
551            ffn_masks.push(blob[li * ffn_b..(li + 1) * ffn_b].to_vec());
552        }
553        let heads_base = arch.num_layers * ffn_b;
554        let mut head_masks = Vec::with_capacity(arch.num_layers);
555        for li in 0..arch.num_layers {
556            head_masks
557                .push(blob[heads_base + li * head_b..heads_base + (li + 1) * head_b].to_vec());
558        }
559        let gates_base = heads_base + arch.num_layers * head_b;
560        let gates = &blob[gates_base..];
561        let layer_gates: Vec<bool> = (0..arch.num_layers)
562            .map(|li| gates[li / 8] & (1 << (li % 8)) != 0)
563            .collect();
564        let expert_masks: Vec<Vec<u8>> = if mm.has_expert_fields {
565            let base = gates_base + arch.gates_mask_bytes();
566            (0..arch.num_layers)
567                .map(|li| blob[base + li * expert_b..base + (li + 1) * expert_b].to_vec())
568                .collect()
569        } else {
570            Vec::new()
571        };
572
573        masks.push(TaskMask {
574            task_id: mm.task_id,
575            name: mm.name.clone(),
576            description: mm.description.clone(),
577            sparsity: mm.sparsity,
578            quality: mm.quality.clone(),
579            ffn_masks,
580            head_masks,
581            layer_gates,
582            expert_masks,
583            parent: mm.parent.clone(),
584            has_hot_pack: mm.has_hot_pack,
585            priority: mm.priority,
586        });
587    }
588
589    Ok(MaskCatalog {
590        masks,
591        default_task: meta.default_task,
592    })
593}