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