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    // A Looped Transformer stores one FFN mask row per VIRTUAL layer
374    // (physical × loops, ordered pass-major: pass 0's layers, then pass
375    // 1's), because the runtime indexes masks by the virtual layer and
376    // the passes are different computations sharing one set of weights.
377    // Heads and gates stay per physical layer. Unlooped: rows == layers,
378    // byte-identical to the legacy layout.
379    let ffn_rows = arch.num_layers * arch.num_loops.max(1);
380    let blob_len = ffn_rows * ffn_b + arch.num_layers * head_b + gates_b;
381
382    // Build blobs first to know sizes. Blob lengths are per-mask now:
383    // base, or base + the optional expert area (spec §5).
384    let mut blobs: Vec<Vec<u8>> = Vec::with_capacity(catalog.masks.len());
385    for m in &catalog.masks {
386        let has_experts = expert_b > 0 && m.expert_masks.iter().any(|e| !e.is_empty());
387        let mut blob = Vec::with_capacity(if has_experts {
388            arch.mask_blob_len_with_experts()
389        } else {
390            blob_len
391        });
392        for vl in 0..ffn_rows {
393            // A caller that built per-physical masks (every pre-loop
394            // writer) gets them replicated to every pass — the exact
395            // semantics its mask had before this area existed.
396            let src_i = if m.ffn_masks.len() >= ffn_rows {
397                vl
398            } else {
399                vl % arch.num_layers.max(1)
400            };
401            let mut row = vec![0u8; ffn_b];
402            if let Some(src) = m.ffn_masks.get(src_i) {
403                let n = src.len().min(ffn_b);
404                row[..n].copy_from_slice(&src[..n]);
405            }
406            zero_tail_bits(&mut row, arch.intermediate_size);
407            blob.extend_from_slice(&row);
408        }
409        for li in 0..arch.num_layers {
410            let mut row = vec![0u8; head_b];
411            if let Some(src) = m.head_masks.get(li) {
412                let n = src.len().min(head_b);
413                row[..n].copy_from_slice(&src[..n]);
414            }
415            zero_tail_bits(&mut row, arch.num_attention_heads);
416            blob.extend_from_slice(&row);
417        }
418        let mut gates = vec![0u8; gates_b];
419        for li in 0..arch.num_layers {
420            if m.layer_alive(li) {
421                gates[li / 8] |= 1 << (li % 8);
422            }
423        }
424        blob.extend_from_slice(&gates);
425        if has_experts {
426            let ne = arch.moe.as_ref().map(|c| c.num_experts).unwrap_or(0);
427            for li in 0..arch.num_layers {
428                let mut row = vec![0u8; expert_b];
429                match m.expert_masks.get(li) {
430                    Some(src) if !src.is_empty() => {
431                        let n = src.len().min(expert_b);
432                        row[..n].copy_from_slice(&src[..n]);
433                        zero_tail_bits(&mut row, ne);
434                    }
435                    // A layer with no restriction inside a masked file:
436                    // all experts routable (all-ones up to num_experts).
437                    _ => {
438                        for e in 0..ne {
439                            row[e / 8] |= 1 << (e % 8);
440                        }
441                    }
442                }
443                blob.extend_from_slice(&row);
444            }
445        }
446        blobs.push(blob);
447    }
448
449    // Two-pass meta serialization is fragile (JSON length depends on
450    // offsets). Instead: compute meta with placeholder offsets of the
451    // final width by serializing once, then patching is avoided by
452    // computing the blobs area start from the meta length iteratively.
453    let build_meta = |blobs_start: u64| -> MasksMeta {
454        let mut metas = Vec::with_capacity(catalog.masks.len());
455        let mut off = blobs_start;
456        for (m, blob) in catalog.masks.iter().zip(&blobs) {
457            off = off.div_ceil(8) * 8; // 8-align each blob
458            metas.push(MaskMeta {
459                task_id: m.task_id,
460                name: m.name.clone(),
461                description: m.description.clone(),
462                sparsity: m.sparsity,
463                quality: m.quality.clone(),
464                parent: m.parent.clone(),
465                priority: m.priority,
466                has_hot_pack: m.has_hot_pack,
467                has_expert_fields: expert_b > 0 && blob.len() > blob_len,
468                blob_off: off,
469                blob_len: blob.len() as u64,
470            });
471            off += blob.len() as u64;
472        }
473        MasksMeta {
474            default_task: catalog.default_task.clone(),
475            masks: metas,
476        }
477    };
478
479    // Iterate until meta length stabilizes (offsets can change digit count).
480    let mut meta_len = 0usize;
481    let mut meta_json;
482    loop {
483        let blobs_start = 8 + meta_len as u64;
484        meta_json = serde_json::to_vec(&build_meta(blobs_start))
485            .map_err(|e| format!("serialize masks meta: {e}"))?;
486        if meta_json.len() == meta_len {
487            break;
488        }
489        meta_len = meta_json.len();
490    }
491
492    let meta = build_meta(8 + meta_len as u64);
493    let mut out = Vec::new();
494    out.extend_from_slice(&(catalog.masks.len() as u32).to_le_bytes());
495    out.extend_from_slice(&(meta_len as u32).to_le_bytes());
496    out.extend_from_slice(&meta_json);
497    for (mm, blob) in meta.masks.iter().zip(&blobs) {
498        while (out.len() as u64) < mm.blob_off {
499            out.push(0);
500        }
501        debug_assert_eq!(out.len() as u64, mm.blob_off);
502        out.extend_from_slice(blob);
503    }
504    Ok(out)
505}
506
507/// Decode the binary masks section into a catalog.
508pub fn decode_masks_section(bytes: &[u8], arch: &ModelArch) -> Result<MaskCatalog, String> {
509    if bytes.len() < 8 {
510        return Err("masks section too short".into());
511    }
512    let n_masks = u32::from_le_bytes(bytes[0..4].try_into().unwrap()) as usize;
513    let meta_len = u32::from_le_bytes(bytes[4..8].try_into().unwrap()) as usize;
514    if 8 + meta_len > bytes.len() {
515        return Err("masks meta out of bounds".into());
516    }
517    let meta: MasksMeta = serde_json::from_slice(&bytes[8..8 + meta_len])
518        .map_err(|e| format!("masks meta JSON: {e}"))?;
519    if meta.masks.len() != n_masks {
520        return Err(format!(
521            "masks count mismatch: envelope {} vs meta {}",
522            n_masks,
523            meta.masks.len()
524        ));
525    }
526
527    let ffn_b = arch.ffn_mask_bytes();
528    let head_b = arch.head_mask_bytes();
529    let expert_b = arch.expert_mask_bytes();
530    // Two acceptable layouts, told apart by the stored blob_len: the
531    // legacy one with one FFN row per PHYSICAL layer, and the loop one
532    // with one per VIRTUAL layer. Unlooped models: identical.
533    let vrows = arch.num_layers * arch.num_loops.max(1);
534    let legacy_blob = arch.mask_blob_len() as u64;
535    let legacy_with_experts = arch.mask_blob_len_with_experts() as u64;
536    let loop_blob =
537        (vrows * ffn_b + arch.num_layers * head_b + arch.gates_mask_bytes()) as u64;
538    let loop_with_experts = loop_blob + (arch.num_layers * expert_b) as u64;
539
540    let mut masks = Vec::with_capacity(n_masks);
541    for mm in &meta.masks {
542        let (expected, ffn_rows) = if mm.has_expert_fields {
543            if mm.blob_len == loop_with_experts {
544                (loop_with_experts, vrows)
545            } else {
546                (legacy_with_experts, arch.num_layers)
547            }
548        } else if mm.blob_len == loop_blob {
549            (loop_blob, vrows)
550        } else {
551            (legacy_blob, arch.num_layers)
552        };
553        if mm.has_expert_fields && expert_b == 0 {
554            return Err(format!(
555                "mask '{}': expert fields flagged but the arch has no MoE block",
556                mm.name
557            ));
558        }
559        if mm.blob_len != expected {
560            return Err(format!(
561                "mask '{}': blob_len {} != expected {} for arch",
562                mm.name, mm.blob_len, expected
563            ));
564        }
565        let start = usize::try_from(mm.blob_off)
566            .map_err(|_| format!("mask '{}': blob offset does not fit usize", mm.name))?;
567        let blob_len = usize::try_from(mm.blob_len)
568            .map_err(|_| format!("mask '{}': blob length does not fit usize", mm.name))?;
569        let end = start
570            .checked_add(blob_len)
571            .ok_or_else(|| format!("mask '{}': blob range overflows", mm.name))?;
572        if end > bytes.len() {
573            return Err(format!("mask '{}': blob out of bounds", mm.name));
574        }
575        let blob = &bytes[start..end];
576
577        let mut ffn_masks = Vec::with_capacity(vrows);
578        for li in 0..ffn_rows {
579            ffn_masks.push(blob[li * ffn_b..(li + 1) * ffn_b].to_vec());
580        }
581        // A legacy mask on a looped model: replicate each physical row
582        // to every pass. Without this, the runtime — which indexes masks
583        // by the VIRTUAL layer — finds nothing past the first pass,
584        // `ffn_active_count` answers 0, and the sparse path silently
585        // zeroes the entire second pass's FFN output.
586        if ffn_rows < vrows {
587            ffn_masks = (0..vrows)
588                .map(|vl| ffn_masks[vl % arch.num_layers.max(1)].clone())
589                .collect();
590        }
591        let heads_base = ffn_rows * ffn_b;
592        let mut head_masks = Vec::with_capacity(arch.num_layers);
593        for li in 0..arch.num_layers {
594            head_masks
595                .push(blob[heads_base + li * head_b..heads_base + (li + 1) * head_b].to_vec());
596        }
597        let gates_base = heads_base + arch.num_layers * head_b;
598        let gates = &blob[gates_base..];
599        let layer_gates: Vec<bool> = (0..arch.num_layers)
600            .map(|li| gates[li / 8] & (1 << (li % 8)) != 0)
601            .collect();
602        let expert_masks: Vec<Vec<u8>> = if mm.has_expert_fields {
603            let base = gates_base + arch.gates_mask_bytes();
604            (0..arch.num_layers)
605                .map(|li| blob[base + li * expert_b..base + (li + 1) * expert_b].to_vec())
606                .collect()
607        } else {
608            Vec::new()
609        };
610
611        masks.push(TaskMask {
612            task_id: mm.task_id,
613            name: mm.name.clone(),
614            description: mm.description.clone(),
615            sparsity: mm.sparsity,
616            quality: mm.quality.clone(),
617            ffn_masks,
618            head_masks,
619            layer_gates,
620            expert_masks,
621            parent: mm.parent.clone(),
622            has_hot_pack: mm.has_hot_pack,
623            priority: mm.priority,
624        });
625    }
626
627    Ok(MaskCatalog {
628        masks,
629        default_task: meta.default_task,
630    })
631}