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