Skip to main content

cortiq_engine/
mimo_mm.rs

1//! MiMo-V2 multimodal towers: the companion container and its data holder.
2//!
3//! # Packaging
4//!
5//! The text model stays a plain `mimo_v2` CMF. The towers live in a
6//! companion file `<stem>.mm.cmf` (header `arch_name` = [`MIMO_MM_ARCH`],
7//! `provenance.mimo_mm.base_arch` = [`MIMO_BASE_ARCH`]) written by
8//! `cortiq convert --mimo-towers mm-only`. One companion serves every text
9//! variant: it binds by configuration (hidden size, the nine pinned special
10//! token ids), not by the text file's directory hash. A single-file
11//! multimodal CMF (`--mimo-towers multimodal`, header `arch_name` =
12//! `mimo_v2`) carries the SAME tensor names beside the text tensors;
13//! [`MimoMm::from_model`] accepts either and detects the towers by the
14//! presence of the [`MM_CONFIG_BLOB`] tensor.
15//!
16//! Discovery ([`MimoMm::discover`]): an explicit `--mm PATH`, else
17//! `<text-stem>.mm.cmf` beside the text file, else the only `*.mm.cmf` in
18//! that directory (two or more is an error: pass `--mm`).
19//!
20//! # Tensor-name contract
21//!
22//! Tower tensors keep their checkpoint names; the audio tokenizer's own
23//! file (`audio_tokenizer/model.safetensors`) is stored under the
24//! `audio_tokenizer.` prefix, encoder only. Shapes are those of the
25//! release config (MiMo-V2.6-Flash); [`mimo_tower_inventory`] derives the
26//! exact list from any config and is what the loader and the converter
27//! check against. Codec column = the default (q4tp) companion; the exact
28//! development companion (`--quant f16`) stores every "q4tp" row as the
29//! source BF16 instead.
30//!
31//! Config blobs (U8, raw JSON bytes of the source files):
32//!
33//! | name | content |
34//! |---|---|
35//! | `mm.config_json` | the full `config.json` (vision_config, audio_config, processor_config, token ids) |
36//! | `audio_tokenizer.config_json` | `audio_tokenizer/config.json` |
37//!
38//! Vision — 364 tensors (`i` in 0..28; sinks only on the windowed blocks
39//! {1-8, 10-17, 19-26}, i.e. every block not in `fullatt_block_indexes`):
40//!
41//! | name | shape | codec |
42//! |---|---|---|
43//! | `visual.patch_embed.proj.weight` | [1280, 3, 2, 16, 16] | BF16 (Conv3d, no bias; flatten to [1280, 1536] in (c, t, y, x) order) |
44//! | `visual.blocks.{i}.norm1.weight` | [1280] | BF16 |
45//! | `visual.blocks.{i}.norm2.weight` | [1280] | BF16 |
46//! | `visual.blocks.{i}.attn.qkv.weight` | [3072, 1280] | q4tp (rows [q 32×64 \| k 8×64 \| v 8×64]) |
47//! | `visual.blocks.{i}.attn.qkv.bias` | [3072] | BF16 |
48//! | `visual.blocks.{i}.attn.proj.weight` | [1280, 2048] | q4tp |
49//! | `visual.blocks.{i}.attn.proj.bias` | [1280] | BF16 |
50//! | `visual.blocks.{i}.attn.sinks` | [32] | BF16 (windowed blocks only) |
51//! | `visual.blocks.{i}.mlp.gate_proj.weight` | [4608, 1280] | q4tp |
52//! | `visual.blocks.{i}.mlp.gate_proj.bias` | [4608] | BF16 |
53//! | `visual.blocks.{i}.mlp.up_proj.weight` | [4608, 1280] | q4tp |
54//! | `visual.blocks.{i}.mlp.up_proj.bias` | [4608] | BF16 |
55//! | `visual.blocks.{i}.mlp.down_proj.weight` | [1280, 4608] | q4tp |
56//! | `visual.blocks.{i}.mlp.down_proj.bias` | [1280] | BF16 |
57//! | `visual.merger.ln_q.weight` | [1280] | BF16 (RMSNorm weight; no bias exists) |
58//! | `visual.merger.mlp.0.weight` | [5120, 5120] | q4tp (no bias) |
59//! | `visual.merger.mlp.2.weight` | [4096, 5120] | q4tp (no bias) |
60//!
61//! Audio encoder — 75 tensors (`l` in 0..6) — and speech embeddings — 20
62//! tensors (`c` in 0..20):
63//!
64//! | name | shape | codec |
65//! |---|---|---|
66//! | `audio_encoder.input_local_transformer.layers.{l}.input_layernorm.weight` | [1024] | BF16 |
67//! | `audio_encoder.input_local_transformer.layers.{l}.post_attention_layernorm.weight` | [1024] | BF16 |
68//! | `audio_encoder.input_local_transformer.layers.{l}.self_attn.{q,k,v}_proj.weight` | [1024, 1024] | q4tp |
69//! | `audio_encoder.input_local_transformer.layers.{l}.self_attn.{q,k,v}_proj.bias` | [1024] | BF16 |
70//! | `audio_encoder.input_local_transformer.layers.{l}.self_attn.o_proj.weight` | [1024, 1024] | q4tp (no bias) |
71//! | `audio_encoder.input_local_transformer.layers.{l}.mlp.{gate,up}_proj.weight` | [4096, 1024] | q4tp |
72//! | `audio_encoder.input_local_transformer.layers.{l}.mlp.down_proj.weight` | [1024, 4096] | q4tp |
73//! | `audio_encoder.input_local_transformer.norm.weight` | [1024] | BF16 |
74//! | `audio_encoder.projection.mlp.0.weight` | [16384, 4096] | q4tp (no bias) |
75//! | `audio_encoder.projection.mlp.2.weight` | [4096, 16384] | q4tp (no bias) |
76//! | `speech_embeddings.{c}.weight` | [1280, 1024] | BF16 (gather table, never quantized) |
77//!
78//! `audio_encoder.input_local_transformer.embed_tokens` does not exist in
79//! the checkpoint (the local transformer takes embeddings) and is never
80//! required.
81//!
82//! Audio tokenizer encoder — 389 tensors (`l` in 0..24, `q` in 0..20;
83//! codebook rows 1024, 1024, 256, then 128 × 17):
84//!
85//! | name | shape | codec |
86//! |---|---|---|
87//! | `audio_tokenizer.encoder.conv1.weight` | [1024, 128, 3] | BF16 |
88//! | `audio_tokenizer.encoder.conv1.bias` | [1024] | BF16 |
89//! | `audio_tokenizer.encoder.conv2.weight` | [1024, 1024, 3] | BF16 (stride 2) |
90//! | `audio_tokenizer.encoder.conv2.bias` | [1024] | BF16 |
91//! | `audio_tokenizer.encoder.layers.{l}.self_attn.{q,v,out}_proj.weight` | [1024, 1024] | q4tp |
92//! | `audio_tokenizer.encoder.layers.{l}.self_attn.{q,v,out}_proj.bias` | [1024] | BF16 |
93//! | `audio_tokenizer.encoder.layers.{l}.self_attn.k_proj.weight` | [1024, 1024] | q4tp (no bias) |
94//! | `audio_tokenizer.encoder.layers.{l}.self_attn_layer_norm.{weight,bias}` | [1024] | BF16 |
95//! | `audio_tokenizer.encoder.layers.{l}.final_layer_norm.{weight,bias}` | [1024] | BF16 |
96//! | `audio_tokenizer.encoder.layers.{l}.fc1.weight` | [4096, 1024] | q4tp |
97//! | `audio_tokenizer.encoder.layers.{l}.fc1.bias` | [4096] | BF16 |
98//! | `audio_tokenizer.encoder.layers.{l}.fc2.weight` | [1024, 4096] | q4tp |
99//! | `audio_tokenizer.encoder.layers.{l}.fc2.bias` | [1024] | BF16 |
100//! | `audio_tokenizer.encoder.layer_norm.{weight,bias}` | [1024] | BF16 |
101//! | `audio_tokenizer.encoder.down_sample_layer.0.weight` | [1024, 1024, 2] | BF16 (Conv1d k2 s2, no bias) |
102//! | `audio_tokenizer.encoder.down_sample_norm.{weight,bias}` | [1024] | BF16 |
103//! | `audio_tokenizer.encoder.quantizer.vq.layers.{q}._codebook.embed` | [bins_q, 1024] | F32, bit-exact |
104//!
105//! Dropped from the tokenizer file: every `decoder.*` tensor and the
106//! codebook training state (`cluster_size`, `embed_avg`, `inited`).
107//!
108//! "BF16" = the source bytes verbatim, bit-exact by construction. F16 is
109//! not used: 195 467 of the 1 297.6 M BF16 values the release towers keep
110//! do not survive BF16 → F16 (|Δ| ≤ 2^-25 on the visual/audio/speech
111//! ones, all below F16's subnormal step). Readers
112//! dispatch on the directory dtype, which [`MimoMm::f32`] and
113//! [`MimoMm::linear`] do (BF16/F16/F32 → f32 once; q4tp/q8_2f stay mapped).
114//!
115//! # API for the tower modules
116//!
117//! ```ignore
118//! let mm = MimoMm::attach(&text, None)?.expect("no companion");   // or MimoMm::open(path)?
119//! let geo = &mm.vision;                                              // parsed geometry
120//! let qkv = mm.linear(&names::vis_block(3, "attn.qkv.weight"), geo.qkv_rows(), geo.hidden)?;
121//! let b   = mm.f32_shaped(&names::vis_block(3, "attn.qkv.bias"), &[geo.qkv_rows()])?;
122//! let cb  = mm.f32_shaped(&names::at_codebook(0), &[1024, 1024])?;  // F32 RVQ codebook
123//! ```
124
125use crate::qtensor::QTensor;
126use crate::tokenizer::Tokenizer;
127use cortiq_core::format::TensorEntry;
128use cortiq_core::{CmfModel, TensorDtype};
129use serde_json::Value;
130use std::path::{Path, PathBuf};
131use std::sync::Arc;
132
133/// Header `arch_name` of the companion file.
134pub const MIMO_MM_ARCH: &str = "mimo_v2_mm";
135/// The text architecture a companion attaches to.
136pub const MIMO_BASE_ARCH: &str = "mimo_v2";
137/// U8 tensor holding the full source `config.json`.
138pub const MM_CONFIG_BLOB: &str = "mm.config_json";
139/// U8 tensor holding `audio_tokenizer/config.json`.
140pub const AUDIO_TOKENIZER_CONFIG_BLOB: &str = "audio_tokenizer.config_json";
141/// Namespace of the audio tokenizer file's tensors inside a CMF.
142pub const AUDIO_TOKENIZER_PREFIX: &str = "audio_tokenizer.";
143/// File suffix of the companion.
144pub const MM_SUFFIX: &str = ".mm.cmf";
145
146/// The nine special tokens the multimodal prompt layout depends on, with
147/// the ids the release pins (config.json + tokenizer_config.json).
148pub const MIMO_SPECIAL_TOKENS: [(&str, u32); 9] = [
149    ("<|vision_start|>", 151652),
150    ("<|vision_end|>", 151653),
151    ("<|image_pad|>", 151655),
152    ("<|video_pad|>", 151656),
153    ("<|audio_pad|>", 151669),
154    ("<|mimo_video_start|>", 151670),
155    ("<|mimo_video_end|>", 151671),
156    ("<|mimo_audio_start|>", 151673),
157    ("<|mimo_audio_end|>", 151674),
158];
159
160/// The special token ids, by role.
161#[derive(Clone, Copy, Debug, PartialEq, Eq)]
162pub struct MimoTokenIds {
163    pub vision_start: u32,
164    pub vision_end: u32,
165    pub image_pad: u32,
166    pub video_pad: u32,
167    pub audio_pad: u32,
168    pub video_start: u32,
169    pub video_end: u32,
170    pub audio_start: u32,
171    pub audio_end: u32,
172}
173
174impl MimoTokenIds {
175    /// The release ids ([`MIMO_SPECIAL_TOKENS`]).
176    pub const PINNED: Self = Self {
177        vision_start: 151652,
178        vision_end: 151653,
179        image_pad: 151655,
180        video_pad: 151656,
181        audio_pad: 151669,
182        video_start: 151670,
183        video_end: 151671,
184        audio_start: 151673,
185        audio_end: 151674,
186    };
187
188    /// `(token spelling, id)` in the order of [`MIMO_SPECIAL_TOKENS`].
189    pub fn named(&self) -> [(&'static str, u32); 9] {
190        [
191            (MIMO_SPECIAL_TOKENS[0].0, self.vision_start),
192            (MIMO_SPECIAL_TOKENS[1].0, self.vision_end),
193            (MIMO_SPECIAL_TOKENS[2].0, self.image_pad),
194            (MIMO_SPECIAL_TOKENS[3].0, self.video_pad),
195            (MIMO_SPECIAL_TOKENS[4].0, self.audio_pad),
196            (MIMO_SPECIAL_TOKENS[5].0, self.video_start),
197            (MIMO_SPECIAL_TOKENS[6].0, self.video_end),
198            (MIMO_SPECIAL_TOKENS[7].0, self.audio_start),
199            (MIMO_SPECIAL_TOKENS[8].0, self.audio_end),
200        ]
201    }
202
203    /// Read the ids from a MiMo `config.json` (top-level keys, the video
204    /// markers from `processor_config`). Every key is required.
205    pub fn from_config(cfg: &Value) -> Result<Self, String> {
206        let top = |k: &str| -> Result<u32, String> {
207            cfg.get(k)
208                .and_then(Value::as_u64)
209                .map(|v| v as u32)
210                .ok_or_else(|| format!("config.json: missing integer '{k}'"))
211        };
212        let proc = |k: &str| -> Result<u32, String> {
213            cfg.get("processor_config")
214                .and_then(|p| p.get(k))
215                .and_then(Value::as_u64)
216                .map(|v| v as u32)
217                .ok_or_else(|| format!("config.json: missing integer 'processor_config.{k}'"))
218        };
219        Ok(Self {
220            vision_start: top("vision_start_token_id")?,
221            vision_end: top("vision_end_token_id")?,
222            image_pad: top("image_token_id")?,
223            video_pad: top("video_token_id")?,
224            audio_pad: top("audio_token_id")?,
225            video_start: proc("video_start_token_id")?,
226            video_end: proc("video_end_token_id")?,
227            audio_start: top("audio_start_token_id")?,
228            audio_end: top("audio_end_token_id")?,
229        })
230    }
231
232    /// Hard error unless every id equals the pinned one.
233    pub fn check_pinned(&self, what: &str) -> Result<(), String> {
234        for ((name, got), (_, want)) in self.named().iter().zip(Self::PINNED.named()) {
235            if *got != want {
236                return Err(format!(
237                    "{what}: special token {name} has id {got}, the MiMo layout pins {want}"
238                ));
239            }
240        }
241        Ok(())
242    }
243}
244
245fn cfg_usize(cfg: &Value, key: &str, what: &str) -> Result<usize, String> {
246    match cfg.get(key) {
247        Some(Value::Number(n)) => n
248            .as_u64()
249            .map(|v| v as usize)
250            .ok_or_else(|| format!("{what}.{key}: not a non-negative integer")),
251        // The release stores two audio keys as strings ("1280", "1024").
252        Some(Value::String(s)) => s
253            .trim()
254            .parse()
255            .map_err(|_| format!("{what}.{key}: '{s}' is not an integer")),
256        _ => Err(format!("{what}: missing integer '{key}'")),
257    }
258}
259
260fn cfg_usize_or(cfg: &Value, key: &str, what: &str, default: usize) -> Result<usize, String> {
261    if cfg.get(key).is_none_or(Value::is_null) {
262        Ok(default)
263    } else {
264        cfg_usize(cfg, key, what)
265    }
266}
267
268fn cfg_f64_or(cfg: &Value, key: &str, default: f64) -> f64 {
269    match cfg.get(key) {
270        Some(Value::Number(n)) => n.as_f64().unwrap_or(default),
271        Some(Value::String(s)) => s.trim().parse().unwrap_or(default),
272        _ => default,
273    }
274}
275
276fn cfg_bool_or(cfg: &Value, key: &str, default: bool) -> bool {
277    cfg.get(key).and_then(Value::as_bool).unwrap_or(default)
278}
279
280fn cfg_usize_list(cfg: &Value, key: &str, what: &str) -> Result<Vec<usize>, String> {
281    cfg.get(key)
282        .and_then(Value::as_array)
283        .ok_or_else(|| format!("{what}: missing list '{key}'"))?
284        .iter()
285        .map(|v| {
286            v.as_u64()
287                .map(|x| x as usize)
288                .ok_or_else(|| format!("{what}.{key}: non-integer entry"))
289        })
290        .collect()
291}
292
293/// `speech_vocab_size` / `speech_zeroemb_idx`: one value for every channel,
294/// or a "a-b-c" list (HF `_parse_maybe_list`).
295fn per_channel(cfg: &Value, key: &str, channels: usize) -> Result<Vec<usize>, String> {
296    let bad = || format!("audio_config.{key}: expected an integer or 'a-b-…' list");
297    let one = |s: &str| s.trim().parse::<usize>().map_err(|_| bad());
298    match cfg.get(key) {
299        Some(Value::Number(n)) => Ok(vec![n.as_u64().ok_or_else(bad)? as usize; channels]),
300        Some(Value::String(s)) if s.contains('-') => {
301            let v = s.split('-').map(one).collect::<Result<Vec<_>, _>>()?;
302            if v.len() != channels {
303                return Err(format!(
304                    "audio_config.{key}: {} entries for {channels} channels",
305                    v.len()
306                ));
307            }
308            Ok(v)
309        }
310        Some(Value::String(s)) => Ok(vec![one(s)?; channels]),
311        _ => Err(bad()),
312    }
313}
314
315/// ViT geometry (`config.vision_config`, HF `MiMoVisionTransformer`).
316#[derive(Clone, Debug, PartialEq)]
317pub struct MimoVisionGeom {
318    pub depth: usize,
319    /// Residual width (1280).
320    pub hidden: usize,
321    pub intermediate: usize,
322    pub heads: usize,
323    pub kv_heads: usize,
324    /// `qk_channels`, default 64 — NOT hidden/heads (= 40).
325    pub head_dim: usize,
326    /// LLM hidden the merger projects to (4096).
327    pub out_hidden: usize,
328    pub in_channels: usize,
329    pub patch: usize,
330    pub temporal_patch: usize,
331    pub merge: usize,
332    /// Blocks with full (per-frame) attention.
333    pub fullatt: Vec<usize>,
334    /// `vit_window_attn_types` (−1 full, 0 row order, 1 column order).
335    pub window_types: Vec<i64>,
336    /// Band half-width `visual_token_window_size` (|i−j| ≤ 64).
337    pub band: usize,
338    pub use_sink: bool,
339    pub rms_eps: f64,
340}
341
342impl MimoVisionGeom {
343    pub fn from_config(cfg: &Value) -> Result<Self, String> {
344        let v = cfg
345            .get("vision_config")
346            .ok_or("config.json: no vision_config")?;
347        let w = "vision_config";
348        let depth = cfg_usize(v, "depth", w)?;
349        let heads = cfg_usize(v, "num_heads", w)?;
350        let window_types: Vec<i64> = match v.get("vit_window_attn_types") {
351            Some(Value::Array(a)) => a
352                .iter()
353                .map(|x| {
354                    x.as_i64()
355                        .ok_or("vision_config.vit_window_attn_types: non-integer")
356                })
357                .collect::<Result<_, _>>()?,
358            _ => vec![-1; depth],
359        };
360        if window_types.len() != depth {
361            return Err(format!(
362                "vision_config: {} vit_window_attn_types for depth {depth}",
363                window_types.len()
364            ));
365        }
366        let fullatt = match v.get("fullatt_block_indexes") {
367            Some(Value::Array(_)) => cfg_usize_list(v, "fullatt_block_indexes", w)?,
368            _ => Vec::new(),
369        };
370        if let Some(&bad) = fullatt.iter().find(|&&i| i >= depth) {
371            return Err(format!(
372                "vision_config: full-attention block {bad} >= depth {depth}"
373            ));
374        }
375        let band = match v.get("visual_token_window_size").and_then(Value::as_i64) {
376            Some(b) if b > 0 => b as usize,
377            _ => return Err("vision_config: visual_token_window_size must be positive".into()),
378        };
379        let g = Self {
380            depth,
381            hidden: cfg_usize(v, "hidden_size", w)?,
382            intermediate: cfg_usize(v, "intermediate_size", w)?,
383            heads,
384            kv_heads: cfg_usize_or(v, "num_key_value_heads", w, heads)?,
385            head_dim: cfg_usize_or(v, "qk_channels", w, 64)?,
386            out_hidden: cfg_usize(v, "out_hidden_size", w)?,
387            in_channels: match v.get("in_channels") {
388                Some(x) if !x.is_null() => cfg_usize(v, "in_channels", w)?,
389                _ => cfg_usize_or(v, "in_chans", w, 3)?,
390            },
391            patch: cfg_usize(v, "patch_size", w)?,
392            temporal_patch: cfg_usize(v, "temporal_patch_size", w)?,
393            merge: cfg_usize_or(v, "spatial_merge_size", w, 2)?,
394            fullatt,
395            window_types,
396            band,
397            use_sink: cfg_bool_or(v, "use_sink", false),
398            rms_eps: cfg_f64_or(v, "rms_norm_eps", 1e-6),
399        };
400        if g.heads == 0 || g.kv_heads == 0 || g.heads % g.kv_heads != 0 {
401            return Err(format!(
402                "vision_config: {} heads over {} kv heads",
403                g.heads, g.kv_heads
404            ));
405        }
406        if g.head_dim == 0 || g.head_dim % 4 != 0 {
407            return Err(format!(
408                "vision_config: head_dim {} must be a positive multiple of 4 (2-D RoPE)",
409                g.head_dim
410            ));
411        }
412        Ok(g)
413    }
414
415    /// Rows of the fused qkv: `(heads + 2·kv_heads)·head_dim`.
416    pub fn qkv_rows(&self) -> usize {
417        (self.heads + 2 * self.kv_heads) * self.head_dim
418    }
419
420    /// Width of one merge unit: `hidden·merge²`.
421    pub fn merge_width(&self) -> usize {
422        self.hidden * self.merge * self.merge
423    }
424
425    /// Values per patch row: `in_channels·temporal_patch·patch²`.
426    pub fn patch_width(&self) -> usize {
427        self.in_channels * self.temporal_patch * self.patch * self.patch
428    }
429
430    /// Block `i` carries a key-0 sink (every non-full block when `use_sink`).
431    pub fn has_sink(&self, i: usize) -> bool {
432        self.use_sink && !self.fullatt.contains(&i)
433    }
434}
435
436/// Audio patch encoder geometry (`config.audio_config`, HF `MiMoAudioEncoder`).
437#[derive(Clone, Debug, PartialEq)]
438pub struct MimoAudioGeom {
439    /// RVQ channels summed per frame (20).
440    pub channels: usize,
441    /// Frames per LLM token (4).
442    pub group_size: usize,
443    /// Local transformer width (1024).
444    pub local_dim: usize,
445    pub local_layers: usize,
446    pub local_heads: usize,
447    pub local_head_dim: usize,
448    pub local_intermediate: usize,
449    pub rope_theta: f64,
450    pub partial_rotary: f64,
451    /// Bidirectional attention inside a group.
452    pub full_attention: bool,
453    pub post_norm: bool,
454    pub projection_layers: usize,
455    /// LLM hidden (4096).
456    pub out_hidden: usize,
457    pub segment_size: usize,
458    /// Rows of each `speech_embeddings.{c}` table.
459    pub speech_vocab: Vec<usize>,
460    /// `padding_idx` of each table (the "empty" code).
461    pub speech_zero: Vec<usize>,
462}
463
464impl MimoAudioGeom {
465    pub fn from_config(cfg: &Value) -> Result<Self, String> {
466        let a = cfg
467            .get("audio_config")
468            .ok_or("config.json: no audio_config")?;
469        let w = "audio_config";
470        let channels = cfg_usize(a, "audio_channels", w)?;
471        let heads = cfg_usize(a, "input_local_attn_heads", w)?;
472        let dim = cfg_usize(a, "input_local_dim", w)?;
473        let g = Self {
474            channels,
475            group_size: cfg_usize(a, "group_size", w)?,
476            local_dim: dim,
477            local_layers: cfg_usize(a, "input_local_layers", w)?,
478            local_heads: heads,
479            local_head_dim: cfg_usize_or(a, "input_local_head_dim", w, dim / heads.max(1))?,
480            local_intermediate: cfg_usize(a, "input_local_intermediate_size", w)?,
481            rope_theta: cfg_f64_or(a, "rope_theta", 640000.0),
482            partial_rotary: cfg_f64_or(a, "partial_rotary_factor", 1.0),
483            full_attention: cfg_bool_or(a, "input_full_attention", true),
484            post_norm: cfg_bool_or(a, "add_post_norm", true),
485            projection_layers: cfg_usize_or(a, "projection_layers", w, 2)?,
486            out_hidden: cfg_usize(a, "out_hidden_size", w)?,
487            segment_size: cfg_usize_or(a, "audio_segment_size", w, 6000)?,
488            speech_vocab: per_channel(a, "speech_vocab_size", channels)?,
489            speech_zero: per_channel(a, "speech_zeroemb_idx", channels)?,
490        };
491        if g.local_heads * g.local_head_dim != g.local_dim {
492            return Err(format!(
493                "audio_config: {} heads × {} != input_local_dim {}",
494                g.local_heads, g.local_head_dim, g.local_dim
495            ));
496        }
497        if !matches!(g.projection_layers, 1 | 2) {
498            return Err(format!(
499                "audio_config: projection_layers {} (expected 1 or 2)",
500                g.projection_layers
501            ));
502        }
503        Ok(g)
504    }
505
506    /// Input width of the projection: `local_dim·group_size` (4096).
507    pub fn proj_in(&self) -> usize {
508        self.local_dim * self.group_size
509    }
510}
511
512/// Audio tokenizer encoder geometry (`audio_tokenizer/config.json`).
513#[derive(Clone, Debug, PartialEq)]
514pub struct MimoAudioTokenizerGeom {
515    pub d_model: usize,
516    pub layers: usize,
517    pub heads: usize,
518    pub ffn: usize,
519    pub n_mels: usize,
520    pub kernel_size: usize,
521    pub stride: usize,
522    pub avg_pooler: usize,
523    /// `encoder_skip_layer_id` as stored (HF saves h after layer index
524    /// `skip_layer_id − 1`).
525    pub skip_layer_id: usize,
526    pub causal: bool,
527    /// `encoder_attn_window_size` (left, right).
528    pub window: (i64, i64),
529    pub hybrid_attention: bool,
530    pub swa_per_block: usize,
531    pub rope_theta: f64,
532    /// Rows of each RVQ codebook.
533    pub codebook_sizes: Vec<usize>,
534    pub sampling_rate: usize,
535    pub hop_length: usize,
536    pub nfft: usize,
537    pub window_size: usize,
538}
539
540impl MimoAudioTokenizerGeom {
541    pub fn from_config(at: &Value) -> Result<Self, String> {
542        let w = "audio_tokenizer/config.json";
543        let win = at
544            .get("encoder_attn_window_size")
545            .and_then(Value::as_array)
546            .filter(|a| a.len() == 2)
547            .and_then(|a| Some((a[0].as_i64()?, a[1].as_i64()?)))
548            .ok_or_else(|| format!("{w}: encoder_attn_window_size must be [left, right]"))?;
549        let ln = at
550            .get("ln_type")
551            .and_then(Value::as_str)
552            .unwrap_or("LayerNorm");
553        if ln != "LayerNorm" {
554            return Err(format!("{w}: ln_type '{ln}' (only LayerNorm)"));
555        }
556        let num_q = cfg_usize(at, "num_quantizers", w)?;
557        let codebook_sizes = cfg_usize_list(at, "codebook_size", w)?;
558        if codebook_sizes.len() != num_q {
559            return Err(format!(
560                "{w}: {} codebook sizes for {num_q} quantizers",
561                codebook_sizes.len()
562            ));
563        }
564        let g = Self {
565            d_model: cfg_usize(at, "d_model", w)?,
566            layers: cfg_usize(at, "encoder_layers", w)?,
567            heads: cfg_usize(at, "encoder_attention_heads", w)?,
568            ffn: cfg_usize(at, "encoder_ffn_dim", w)?,
569            n_mels: cfg_usize(at, "n_mels", w)?,
570            kernel_size: cfg_usize(at, "kernel_size", w)?,
571            stride: cfg_usize(at, "stride_size", w)?,
572            avg_pooler: cfg_usize_or(at, "avg_pooler", w, 1)?,
573            skip_layer_id: cfg_usize(at, "encoder_skip_layer_id", w)?,
574            causal: cfg_bool_or(at, "encoder_causal", false),
575            window: win,
576            hybrid_attention: cfg_bool_or(at, "hybrid_attention", false),
577            swa_per_block: cfg_usize_or(at, "swa_per_block", w, 1)?,
578            rope_theta: cfg_f64_or(at, "rope_theta", 10000.0),
579            codebook_sizes,
580            sampling_rate: cfg_usize(at, "sampling_rate", w)?,
581            hop_length: cfg_usize(at, "hop_length", w)?,
582            nfft: cfg_usize(at, "nfft", w)?,
583            window_size: cfg_usize(at, "window_size", w)?,
584        };
585        if g.heads == 0 || g.d_model % g.heads != 0 {
586            return Err(format!("{w}: d_model {} over {} heads", g.d_model, g.heads));
587        }
588        if at.get("scale_embedding").and_then(Value::as_bool) == Some(true) {
589            return Err(format!("{w}: scale_embedding=true is not supported"));
590        }
591        Ok(g)
592    }
593}
594
595/// The four tower groups of a MiMo-V2 checkpoint.
596#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
597pub enum MimoTowerGroup {
598    Vision,
599    AudioEncoder,
600    SpeechEmbeddings,
601    AudioTokenizer,
602}
603
604impl MimoTowerGroup {
605    pub const ALL: [Self; 4] = [
606        Self::Vision,
607        Self::AudioEncoder,
608        Self::SpeechEmbeddings,
609        Self::AudioTokenizer,
610    ];
611
612    /// The group a (CMF) tensor name belongs to, if any. Config blobs are
613    /// not tower tensors.
614    pub fn of(name: &str) -> Option<Self> {
615        if name.starts_with("visual.") {
616            Some(Self::Vision)
617        } else if name.starts_with("audio_encoder.") {
618            Some(Self::AudioEncoder)
619        } else if name.starts_with("speech_embeddings.") {
620            Some(Self::SpeechEmbeddings)
621        } else if name.starts_with("audio_tokenizer.encoder.") {
622            Some(Self::AudioTokenizer)
623        } else {
624            None
625        }
626    }
627
628    pub fn label(self) -> &'static str {
629        match self {
630            Self::Vision => "visual",
631            Self::AudioEncoder => "audio_encoder",
632            Self::SpeechEmbeddings => "speech_embeddings",
633            Self::AudioTokenizer => "audio_tokenizer",
634        }
635    }
636}
637
638/// An RVQ codebook of the audio tokenizer (stored F32, never quantized).
639pub fn is_codebook(name: &str) -> bool {
640    name.starts_with("audio_tokenizer.encoder.quantizer.") && name.ends_with("._codebook.embed")
641}
642
643/// Tensor-name builders (see the module table).
644pub mod names {
645    use super::AUDIO_TOKENIZER_PREFIX;
646
647    pub const VIS_PATCH_EMBED: &str = "visual.patch_embed.proj.weight";
648    pub const VIS_MERGER_NORM: &str = "visual.merger.ln_q.weight";
649    pub const VIS_MERGER_FC1: &str = "visual.merger.mlp.0.weight";
650    pub const VIS_MERGER_FC2: &str = "visual.merger.mlp.2.weight";
651    pub const AUD_LOCAL_NORM: &str = "audio_encoder.input_local_transformer.norm.weight";
652    pub const AUD_PROJ_FC1: &str = "audio_encoder.projection.mlp.0.weight";
653    pub const AUD_PROJ_FC2: &str = "audio_encoder.projection.mlp.2.weight";
654    /// Single-linear projection (`projection_layers == 1`).
655    pub const AUD_PROJ_SINGLE: &str = "audio_encoder.projection.weight";
656
657    /// `visual.blocks.{i}.{leaf}`, e.g. `vis_block(3, "attn.qkv.weight")`.
658    pub fn vis_block(i: usize, leaf: &str) -> String {
659        format!("visual.blocks.{i}.{leaf}")
660    }
661
662    /// `audio_encoder.input_local_transformer.layers.{l}.{leaf}`.
663    pub fn aud_layer(l: usize, leaf: &str) -> String {
664        format!("audio_encoder.input_local_transformer.layers.{l}.{leaf}")
665    }
666
667    /// `speech_embeddings.{c}.weight`.
668    pub fn speech_embedding(c: usize) -> String {
669        format!("speech_embeddings.{c}.weight")
670    }
671
672    /// `audio_tokenizer.encoder.{leaf}`, e.g. `at("conv1.weight")`.
673    pub fn at(leaf: &str) -> String {
674        format!("{AUDIO_TOKENIZER_PREFIX}encoder.{leaf}")
675    }
676
677    /// `audio_tokenizer.encoder.layers.{l}.{leaf}`.
678    pub fn at_layer(l: usize, leaf: &str) -> String {
679        format!("{AUDIO_TOKENIZER_PREFIX}encoder.layers.{l}.{leaf}")
680    }
681
682    /// `audio_tokenizer.encoder.quantizer.vq.layers.{q}._codebook.embed`.
683    pub fn at_codebook(q: usize) -> String {
684        format!("{AUDIO_TOKENIZER_PREFIX}encoder.quantizer.vq.layers.{q}._codebook.embed")
685    }
686}
687
688/// The exact tower inventory `(name, shape)` implied by the configs, in
689/// the order the converter writes it (vision, audio encoder, speech
690/// embeddings, audio tokenizer). Release: 364 + 75 + 20 + 389 = 848.
691pub fn mimo_tower_inventory(
692    config: &Value,
693    at_config: &Value,
694) -> Result<Vec<(String, Vec<usize>)>, String> {
695    let v = MimoVisionGeom::from_config(config)?;
696    let a = MimoAudioGeom::from_config(config)?;
697    let t = MimoAudioTokenizerGeom::from_config(at_config)?;
698    let mut out: Vec<(String, Vec<usize>)> = Vec::new();
699    let mut push = |n: String, s: Vec<usize>| out.push((n, s));
700
701    // Vision.
702    push(
703        names::VIS_PATCH_EMBED.into(),
704        vec![v.hidden, v.in_channels, v.temporal_patch, v.patch, v.patch],
705    );
706    let (h, i, hd) = (v.hidden, v.intermediate, v.head_dim);
707    for b in 0..v.depth {
708        let n = |leaf: &str| names::vis_block(b, leaf);
709        push(n("norm1.weight"), vec![h]);
710        push(n("norm2.weight"), vec![h]);
711        push(n("attn.qkv.weight"), vec![v.qkv_rows(), h]);
712        push(n("attn.qkv.bias"), vec![v.qkv_rows()]);
713        push(n("attn.proj.weight"), vec![h, v.heads * hd]);
714        push(n("attn.proj.bias"), vec![h]);
715        if v.has_sink(b) {
716            push(n("attn.sinks"), vec![v.heads]);
717        }
718        push(n("mlp.gate_proj.weight"), vec![i, h]);
719        push(n("mlp.gate_proj.bias"), vec![i]);
720        push(n("mlp.up_proj.weight"), vec![i, h]);
721        push(n("mlp.up_proj.bias"), vec![i]);
722        push(n("mlp.down_proj.weight"), vec![h, i]);
723        push(n("mlp.down_proj.bias"), vec![h]);
724    }
725    push(names::VIS_MERGER_NORM.into(), vec![h]);
726    push(
727        names::VIS_MERGER_FC1.into(),
728        vec![v.merge_width(), v.merge_width()],
729    );
730    push(
731        names::VIS_MERGER_FC2.into(),
732        vec![v.out_hidden, v.merge_width()],
733    );
734
735    // Audio patch encoder (a Qwen2 stack: q/k/v biased, o_proj not).
736    let (d, ai) = (a.local_dim, a.local_intermediate);
737    let qd = a.local_heads * a.local_head_dim;
738    for l in 0..a.local_layers {
739        let n = |leaf: &str| names::aud_layer(l, leaf);
740        push(n("input_layernorm.weight"), vec![d]);
741        push(n("post_attention_layernorm.weight"), vec![d]);
742        for p in ["q_proj", "k_proj", "v_proj"] {
743            push(n(&format!("self_attn.{p}.weight")), vec![qd, d]);
744            push(n(&format!("self_attn.{p}.bias")), vec![qd]);
745        }
746        push(n("self_attn.o_proj.weight"), vec![d, qd]);
747        push(n("mlp.gate_proj.weight"), vec![ai, d]);
748        push(n("mlp.up_proj.weight"), vec![ai, d]);
749        push(n("mlp.down_proj.weight"), vec![d, ai]);
750    }
751    if a.post_norm {
752        push(names::AUD_LOCAL_NORM.into(), vec![d]);
753    }
754    if a.projection_layers == 2 {
755        push(
756            names::AUD_PROJ_FC1.into(),
757            vec![4 * a.proj_in(), a.proj_in()],
758        );
759        push(
760            names::AUD_PROJ_FC2.into(),
761            vec![a.out_hidden, 4 * a.proj_in()],
762        );
763    } else {
764        push(
765            names::AUD_PROJ_SINGLE.into(),
766            vec![a.out_hidden, a.proj_in()],
767        );
768    }
769    for c in 0..a.channels {
770        push(names::speech_embedding(c), vec![a.speech_vocab[c], d]);
771    }
772
773    // Audio tokenizer encoder.
774    let (m, f) = (t.d_model, t.ffn);
775    push(names::at("conv1.weight"), vec![m, t.n_mels, t.kernel_size]);
776    push(names::at("conv1.bias"), vec![m]);
777    push(names::at("conv2.weight"), vec![m, m, t.kernel_size]);
778    push(names::at("conv2.bias"), vec![m]);
779    for l in 0..t.layers {
780        let n = |leaf: &str| names::at_layer(l, leaf);
781        push(n("self_attn.k_proj.weight"), vec![m, m]);
782        for p in ["q_proj", "v_proj", "out_proj"] {
783            push(n(&format!("self_attn.{p}.weight")), vec![m, m]);
784            push(n(&format!("self_attn.{p}.bias")), vec![m]);
785        }
786        push(n("self_attn_layer_norm.weight"), vec![m]);
787        push(n("self_attn_layer_norm.bias"), vec![m]);
788        push(n("final_layer_norm.weight"), vec![m]);
789        push(n("final_layer_norm.bias"), vec![m]);
790        push(n("fc1.weight"), vec![f, m]);
791        push(n("fc1.bias"), vec![f]);
792        push(n("fc2.weight"), vec![m, f]);
793        push(n("fc2.bias"), vec![m]);
794    }
795    push(names::at("layer_norm.weight"), vec![m]);
796    push(names::at("layer_norm.bias"), vec![m]);
797    if t.avg_pooler != 1 {
798        push(
799            names::at("down_sample_layer.0.weight"),
800            vec![m, m, t.avg_pooler],
801        );
802        push(names::at("down_sample_norm.weight"), vec![m]);
803        push(names::at("down_sample_norm.bias"), vec![m]);
804    }
805    for (q, &bins) in t.codebook_sizes.iter().enumerate() {
806        push(names::at_codebook(q), vec![bins, m]);
807    }
808    Ok(out)
809}
810
811fn json_blob(model: &CmfModel, name: &str) -> Result<Value, String> {
812    let e = model
813        .tensor(name)
814        .ok_or_else(|| format!("{}: no '{name}' tensor", model.path.display()))?;
815    if e.dtype != TensorDtype::U8 {
816        return Err(format!("'{name}' is {:?}, expected U8 JSON bytes", e.dtype));
817    }
818    serde_json::from_slice(model.entry_bytes(e)).map_err(|e| format!("'{name}': {e}"))
819}
820
821/// Where the tower tensors came from.
822#[derive(Clone, Copy, Debug, PartialEq, Eq)]
823pub enum MimoMmSource {
824    /// A `mimo_v2_mm` companion file.
825    Companion,
826    /// A single-file multimodal `mimo_v2` CMF.
827    SingleFile,
828}
829
830/// Data holder for the MiMo-V2 towers: the container handle, the parsed
831/// configs and geometry, and typed tensor access. Building one validates
832/// the whole tower inventory (names, shapes, codebook dtype) and the pinned
833/// special ids; [`Self::validate_text`] checks the pairing with a text model.
834pub struct MimoMm {
835    model: Arc<CmfModel>,
836    pub source: MimoMmSource,
837    /// The full source `config.json`.
838    pub config: Value,
839    /// `audio_tokenizer/config.json`.
840    pub audio_tokenizer_config: Value,
841    pub vision: MimoVisionGeom,
842    pub audio: MimoAudioGeom,
843    pub audio_tokenizer: MimoAudioTokenizerGeom,
844    pub tokens: MimoTokenIds,
845    /// LLM hidden size the towers project into (4096).
846    pub hidden_size: usize,
847    /// `provenance.mimo_mm` as written by the converter (codec per group,
848    /// config sha256, source revision), when present.
849    pub provenance: Option<Value>,
850}
851
852impl std::fmt::Debug for MimoMm {
853    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
854        f.debug_struct("MimoMm")
855            .field("path", &self.model.path)
856            .field("source", &self.source)
857            .field("hidden_size", &self.hidden_size)
858            .finish()
859    }
860}
861
862impl MimoMm {
863    /// Open a companion (or single-file multimodal CMF) by path.
864    pub fn open(path: impl AsRef<Path>) -> Result<Self, String> {
865        let path = path.as_ref();
866        let model = CmfModel::open(path)
867            .map_err(|e| format!("open MiMo companion {}: {e}", path.display()))?;
868        Self::from_model(&Arc::new(model))
869    }
870
871    /// Build from an open container: a `mimo_v2_mm` companion, or a
872    /// `mimo_v2` file that carries the towers. Every inconsistency is an
873    /// error; nothing is defaulted.
874    pub fn from_model(model: &Arc<CmfModel>) -> Result<Self, String> {
875        let arch = model.arch();
876        let where_ = model.path.display().to_string();
877        let prov = model
878            .header
879            .provenance
880            .as_ref()
881            .and_then(|p| p.get("mimo_mm"))
882            .cloned();
883        let source = match arch.arch_name.as_str() {
884            MIMO_MM_ARCH => {
885                let base = prov
886                    .as_ref()
887                    .and_then(|p| p.get("base_arch"))
888                    .and_then(Value::as_str);
889                if base != Some(MIMO_BASE_ARCH) {
890                    return Err(format!(
891                        "{where_}: companion base_arch {base:?}, expected '{MIMO_BASE_ARCH}'"
892                    ));
893                }
894                MimoMmSource::Companion
895            }
896            MIMO_BASE_ARCH => {
897                if model.tensor(MM_CONFIG_BLOB).is_none() {
898                    return Err(format!(
899                        "{where_}: text-only mimo_v2 file (no '{MM_CONFIG_BLOB}'); \
900                         attach a {MIMO_MM_ARCH} companion instead"
901                    ));
902                }
903                MimoMmSource::SingleFile
904            }
905            other => {
906                return Err(format!(
907                    "{where_}: arch '{other}' is neither '{MIMO_MM_ARCH}' nor '{MIMO_BASE_ARCH}'"
908                ));
909            }
910        };
911        let config = json_blob(model, MM_CONFIG_BLOB)?;
912        let at_config = json_blob(model, AUDIO_TOKENIZER_CONFIG_BLOB)?;
913        let mt = config.get("model_type").and_then(Value::as_str);
914        if mt != Some(MIMO_BASE_ARCH) {
915            return Err(format!(
916                "{where_}: {MM_CONFIG_BLOB} model_type {mt:?}, expected '{MIMO_BASE_ARCH}'"
917            ));
918        }
919        let hidden = cfg_usize(&config, "hidden_size", "config.json")?;
920        if hidden != arch.hidden_size {
921            return Err(format!(
922                "{where_}: header hidden_size {} != config hidden_size {hidden}",
923                arch.hidden_size
924            ));
925        }
926        let tokens = MimoTokenIds::from_config(&config)?;
927        tokens.check_pinned(&where_)?;
928        let vision = MimoVisionGeom::from_config(&config)?;
929        let audio = MimoAudioGeom::from_config(&config)?;
930        let audio_tokenizer = MimoAudioTokenizerGeom::from_config(&at_config)?;
931        if vision.out_hidden != hidden || audio.out_hidden != hidden {
932            return Err(format!(
933                "{where_}: tower outputs (vision {}, audio {}) != LLM hidden {hidden}",
934                vision.out_hidden, audio.out_hidden
935            ));
936        }
937
938        // The inventory: every expected tensor present with its shape, and
939        // no unknown tower tensor (a newer or older converter layout).
940        let inv = mimo_tower_inventory(&config, &at_config)?;
941        let mut missing = Vec::new();
942        for (name, shape) in &inv {
943            match model.tensor(name) {
944                None => missing.push(name.clone()),
945                Some(e) if &e.shape != shape => {
946                    return Err(format!(
947                        "{where_}: '{name}' has shape {:?}, expected {shape:?}",
948                        e.shape
949                    ));
950                }
951                Some(e) if is_codebook(name) && e.dtype != TensorDtype::F32 => {
952                    return Err(format!(
953                        "{where_}: RVQ codebook '{name}' is {:?}; it must be F32",
954                        e.dtype
955                    ));
956                }
957                Some(_) => {}
958            }
959        }
960        if !missing.is_empty() {
961            return Err(format!(
962                "{where_}: {} tower tensors missing, e.g. {:?}",
963                missing.len(),
964                &missing[..missing.len().min(4)]
965            ));
966        }
967        let known: std::collections::HashSet<&str> = inv.iter().map(|(n, _)| n.as_str()).collect();
968        let unknown: Vec<&str> = model
969            .tensors
970            .iter()
971            .map(|t| t.name.as_str())
972            .filter(|n| MimoTowerGroup::of(n).is_some() && !known.contains(n))
973            .collect();
974        if !unknown.is_empty() {
975            return Err(format!(
976                "{where_}: {} unexpected tower tensors, e.g. {:?}",
977                unknown.len(),
978                &unknown[..unknown.len().min(4)]
979            ));
980        }
981        if source == MimoMmSource::Companion {
982            let extra: Vec<&str> = model
983                .tensors
984                .iter()
985                .map(|t| t.name.as_str())
986                .filter(|n| {
987                    MimoTowerGroup::of(n).is_none()
988                        && *n != MM_CONFIG_BLOB
989                        && *n != AUDIO_TOKENIZER_CONFIG_BLOB
990                })
991                .collect();
992            if !extra.is_empty() {
993                return Err(format!(
994                    "{where_}: companion carries non-tower tensors, e.g. {:?}",
995                    &extra[..extra.len().min(4)]
996                ));
997            }
998        }
999        Ok(Self {
1000            model: model.clone(),
1001            source,
1002            config,
1003            audio_tokenizer_config: at_config,
1004            vision,
1005            audio,
1006            audio_tokenizer,
1007            tokens,
1008            hidden_size: hidden,
1009            provenance: prov,
1010        })
1011    }
1012
1013    /// Find the companion of a text CMF: `explicit` (must exist), else
1014    /// `<stem>.mm.cmf` beside it (the stem without `.cmf` and without a
1015    /// `-NNNNN-of-NNNNN` shard suffix), else the only `*.mm.cmf` in the
1016    /// directory. `Ok(None)` when there is none; two candidates are an error.
1017    pub fn discover(text_path: &Path, explicit: Option<&Path>) -> Result<Option<PathBuf>, String> {
1018        if let Some(p) = explicit {
1019            if !p.is_file() {
1020                return Err(format!("--mm {}: no such file", p.display()));
1021            }
1022            return Ok(Some(p.to_path_buf()));
1023        }
1024        let dir = match text_path.parent() {
1025            Some(d) if !d.as_os_str().is_empty() => d.to_path_buf(),
1026            _ => PathBuf::from("."),
1027        };
1028        let file = text_path
1029            .file_name()
1030            .map(|f| f.to_string_lossy().into_owned())
1031            .unwrap_or_default();
1032        if file.ends_with(MM_SUFFIX) {
1033            return Ok(None);
1034        }
1035        let stem = file.strip_suffix(".cmf").unwrap_or(&file);
1036        let stem = strip_shard_suffix(stem);
1037        let sibling = dir.join(format!("{stem}{MM_SUFFIX}"));
1038        if sibling.is_file() {
1039            return Ok(Some(sibling));
1040        }
1041        let mut found: Vec<PathBuf> = std::fs::read_dir(&dir)
1042            .map_err(|e| format!("scan {} for {MM_SUFFIX}: {e}", dir.display()))?
1043            .filter_map(|e| e.ok().map(|e| e.path()))
1044            .filter(|p| {
1045                p.is_file()
1046                    && p.file_name()
1047                        .is_some_and(|f| f.to_string_lossy().ends_with(MM_SUFFIX))
1048            })
1049            .collect();
1050        found.sort();
1051        match found.len() {
1052            0 => Ok(None),
1053            1 => Ok(found.pop()),
1054            _ => Err(format!(
1055                "{} MiMo companions next to {} ({}); pass --mm PATH",
1056                found.len(),
1057                text_path.display(),
1058                found
1059                    .iter()
1060                    .map(|p| p.display().to_string())
1061                    .collect::<Vec<_>>()
1062                    .join(", ")
1063            )),
1064        }
1065    }
1066
1067    /// Towers for a text model: the file's own towers (single-file
1068    /// multimodal), else a discovered or explicit companion — validated
1069    /// against the text model ([`Self::validate_text_model`]). `Ok(None)`:
1070    /// the model is not `mimo_v2`, or no towers exist anywhere. An explicit
1071    /// `--mm` on a non-MiMo model is an error.
1072    pub fn attach(text: &Arc<CmfModel>, explicit: Option<&Path>) -> Result<Option<Self>, String> {
1073        if text.arch().arch_name != MIMO_BASE_ARCH {
1074            if let Some(p) = explicit {
1075                return Err(format!(
1076                    "--mm {}: the model is '{}', not {MIMO_BASE_ARCH}",
1077                    p.display(),
1078                    text.arch().arch_name
1079                ));
1080            }
1081            return Ok(None);
1082        }
1083        if explicit.is_none() && text.tensor(MM_CONFIG_BLOB).is_some() {
1084            let mm = Self::from_model(text)?;
1085            mm.validate_text_model(text)?;
1086            return Ok(Some(mm));
1087        }
1088        let Some(path) = Self::discover(&text.path, explicit)? else {
1089            return Ok(None);
1090        };
1091        let mm = Self::open(&path)?;
1092        mm.validate_text_model(text)?;
1093        Ok(Some(mm))
1094    }
1095
1096    /// [`Self::validate_text`] with the tokenizer embedded in the text CMF
1097    /// (a text file without one cannot be checked and is refused).
1098    pub fn validate_text_model(&self, text: &CmfModel) -> Result<(), String> {
1099        let vocab = text.vocab.as_deref().ok_or_else(|| {
1100            format!(
1101                "{}: no embedded tokenizer — the MiMo special ids cannot be checked",
1102                text.path.display()
1103            )
1104        })?;
1105        let tok = Tokenizer::from_bytes(vocab)
1106            .map_err(|e| format!("{}: tokenizer: {e}", text.path.display()))?;
1107        self.validate_text(text, &tok)
1108    }
1109
1110    /// Hard checks that this tower set belongs to `text`: arch `mimo_v2`,
1111    /// the same hidden size, and a tokenizer that maps the nine special
1112    /// tokens to the pinned ids.
1113    pub fn validate_text(&self, text: &CmfModel, tok: &Tokenizer) -> Result<(), String> {
1114        let a = text.arch();
1115        let where_ = text.path.display();
1116        if a.arch_name != MIMO_BASE_ARCH {
1117            return Err(format!(
1118                "{where_}: arch '{}' — MiMo towers attach only to {MIMO_BASE_ARCH}",
1119                a.arch_name
1120            ));
1121        }
1122        if a.hidden_size != self.hidden_size {
1123            return Err(format!(
1124                "{where_}: text hidden_size {} != tower hidden_size {} ({})",
1125                a.hidden_size,
1126                self.hidden_size,
1127                self.model.path.display()
1128            ));
1129        }
1130        for (name, want) in self.tokens.named() {
1131            match tok.token_to_id(name) {
1132                Some(got) if got == want => {}
1133                got => {
1134                    return Err(format!(
1135                        "{where_}: tokenizer maps {name} to {got:?}, the towers need {want}"
1136                    ));
1137                }
1138            }
1139            if want as usize >= a.vocab_size {
1140                return Err(format!(
1141                    "{where_}: special id {want} ({name}) outside vocab_size {}",
1142                    a.vocab_size
1143                ));
1144            }
1145        }
1146        Ok(())
1147    }
1148
1149    /// The container the tower tensors live in.
1150    pub fn model(&self) -> &Arc<CmfModel> {
1151        &self.model
1152    }
1153
1154    pub fn has(&self, name: &str) -> bool {
1155        self.model.tensor(name).is_some()
1156    }
1157
1158    /// Directory entry (dtype, shape) of a tensor.
1159    pub fn entry(&self, name: &str) -> Result<&TensorEntry, String> {
1160        self.model
1161            .tensor(name)
1162            .ok_or_else(|| format!("{}: missing tensor '{name}'", self.model.path.display()))
1163    }
1164
1165    /// Storage dtype of a tensor, if present.
1166    pub fn dtype(&self, name: &str) -> Option<TensorDtype> {
1167        self.model.tensor(name).map(|e| e.dtype)
1168    }
1169
1170    /// A 2-D weight `[rows, cols]` on the engine's kernels (quantized
1171    /// payloads stay memory-mapped; F16/F32 dequantize to f32 once).
1172    pub fn linear(&self, name: &str, rows: usize, cols: usize) -> Result<QTensor, String> {
1173        let e = self.entry(name)?;
1174        if e.shape != [rows, cols] {
1175            return Err(format!(
1176                "'{name}' has shape {:?}, expected [{rows}, {cols}]",
1177                e.shape
1178            ));
1179        }
1180        QTensor::from_model(&self.model, name)
1181    }
1182
1183    /// As [`Self::linear`], as the tower-side `Proj` (exact f32 GEMM arm for
1184    /// float payloads).
1185    #[allow(dead_code)] // the vision/audio tower modules are its callers
1186    pub(crate) fn proj(
1187        &self,
1188        name: &str,
1189        rows: usize,
1190        cols: usize,
1191    ) -> Result<crate::dit::Proj, String> {
1192        self.linear(name, rows, cols)?;
1193        crate::dit::Proj::from_model(&self.model, name)
1194    }
1195
1196    /// Any tensor dequantized to f32 (row-major, directory shape).
1197    pub fn f32(&self, name: &str) -> Result<Vec<f32>, String> {
1198        crate::dit::cmf_f32(&self.model, name)
1199    }
1200
1201    /// [`Self::f32`] with a shape check.
1202    pub fn f32_shaped(&self, name: &str, shape: &[usize]) -> Result<Vec<f32>, String> {
1203        let e = self.entry(name)?;
1204        if e.shape != shape {
1205            return Err(format!(
1206                "'{name}' has shape {:?}, expected {shape:?}",
1207                e.shape
1208            ));
1209        }
1210        self.f32(name)
1211    }
1212
1213    /// Codec recorded by the converter for a tower group ("q4tp", "f16", …).
1214    pub fn group_codec(&self, group: MimoTowerGroup) -> Option<&str> {
1215        self.provenance
1216            .as_ref()?
1217            .get("codec")?
1218            .get(group.label())?
1219            .get("matrices")?
1220            .as_str()
1221    }
1222}
1223
1224/// `name-00001-of-00003` → `name`.
1225fn strip_shard_suffix(stem: &str) -> &str {
1226    let b = stem.as_bytes();
1227    // "-NNNNN-of-NNNNN" is 15 bytes.
1228    if b.len() > 15 {
1229        let tail = &stem[stem.len() - 15..];
1230        let t = tail.as_bytes();
1231        let digits = |r: std::ops::Range<usize>| t[r].iter().all(u8::is_ascii_digit);
1232        if t[0] == b'-' && digits(1..6) && &tail[6..10] == "-of-" && digits(10..15) {
1233            return &stem[..stem.len() - 15];
1234        }
1235    }
1236    stem
1237}
1238
1239#[cfg(test)]
1240mod tests {
1241    use super::*;
1242
1243    #[test]
1244    fn shard_suffix_is_stripped_only_when_exact() {
1245        assert_eq!(strip_shard_suffix("mimo-q4tp-00001-of-00004"), "mimo-q4tp");
1246        assert_eq!(strip_shard_suffix("mimo-q4tp"), "mimo-q4tp");
1247        assert_eq!(strip_shard_suffix("x-0001-of-00004"), "x-0001-of-00004");
1248    }
1249
1250    #[test]
1251    fn per_channel_accepts_scalar_string_and_list() {
1252        let c = serde_json::json!({"a": "1280", "b": 7, "c": "1-2-3"});
1253        assert_eq!(per_channel(&c, "a", 2).unwrap(), vec![1280, 1280]);
1254        assert_eq!(per_channel(&c, "b", 3).unwrap(), vec![7, 7, 7]);
1255        assert_eq!(per_channel(&c, "c", 3).unwrap(), vec![1, 2, 3]);
1256        assert!(per_channel(&c, "c", 2).is_err());
1257        assert!(per_channel(&c, "missing", 2).is_err());
1258    }
1259}