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