Skip to main content

cortiq_core/
format.rs

1//! CMF v2 binary container — envelope, tensor directory, mmap access.
2//!
3//! See `docs/CMF_V2_SPEC.md`. Layout summary:
4//!
5//! ```text
6//! [0x00]  magic "CMF\x01" | version u32 = 2 | flags u32 | required_features u32
7//! [0x10]  header_off/len | dir_off/len | data_off/len   (u64 LE each)
8//! [0x40]  masks_off/len  | vocab_off/len | index_off/len
9//! [0x70]  16 reserved bytes (zero)
10//! [0x80]  header JSON → tensor directory → weight blob (4096-aligned,
11//!         tensors 64-aligned) → masks → vocab → sparse index
12//! ```
13//!
14//! The tensor directory is the ONLY source of truth for the weight blob
15//! layout — there is no computable layout, by design (v1 bug class #1).
16//! Every validation failure is a hard error: no silent fallbacks.
17
18use crate::hash::hash64;
19use crate::mask::{decode_masks_section, encode_masks_section, MaskCatalog, TaskMask};
20use crate::quant::expected_nbytes;
21use crate::types::{ModelArch, QuantType, TensorDtype};
22use serde::{Deserialize, Serialize};
23use std::collections::HashMap;
24use std::fs::File;
25use std::io::{self, BufWriter, Write};
26use std::path::{Path, PathBuf};
27
28pub const CMF_MAGIC: [u8; 4] = *b"CMF\x01";
29pub const CMF_VERSION: u32 = 2;
30pub const ENVELOPE_LEN: usize = 128;
31/// Weight blob is page-aligned for mmap.
32pub const DATA_ALIGNMENT: u64 = 4096;
33/// Every tensor inside the blob is 64-byte aligned (SIMD / cache line).
34pub const TENSOR_ALIGNMENT: u64 = 64;
35/// One directory record is 56 bytes (see `.vmfc` v2).
36pub const DIR_RECORD_LEN: usize = 56;
37pub const DIR_MAX_NDIM: usize = 6;
38
39/// `required_features` bits. A reader MUST refuse a file with any bit
40/// it does not support.
41pub mod features {
42    pub const TENSOR_DIR: u32 = 1 << 0;
43    pub const BINARY_MASKS: u32 = 1 << 1;
44    pub const QUANT_2F: u32 = 1 << 2;
45    pub const DELTA_MASKS: u32 = 1 << 3;
46    pub const HOT_PACKS: u32 = 1 << 4;
47
48    /// Features this reader implements today.
49    pub const SUPPORTED: u32 = TENSOR_DIR | BINARY_MASKS | QUANT_2F;
50}
51
52/// JSON header — architecture and provenance (human-readable part;
53/// machine-critical data lives in binary sections).
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct CmfHeader {
56    #[serde(default = "default_format")]
57    pub format: String,
58    pub version: u32,
59    pub arch: ModelArch,
60    /// Informational default; per-tensor truth is in the directory.
61    pub quant_type: QuantType,
62    #[serde(default, skip_serializing_if = "Option::is_none")]
63    pub provenance: Option<serde_json::Value>,
64    /// Chat/eos bundle (spec §6.1): the file — not the binary — defines
65    /// chat behavior. Additive: absent in older files.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub tokenizer_config: Option<TokenizerBundle>,
68    /// Section-level integrity (spec §8.1): hex hash64 of the raw bytes
69    /// of the optional sections. header/dir hashes live in the envelope
70    /// reserved bytes — JSON cannot protect the JSON that carries it.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub section_hashes: Option<SectionHashes>,
73    /// Per-skill records (spec §9): replacement tensors live in the
74    /// directory as `skill.{id}.{name}`; this registry carries the
75    /// selection descriptor and the honest quality contract.
76    #[serde(default, skip_serializing_if = "Vec::is_empty")]
77    pub skills: Vec<SkillRecord>,
78    /// Sharding (spec §10): this file is shard `no` of `count`; every
79    /// shard is a standalone valid .cmf carrying a tensor subset.
80    /// Naming convention: `…-{no:05}-of-{count:05}.cmf`.
81    #[serde(default, skip_serializing_if = "Option::is_none")]
82    pub shard: Option<ShardInfo>,
83    /// Measured confidence calibration (B1): a temperature fit on held-out
84    /// so the displayed Born-mass confidence is a true property of the
85    /// model (softmax(logits/T)), not a raw estimate. Additive; absent =
86    /// use raw (T=1). Written by `set_calibration.py` after `cortiq
87    /// calibrate` measures the reliability/ECE.
88    #[serde(default, skip_serializing_if = "Option::is_none")]
89    pub calibration: Option<Calibration>,
90}
91
92/// Confidence-calibration record (spec §6.2). `temperature` scales the
93/// logits before softmax when reporting confidence; `ece_before`/`after`
94/// are the measured Expected Calibration Error (honest provenance).
95#[derive(Debug, Clone, Serialize, Deserialize)]
96pub struct Calibration {
97    pub temperature: f32,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub ece_before: Option<f32>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub ece_after: Option<f32>,
102}
103
104/// Shard coordinates (1-based, gguf-split style).
105#[derive(Debug, Clone, Serialize, Deserialize)]
106pub struct ShardInfo {
107    pub no: usize,
108    pub count: usize,
109}
110
111/// Recon-argmin routing parameters (spec §9; P1 signal-consistency):
112/// E = ‖(φ−mean) − B·Bᵀ(φ−mean)‖² / ‖φ−mean‖²; pick argmin over skills.
113#[derive(Debug, Clone, Serialize, Deserialize)]
114pub struct SelectionDescriptor {
115    /// "mse" (normalized reconstruction error) — the only metric today.
116    pub metric: String,
117    /// Backbone layer whose mean-pooled hidden is φ(x).
118    pub phi_layer: usize,
119    /// Subspace mean, f16 LE base64, len = hidden.
120    pub mean: String,
121    /// Orthonormal basis rows, f16 LE base64, len = rank·hidden.
122    pub basis: String,
123    pub rank: usize,
124}
125
126/// One skill of the swarm (spec §9; Patent 15 per-skill record).
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct SkillRecord {
129    pub id: String,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub name: Option<String>,
132    /// Layers this skill specializes (a proper subset).
133    #[serde(default)]
134    pub layers: Vec<usize>,
135    /// Selection descriptor for recon-argmin routing (208c, P1):
136    /// per-skill affine subspace over φ(x) = mean-pooled hidden state.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub selection: Option<SelectionDescriptor>,
139    /// Optional input-mask task name (208b), applied with the skill.
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub input_mask_task: Option<String>,
142    /// Measured quality (claim 16): overlaid vs backbone, held-out.
143    #[serde(default, skip_serializing_if = "Option::is_none")]
144    pub quality: Option<serde_json::Value>,
145}
146
147/// Hex-encoded hash64 per optional section (u64 as JSON number would
148/// lose precision past 2^53).
149#[derive(Debug, Clone, Default, Serialize, Deserialize)]
150pub struct SectionHashes {
151    #[serde(default, skip_serializing_if = "Option::is_none")]
152    pub masks: Option<String>,
153    #[serde(default, skip_serializing_if = "Option::is_none")]
154    pub vocab: Option<String>,
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub index: Option<String>,
157}
158
159/// Chat template + generation stop tokens carried by the container.
160#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
161pub struct TokenizerBundle {
162    /// Jinja chat template (chat_template.jinja / tokenizer_config.json)
163    #[serde(default, skip_serializing_if = "Option::is_none")]
164    pub chat_template: Option<String>,
165    /// All ids that terminate generation (generation_config + im_end)
166    #[serde(default)]
167    pub eos_token_ids: Vec<u32>,
168    #[serde(default, skip_serializing_if = "Option::is_none")]
169    pub bos_token_id: Option<u32>,
170    #[serde(default, skip_serializing_if = "Option::is_none")]
171    pub pad_token_id: Option<u32>,
172}
173
174fn default_format() -> String {
175    "cmf".to_string()
176}
177
178/// One tensor directory entry.
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct TensorEntry {
181    pub name: String,
182    pub dtype: TensorDtype,
183    pub shape: Vec<usize>,
184    /// Offset relative to the OWNING shard's `data_off`, multiple of 64.
185    pub off: u64,
186    pub nbytes: u64,
187    /// Runtime-only: which shard's mmap holds the bytes (0 for the
188    /// single-file case; not part of the 56-byte record).
189    pub shard: usize,
190    /// `hash64` of the tensor bytes.
191    pub hash: u64,
192}
193
194impl TensorEntry {
195    pub fn n_elems(&self) -> usize {
196        self.shape.iter().product()
197    }
198}
199
200/// Input for the Rust writer: one tensor with its encoded bytes.
201#[derive(Debug, Clone)]
202pub struct TensorSpec {
203    pub name: String,
204    pub dtype: TensorDtype,
205    pub shape: Vec<usize>,
206    pub data: Vec<u8>,
207}
208
209/// Sparse index entry — precomputed per-task per-layer active group IDs.
210#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
211pub struct SparseIndexEntry {
212    pub task_id: u32,
213    pub layer_idx: usize,
214    /// Active quant-group indices for FFN (sorted, group = 32 neurons).
215    pub active_ffn_groups: Vec<u16>,
216    /// Active head indices for attention (sorted).
217    pub active_heads: Vec<u8>,
218}
219
220/// Section ranges parsed from the fixed envelope.
221#[derive(Debug, Clone, Copy, Default)]
222struct Envelope {
223    required_features: u32,
224    header: (u64, u64),
225    dir: (u64, u64),
226    data: (u64, u64),
227    masks: (u64, u64),
228    vocab: (u64, u64),
229    index: (u64, u64),
230    /// hash64 of the header JSON bytes (reserved [0x70]); 0 = absent.
231    header_hash: u64,
232    /// hash64 of the tensor-directory bytes (reserved [0x78]); 0 = absent.
233    dir_hash: u64,
234}
235
236enum Backing {
237    Mmap(memmap2::Mmap),
238    Owned(Vec<u8>),
239}
240
241impl Backing {
242    fn bytes(&self) -> &[u8] {
243        match self {
244            Backing::Mmap(m) => m,
245            Backing::Owned(v) => v,
246        }
247    }
248}
249
250/// A loaded CMF model: metadata owned, weights zero-copy via mmap.
251pub struct CmfModel {
252    pub path: PathBuf,
253    pub header: CmfHeader,
254    pub required_features: u32,
255    pub tensors: Vec<TensorEntry>,
256    by_name: HashMap<String, usize>,
257    pub masks: MaskCatalog,
258    pub sparse_index: Vec<SparseIndexEntry>,
259    /// Embedded tokenizer.json bytes, if present.
260    pub vocab: Option<Vec<u8>>,
261    backing: Backing,
262    data_off: u64,
263    envelope: Envelope,
264    /// Shards 2..N (spec §10): (backing, data_off) per extra file;
265    /// `TensorEntry.shard` 0 = this file, i>0 = extra_shards[i-1].
266    extra_shards: Vec<(Backing, u64)>,
267}
268
269impl std::fmt::Debug for CmfModel {
270    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
271        f.debug_struct("CmfModel")
272            .field("path", &self.path)
273            .field("arch", &self.header.arch.arch_name)
274            .field("tensors", &self.tensors.len())
275            .field("masks", &self.masks.masks.len())
276            .finish()
277    }
278}
279
280impl CmfModel {
281    /// Open and strictly validate a CMF v2 file. Any inconsistency is an
282    /// error — this function never substitutes defaults.
283    pub fn open(path: impl AsRef<Path>) -> Result<Self, CmfError> {
284        let path = path.as_ref().to_path_buf();
285        if !path.exists() {
286            return Err(CmfError::FileNotFound(path.display().to_string()));
287        }
288        let file = File::open(&path)?;
289        let file_len = file.metadata()?.len();
290
291        let backing = match unsafe { memmap2::MmapOptions::new().map(&file) } {
292            Ok(m) => Backing::Mmap(m),
293            Err(e) => {
294                tracing::warn!("mmap failed ({e}), reading file into memory");
295                Backing::Owned(std::fs::read(&path)?)
296            }
297        };
298
299        let env = Self::parse_envelope(backing.bytes(), file_len)?;
300
301        let bytes = backing.bytes();
302        let section = |off: u64, len: u64| -> &[u8] {
303            &bytes[off as usize..(off + len) as usize]
304        };
305
306        // Header JSON
307        let header: CmfHeader = serde_json::from_slice(section(env.header.0, env.header.1))
308            .map_err(|e| CmfError::Parse(format!("header JSON: {e}")))?;
309
310        // Tensor directory
311        let tensors = Self::decode_directory(section(env.dir.0, env.dir.1))?;
312        for t in &tensors {
313            if t.off % TENSOR_ALIGNMENT != 0 {
314                return Err(CmfError::Bounds(format!(
315                    "tensor '{}': offset {} not 64-aligned",
316                    t.name, t.off
317                )));
318            }
319            if t.off + t.nbytes > env.data.1 {
320                return Err(CmfError::Bounds(format!(
321                    "tensor '{}': [{}, {}) exceeds data section ({} bytes)",
322                    t.name,
323                    t.off,
324                    t.off + t.nbytes,
325                    env.data.1
326                )));
327            }
328            if let Some(expect) = expected_nbytes(t.dtype, &t.shape) {
329                if expect as u64 != t.nbytes {
330                    return Err(CmfError::Bounds(format!(
331                        "tensor '{}': nbytes {} != expected {} for {:?}{:?}",
332                        t.name, t.nbytes, expect, t.dtype, t.shape
333                    )));
334                }
335            }
336        }
337        let by_name = tensors
338            .iter()
339            .enumerate()
340            .map(|(i, t)| (t.name.clone(), i))
341            .collect();
342
343        // Masks
344        let masks = if env.masks.1 > 0 {
345            decode_masks_section(section(env.masks.0, env.masks.1), &header.arch)
346                .map_err(CmfError::Parse)?
347        } else {
348            MaskCatalog::empty()
349        };
350
351        // Vocab (tokenizer.json)
352        let vocab = if env.vocab.1 > 0 {
353            Some(section(env.vocab.0, env.vocab.1).to_vec())
354        } else {
355            None
356        };
357
358        // Sparse index
359        let sparse_index = if env.index.1 > 0 {
360            decode_sparse_index(section(env.index.0, env.index.1))?
361        } else {
362            vec![]
363        };
364
365        tracing::info!(
366            "Opened CMF v2: {} | {} tensors | {} masks | vocab {} | {:.1} MB",
367            header.arch.arch_name,
368            tensors.len(),
369            masks.masks.len(),
370            if vocab.is_some() { "embedded" } else { "none" },
371            file_len as f64 / 1e6
372        );
373
374        Ok(Self {
375            path,
376            header,
377            required_features: env.required_features,
378            tensors,
379            by_name,
380            masks,
381            sparse_index,
382            vocab,
383            backing,
384            data_off: env.data.0,
385            envelope: env,
386            extra_shards: Vec::new(),
387        })
388    }
389
390    /// Open a sharded model (spec §10): pass shard 1; siblings found by
391    /// the `-{no:05}-of-{count:05}.cmf` convention. Directories merge;
392    /// masks/vocab/index/skills come from shard 1.
393    pub fn open_sharded(path: impl AsRef<Path>) -> Result<Self, CmfError> {
394        let path = path.as_ref();
395        let mut first = Self::open(path)?;
396        let Some(info) = first.header.shard.clone() else {
397            return Ok(first); // not sharded — plain open
398        };
399        if info.no != 1 {
400            return Err(CmfError::Parse(format!(
401                "open shard 1, not {} (of {})",
402                info.no, info.count
403            )));
404        }
405        let name = path
406            .file_name()
407            .and_then(|n| n.to_str())
408            .ok_or_else(|| CmfError::Parse("bad shard path".into()))?;
409        let tag1 = format!("-{:05}-of-{:05}.cmf", 1, info.count);
410        if !name.ends_with(&tag1) {
411            return Err(CmfError::Parse(format!(
412                "shard file must end with '{tag1}' (got '{name}')"
413            )));
414        }
415        let stem = &name[..name.len() - tag1.len()];
416        for no in 2..=info.count {
417            let sib = path.with_file_name(format!(
418                "{stem}-{:05}-of-{:05}.cmf",
419                no, info.count
420            ));
421            let sh = Self::open(&sib)?;
422            match &sh.header.shard {
423                Some(si) if si.no == no && si.count == info.count => {}
424                other => {
425                    return Err(CmfError::Parse(format!(
426                        "{}: wrong shard coords {other:?}",
427                        sib.display()
428                    )));
429                }
430            }
431            let shard_idx = first.extra_shards.len() + 1;
432            first.extra_shards.push((sh.backing, sh.envelope.data.0));
433            for mut t in sh.tensors {
434                t.shard = shard_idx;
435                first.by_name.insert(t.name.clone(), first.tensors.len());
436                first.tensors.push(t);
437            }
438        }
439        tracing::info!(
440            "sharded model: {} files, {} tensors total",
441            info.count,
442            first.tensors.len()
443        );
444        Ok(first)
445    }
446
447    fn parse_envelope(bytes: &[u8], file_len: u64) -> Result<Envelope, CmfError> {
448        if bytes.len() < ENVELOPE_LEN {
449            return Err(CmfError::Bounds(format!(
450                "file too small for CMF envelope: {} bytes",
451                bytes.len()
452            )));
453        }
454        if bytes[0..4] != CMF_MAGIC {
455            return Err(CmfError::InvalidMagic);
456        }
457        let u32le = |o: usize| u32::from_le_bytes(bytes[o..o + 4].try_into().unwrap());
458        let u64le = |o: usize| u64::from_le_bytes(bytes[o..o + 8].try_into().unwrap());
459
460        let version = u32le(4);
461        if version != CMF_VERSION {
462            return Err(CmfError::UnsupportedVersion(version));
463        }
464        let _flags = u32le(8); // reserved
465        let required_features = u32le(12);
466        let unknown = required_features & !features::SUPPORTED;
467        if unknown != 0 {
468            return Err(CmfError::UnsupportedFeature(unknown));
469        }
470
471        let env = Envelope {
472            required_features,
473            header: (u64le(0x10), u64le(0x18)),
474            dir: (u64le(0x20), u64le(0x28)),
475            data: (u64le(0x30), u64le(0x38)),
476            masks: (u64le(0x40), u64le(0x48)),
477            vocab: (u64le(0x50), u64le(0x58)),
478            index: (u64le(0x60), u64le(0x68)),
479            header_hash: u64le(0x70),
480            dir_hash: u64le(0x78),
481        };
482
483        for (name, (off, len), required) in [
484            ("header", env.header, true),
485            ("dir", env.dir, true),
486            ("data", env.data, false),
487            ("masks", env.masks, false),
488            ("vocab", env.vocab, false),
489            ("index", env.index, false),
490        ] {
491            if required && len == 0 {
492                return Err(CmfError::Bounds(format!("section '{name}' is required")));
493            }
494            if len > 0 && off.checked_add(len).map(|end| end > file_len).unwrap_or(true) {
495                return Err(CmfError::Bounds(format!(
496                    "section '{name}' [{off}, {}) exceeds file ({file_len} bytes)",
497                    off.saturating_add(len)
498                )));
499            }
500        }
501        if env.data.1 > 0 && env.data.0 % DATA_ALIGNMENT != 0 {
502            return Err(CmfError::Bounds(format!(
503                "data section offset {} not {}-aligned",
504                env.data.0, DATA_ALIGNMENT
505            )));
506        }
507        Ok(env)
508    }
509
510    fn decode_directory(bytes: &[u8]) -> Result<Vec<TensorEntry>, CmfError> {
511        if bytes.len() < 16 {
512            return Err(CmfError::Parse("tensor directory too short".into()));
513        }
514        let count = u64::from_le_bytes(bytes[0..8].try_into().unwrap()) as usize;
515        let pool_off = u64::from_le_bytes(bytes[8..16].try_into().unwrap()) as usize;
516        let records_end = 16 + count * DIR_RECORD_LEN;
517        if records_end > bytes.len() || pool_off > bytes.len() || pool_off < records_end {
518            return Err(CmfError::Parse(format!(
519                "tensor directory malformed: count={count}, pool_off={pool_off}, len={}",
520                bytes.len()
521            )));
522        }
523        let pool = &bytes[pool_off..];
524
525        let mut out = Vec::with_capacity(count);
526        for i in 0..count {
527            let r = &bytes[16 + i * DIR_RECORD_LEN..16 + (i + 1) * DIR_RECORD_LEN];
528            let name_off = u32::from_le_bytes(r[0..4].try_into().unwrap()) as usize;
529            let name_len = u16::from_le_bytes(r[4..6].try_into().unwrap()) as usize;
530            let dtype_id = r[6];
531            let ndim = r[7] as usize;
532            if ndim > DIR_MAX_NDIM {
533                return Err(CmfError::Parse(format!("tensor #{i}: ndim {ndim} > 6")));
534            }
535            let mut shape = Vec::with_capacity(ndim);
536            for d in 0..ndim {
537                shape.push(u32::from_le_bytes(r[8 + d * 4..12 + d * 4].try_into().unwrap()) as usize);
538            }
539            let off = u64::from_le_bytes(r[32..40].try_into().unwrap());
540            let nbytes = u64::from_le_bytes(r[40..48].try_into().unwrap());
541            let hash = u64::from_le_bytes(r[48..56].try_into().unwrap());
542
543            if name_off + name_len > pool.len() {
544                return Err(CmfError::Parse(format!("tensor #{i}: name out of pool")));
545            }
546            let name = std::str::from_utf8(&pool[name_off..name_off + name_len])
547                .map_err(|_| CmfError::Parse(format!("tensor #{i}: name is not UTF-8")))?
548                .to_string();
549            let dtype = TensorDtype::from_id(dtype_id).ok_or(CmfError::UnknownDtype(dtype_id))?;
550
551            out.push(TensorEntry {
552                name,
553                dtype,
554                shape,
555                off,
556                nbytes,
557                shard: 0,
558                hash,
559            });
560        }
561        Ok(out)
562    }
563
564    // ───────────────────────── access ─────────────────────────
565
566    pub fn arch(&self) -> &ModelArch {
567        &self.header.arch
568    }
569
570    pub fn tensor(&self, name: &str) -> Option<&TensorEntry> {
571        self.by_name.get(name).map(|&i| &self.tensors[i])
572    }
573
574    /// Tensor-source indirection (spec §9, Patent 15 fig3/302): the
575    /// skill's replacement is read IN PLACE OF the backbone tensor —
576    /// either/or, never combined. None skill → backbone directly.
577    pub fn resolve_tensor(&self, name: &str, skill: Option<&str>) -> Option<&TensorEntry> {
578        if let Some(sid) = skill {
579            if let Some(t) = self.tensor(&format!("skill.{sid}.{name}")) {
580                return Some(t);
581            }
582        }
583        self.tensor(name)
584    }
585
586    /// The per-skill delta index view (claim 2): directory entries of
587    /// one skill — exactly the byte ranges lazy loading pages in.
588    pub fn skill_tensors(&self, skill_id: &str) -> impl Iterator<Item = &TensorEntry> {
589        let prefix = format!("skill.{skill_id}.");
590        self.tensors.iter().filter(move |t| t.name.starts_with(&prefix))
591    }
592
593    /// Zero-copy bytes of a tensor from the mmap'd data section.
594    pub fn tensor_bytes(&self, name: &str) -> Result<&[u8], CmfError> {
595        let entry = self
596            .tensor(name)
597            .ok_or_else(|| CmfError::MissingTensor(name.to_string()))?;
598        Ok(self.entry_bytes(entry))
599    }
600
601    /// All bytes of the primary mapping (GPU path: no-copy Metal buffer
602    /// over the same mmap — unified memory, zero copying).
603    pub fn primary_bytes(&self) -> &[u8] {
604        self.backing.bytes()
605    }
606
607    /// Absolute offset of the tensor within the primary mapping
608    /// (None for tensors from sibling shards).
609    pub fn entry_abs_offset(&self, entry: &TensorEntry) -> Option<usize> {
610        (entry.shard == 0).then(|| (self.data_off + entry.off) as usize)
611    }
612
613    pub fn entry_bytes(&self, entry: &TensorEntry) -> &[u8] {
614        let (bytes, data_off) = if entry.shard == 0 {
615            (self.backing.bytes(), self.data_off)
616        } else {
617            let (b, o) = &self.extra_shards[entry.shard - 1];
618            (b.bytes(), *o)
619        };
620        let start = (data_off + entry.off) as usize;
621        &bytes[start..start + entry.nbytes as usize]
622    }
623
624    /// Tensors belonging to layer `i` (prefix `model.layers.{i}.`).
625    pub fn layer_tensors(&self, layer_idx: usize) -> Vec<&TensorEntry> {
626        let prefix = format!("model.layers.{layer_idx}.");
627        self.tensors
628            .iter()
629            .filter(|t| t.name.starts_with(&prefix))
630            .collect()
631    }
632
633    /// Total parameter count estimated from matrix tensors (ndim ≥ 2).
634    pub fn total_param_count(&self) -> u64 {
635        self.tensors
636            .iter()
637            .filter(|t| t.shape.len() >= 2)
638            .map(|t| t.n_elems() as u64)
639            .sum()
640    }
641
642    /// Recompute all tensor hashes; returns human-readable problems
643    /// (empty = file intact).
644    pub fn verify(&self) -> Vec<String> {
645        let mut problems = Vec::new();
646
647        // Section-level integrity (spec §8.1). Zero/absent = legacy file.
648        let bytes = self.backing.bytes();
649        let env = &self.envelope;
650        let sect = |(off, len): (u64, u64)| &bytes[off as usize..(off + len) as usize];
651        let check = |name: &str, stored: u64, span: (u64, u64)| -> Option<String> {
652            if stored != 0 && span.1 > 0 {
653                let actual = hash64(sect(span));
654                if actual != stored {
655                    return Some(format!(
656                        "section '{name}': hash mismatch (stored {stored:016x}, \
657                         actual {actual:016x})"
658                    ));
659                }
660            }
661            None
662        };
663        problems.extend(check("header", env.header_hash, env.header));
664        problems.extend(check("dir", env.dir_hash, env.dir));
665        if let Some(sh) = &self.header.section_hashes {
666            for (name, hex, span) in [
667                ("masks", &sh.masks, env.masks),
668                ("vocab", &sh.vocab, env.vocab),
669                ("index", &sh.index, env.index),
670            ] {
671                if let Some(hex) = hex {
672                    match u64::from_str_radix(hex, 16) {
673                        Ok(stored) => problems.extend(check(name, stored, span)),
674                        Err(_) => problems.push(format!(
675                            "section '{name}': malformed hash '{hex}'"
676                        )),
677                    }
678                }
679            }
680        }
681
682        for t in &self.tensors {
683            let actual = hash64(self.entry_bytes(t));
684            if actual != t.hash {
685                problems.push(format!(
686                    "tensor '{}': hash mismatch (stored {:016x}, actual {:016x})",
687                    t.name, t.hash, actual
688                ));
689            }
690        }
691        problems
692    }
693
694    /// Approximate active weight bytes under a mask, from real tensor
695    /// sizes in the directory (not from a formula).
696    pub fn compute_active_size(&self, mask: &TaskMask) -> u64 {
697        let arch = &self.header.arch;
698        let mut total = 0u64;
699        for li in 0..arch.num_layers {
700            if !mask.layer_alive(li) {
701                continue;
702            }
703            let ffn_frac = mask.ffn_active_count(li) as f64 / arch.intermediate_size.max(1) as f64;
704            let head_frac =
705                mask.active_head_count(li) as f64 / arch.num_attention_heads.max(1) as f64;
706            for t in self.layer_tensors(li) {
707                let frac = if t.name.contains(".mlp.") {
708                    ffn_frac
709                } else if t.name.contains(".self_attn.") {
710                    head_frac
711                } else {
712                    1.0
713                };
714                total += (t.nbytes as f64 * frac) as u64;
715            }
716        }
717        total
718    }
719
720    // ───────────────────────── writer ─────────────────────────
721
722    /// Write a CMF v2 file. Offsets, alignment, hashes and the sparse
723    /// index are computed here — the caller supplies content only.
724    pub fn write(
725        path: impl AsRef<Path>,
726        header: &CmfHeader,
727        tensors: &[TensorSpec],
728        masks: Option<&MaskCatalog>,
729        vocab: Option<&[u8]>,
730    ) -> Result<(), CmfError> {
731        let path = path.as_ref();
732
733        // Directory + data layout.
734        let mut entries = Vec::with_capacity(tensors.len());
735        let mut data_cursor = 0u64;
736        for t in tensors {
737            if t.shape.len() > DIR_MAX_NDIM {
738                return Err(CmfError::Parse(format!(
739                    "tensor '{}': ndim {} > 6",
740                    t.name,
741                    t.shape.len()
742                )));
743            }
744            if let Some(expect) = expected_nbytes(t.dtype, &t.shape) {
745                if expect != t.data.len() {
746                    return Err(CmfError::Bounds(format!(
747                        "tensor '{}': data {} bytes != expected {} for {:?}{:?}",
748                        t.name,
749                        t.data.len(),
750                        expect,
751                        t.dtype,
752                        t.shape
753                    )));
754                }
755            }
756            data_cursor = align_to(data_cursor, TENSOR_ALIGNMENT);
757            entries.push(TensorEntry {
758                name: t.name.clone(),
759                dtype: t.dtype,
760                shape: t.shape.clone(),
761                off: data_cursor,
762                nbytes: t.data.len() as u64,
763                shard: 0,
764                hash: hash64(&t.data),
765            });
766            data_cursor += t.data.len() as u64;
767        }
768        let data_len = data_cursor;
769
770        let dir_bytes = Self::encode_directory(&entries);
771
772        let masks_bytes = match masks {
773            Some(catalog) if !catalog.masks.is_empty() => {
774                Some(encode_masks_section(catalog, &header.arch).map_err(CmfError::Parse)?)
775            }
776            _ => None,
777        };
778        let index_bytes = match masks {
779            Some(catalog) if !catalog.masks.is_empty() => {
780                let idx = build_sparse_index(catalog, &header.arch);
781                Some(encode_sparse_index(&idx))
782            }
783            _ => None,
784        };
785
786        // Section hashes go INTO the header (so the envelope's header
787        // hash transitively covers them), then the header is serialized.
788        let hex = |b: Option<&[u8]>| b.map(|b| format!("{:016x}", hash64(b)));
789        let mut header = header.clone();
790        if masks_bytes.is_some() || vocab.is_some() || index_bytes.is_some() {
791            header.section_hashes = Some(SectionHashes {
792                masks: hex(masks_bytes.as_deref()),
793                vocab: hex(vocab),
794                index: hex(index_bytes.as_deref()),
795            });
796        }
797        let header_json =
798            serde_json::to_vec(&header).map_err(|e| CmfError::Parse(format!("header: {e}")))?;
799
800        let mut required_features = features::TENSOR_DIR;
801        if masks_bytes.is_some() {
802            required_features |= features::BINARY_MASKS;
803        }
804        if entries
805            .iter()
806            .any(|t| matches!(t.dtype, TensorDtype::Q8_2f | TensorDtype::Vbit))
807        {
808            required_features |= features::QUANT_2F;
809        }
810
811        // Section offsets.
812        let header_off = ENVELOPE_LEN as u64;
813        let dir_off = header_off + header_json.len() as u64;
814        let data_off = align_to(dir_off + dir_bytes.len() as u64, DATA_ALIGNMENT);
815        let masks_off = data_off + data_len;
816        let masks_len = masks_bytes.as_ref().map(|b| b.len() as u64).unwrap_or(0);
817        let vocab_off = masks_off + masks_len;
818        let vocab_len = vocab.map(|b| b.len() as u64).unwrap_or(0);
819        let index_off = vocab_off + vocab_len;
820        let index_len = index_bytes.as_ref().map(|b| b.len() as u64).unwrap_or(0);
821
822        // Envelope.
823        let mut env = Vec::with_capacity(ENVELOPE_LEN);
824        env.extend_from_slice(&CMF_MAGIC);
825        env.extend_from_slice(&CMF_VERSION.to_le_bytes());
826        env.extend_from_slice(&0u32.to_le_bytes()); // flags
827        env.extend_from_slice(&required_features.to_le_bytes());
828        for (off, len) in [
829            (header_off, header_json.len() as u64),
830            (dir_off, dir_bytes.len() as u64),
831            (data_off, data_len),
832            (if masks_len > 0 { masks_off } else { 0 }, masks_len),
833            (if vocab_len > 0 { vocab_off } else { 0 }, vocab_len),
834            (if index_len > 0 { index_off } else { 0 }, index_len),
835        ] {
836            env.extend_from_slice(&off.to_le_bytes());
837            env.extend_from_slice(&len.to_le_bytes());
838        }
839        // Reserved bytes carry header/dir integrity (spec §8.1).
840        env.extend_from_slice(&hash64(&header_json).to_le_bytes());
841        env.extend_from_slice(&hash64(&dir_bytes).to_le_bytes());
842        env.resize(ENVELOPE_LEN, 0);
843
844        // Write out.
845        let mut f = BufWriter::new(File::create(path)?);
846        f.write_all(&env)?;
847        f.write_all(&header_json)?;
848        f.write_all(&dir_bytes)?;
849        let mut pos = dir_off + dir_bytes.len() as u64;
850        f.write_all(&zeros((data_off - pos) as usize))?;
851        pos = data_off;
852        for (spec, entry) in tensors.iter().zip(&entries) {
853            let target = data_off + entry.off;
854            f.write_all(&zeros((target - pos) as usize))?;
855            f.write_all(&spec.data)?;
856            pos = target + spec.data.len() as u64;
857        }
858        debug_assert_eq!(pos, data_off + data_len);
859        if let Some(mb) = &masks_bytes {
860            f.write_all(mb)?;
861        }
862        if let Some(vb) = vocab {
863            f.write_all(vb)?;
864        }
865        if let Some(ib) = &index_bytes {
866            f.write_all(ib)?;
867        }
868        f.flush()?;
869
870        tracing::info!(
871            "Wrote CMF v2: {} ({} tensors, {} masks, {:.1} MB)",
872            path.display(),
873            entries.len(),
874            masks.map(|m| m.masks.len()).unwrap_or(0),
875            std::fs::metadata(path)?.len() as f64 / 1e6
876        );
877        Ok(())
878    }
879
880    fn encode_directory(entries: &[TensorEntry]) -> Vec<u8> {
881        let mut pool = Vec::new();
882        let mut name_offs = Vec::with_capacity(entries.len());
883        for e in entries {
884            name_offs.push((pool.len() as u32, e.name.len() as u16));
885            pool.extend_from_slice(e.name.as_bytes());
886        }
887        let pool_off = 16 + entries.len() * DIR_RECORD_LEN;
888
889        let mut out = Vec::with_capacity(pool_off + pool.len());
890        out.extend_from_slice(&(entries.len() as u64).to_le_bytes());
891        out.extend_from_slice(&(pool_off as u64).to_le_bytes());
892        for (e, (noff, nlen)) in entries.iter().zip(&name_offs) {
893            out.extend_from_slice(&noff.to_le_bytes());
894            out.extend_from_slice(&nlen.to_le_bytes());
895            out.push(e.dtype.id());
896            out.push(e.shape.len() as u8);
897            for d in 0..DIR_MAX_NDIM {
898                out.extend_from_slice(&(e.shape.get(d).copied().unwrap_or(0) as u32).to_le_bytes());
899            }
900            out.extend_from_slice(&e.off.to_le_bytes());
901            out.extend_from_slice(&e.nbytes.to_le_bytes());
902            out.extend_from_slice(&e.hash.to_le_bytes());
903        }
904        out.extend_from_slice(&pool);
905        out
906    }
907}
908
909fn align_to(x: u64, a: u64) -> u64 {
910    (x + a - 1) / a * a
911}
912
913fn zeros(n: usize) -> Vec<u8> {
914    vec![0u8; n]
915}
916
917// ───────────────────── sparse index (§7 of the spec) ─────────────────────
918
919/// Build the sparse index from mask bitfields: a 32-neuron FFN group is
920/// active if it contains at least one active bit.
921pub fn build_sparse_index(catalog: &MaskCatalog, arch: &ModelArch) -> Vec<SparseIndexEntry> {
922    let mut out = Vec::new();
923    for m in &catalog.masks {
924        for li in 0..arch.num_layers {
925            if !m.layer_alive(li) {
926                continue;
927            }
928            let mut groups = Vec::new();
929            if let Some(bits) = m.ffn_masks.get(li) {
930                let n_groups = (arch.intermediate_size + 31) / 32;
931                for g in 0..n_groups {
932                    // Group g covers bits [g*32, g*32+32) = bytes [g*4, g*4+4).
933                    let active = bits[g * 4..(g * 4 + 4).min(bits.len())]
934                        .iter()
935                        .any(|&b| b != 0);
936                    if active {
937                        groups.push(g as u16);
938                    }
939                }
940            }
941            let mut heads = Vec::new();
942            if let Some(bits) = m.head_masks.get(li) {
943                for h in 0..arch.num_attention_heads {
944                    if bits.get(h / 8).map(|b| b & (1 << (h % 8)) != 0).unwrap_or(false) {
945                        heads.push(h as u8);
946                    }
947                }
948            }
949            out.push(SparseIndexEntry {
950                task_id: m.task_id,
951                layer_idx: li,
952                active_ffn_groups: groups,
953                active_heads: heads,
954            });
955        }
956    }
957    out
958}
959
960/// `[u32 n_entries][u32 reserved]` then per entry:
961/// `[u32 task][u32 layer][u32 n_groups][u32 n_heads][u16×g][u8×h][pad→4]`.
962pub fn encode_sparse_index(entries: &[SparseIndexEntry]) -> Vec<u8> {
963    let mut out = Vec::new();
964    out.extend_from_slice(&(entries.len() as u32).to_le_bytes());
965    out.extend_from_slice(&0u32.to_le_bytes());
966    for e in entries {
967        out.extend_from_slice(&e.task_id.to_le_bytes());
968        out.extend_from_slice(&(e.layer_idx as u32).to_le_bytes());
969        out.extend_from_slice(&(e.active_ffn_groups.len() as u32).to_le_bytes());
970        out.extend_from_slice(&(e.active_heads.len() as u32).to_le_bytes());
971        for g in &e.active_ffn_groups {
972            out.extend_from_slice(&g.to_le_bytes());
973        }
974        out.extend_from_slice(&e.active_heads);
975        while out.len() % 4 != 0 {
976            out.push(0);
977        }
978    }
979    out
980}
981
982pub fn decode_sparse_index(bytes: &[u8]) -> Result<Vec<SparseIndexEntry>, CmfError> {
983    let err = |msg: &str| CmfError::Parse(format!("sparse index: {msg}"));
984    if bytes.len() < 8 {
985        return Err(err("too short"));
986    }
987    let n = u32::from_le_bytes(bytes[0..4].try_into().unwrap()) as usize;
988    let mut pos = 8usize;
989    let mut out = Vec::with_capacity(n);
990    for _ in 0..n {
991        if pos + 16 > bytes.len() {
992            return Err(err("entry header out of bounds"));
993        }
994        let task_id = u32::from_le_bytes(bytes[pos..pos + 4].try_into().unwrap());
995        let layer_idx = u32::from_le_bytes(bytes[pos + 4..pos + 8].try_into().unwrap()) as usize;
996        let n_groups = u32::from_le_bytes(bytes[pos + 8..pos + 12].try_into().unwrap()) as usize;
997        let n_heads = u32::from_le_bytes(bytes[pos + 12..pos + 16].try_into().unwrap()) as usize;
998        pos += 16;
999        if pos + n_groups * 2 + n_heads > bytes.len() {
1000            return Err(err("entry data out of bounds"));
1001        }
1002        let mut groups = Vec::with_capacity(n_groups);
1003        for g in 0..n_groups {
1004            groups.push(u16::from_le_bytes(bytes[pos + g * 2..pos + g * 2 + 2].try_into().unwrap()));
1005        }
1006        pos += n_groups * 2;
1007        let heads = bytes[pos..pos + n_heads].to_vec();
1008        pos += n_heads;
1009        pos = (pos + 3) / 4 * 4;
1010        out.push(SparseIndexEntry {
1011            task_id,
1012            layer_idx,
1013            active_ffn_groups: groups,
1014            active_heads: heads,
1015        });
1016    }
1017    Ok(out)
1018}
1019
1020/// Errors from CMF operations. Every failure mode is loud.
1021#[derive(Debug, thiserror::Error)]
1022pub enum CmfError {
1023    #[error("File not found: {0}")]
1024    FileNotFound(String),
1025    #[error("Invalid CMF magic bytes")]
1026    InvalidMagic,
1027    #[error("Unsupported CMF version: {0}")]
1028    UnsupportedVersion(u32),
1029    #[error("File requires unsupported features (bits {0:#x})")]
1030    UnsupportedFeature(u32),
1031    #[error("Unknown tensor dtype id: {0}")]
1032    UnknownDtype(u8),
1033    #[error("Tensor not found: {0}")]
1034    MissingTensor(String),
1035    #[error("Bounds error: {0}")]
1036    Bounds(String),
1037    #[error("IO error: {0}")]
1038    Io(#[from] io::Error),
1039    #[error("Parse error: {0}")]
1040    Parse(String),
1041}