Skip to main content

combs_formats/
tflite.rs

1//! TFLite flatbuffer model source (`.tflite` / raw `.task` — `TFL3`).
2//!
3//! Recon-verified layout:
4//! - `Model { subgraphs, buffers, metadata }` at the flatbuffer root
5//! - weight tensors live in `Buffer { offset, size }` absolute file ranges
6//!   (no inline data vectors in ODML exports)
7//! - the model graph in these exports is a weight carrier: tensor NAMES
8//!   follow the ODML scheme (`transformer.layer_{i}.attn.q.w`), quantized
9//!   as int8 (+ `w_quantized_scale` f32 per output row, `w.sum_i` i32
10//!   compensation sums — unused by our f32 compute)
11//! - `metadata[]` carries `odml.infra.proto.LlmParameters` (config proto)
12//!   and `spm_vocab_model` (SentencePiece tokenizer)
13//!
14//! This block maps ODML names → canonical HF names so the architecture
15//! blocks (gemma.rs) load unchanged: **format ⊥ architecture**.
16
17use std::collections::HashMap;
18use std::path::{Path, PathBuf};
19
20use memmap2::Mmap;
21
22use crate::flatbuf::FlatBuffer;
23use crate::metadata::AttentionPattern;
24use crate::protomin::Proto;
25use crate::source::{ModelSource, TensorDtype, TensorReader};
26use crate::tokenizer::TokenizerSpec;
27use crate::{FormatError, ModelMetadata, Result};
28
29fn bad(what: impl Into<String>) -> FormatError {
30    FormatError::Safetensors(format!("tflite: {}", what.into()))
31}
32
33/// Tensor types we read from the schema enum (subset).
34const TFLITE_F32: u8 = 0;
35const TFLITE_INT8: u8 = 9;
36
37/// One tensor entry in the subgraph (pre-mapping).
38struct RawTensor {
39    name: String,
40    shape: Vec<usize>,
41    dtype: u8,
42    buffer: usize,
43}
44
45/// A buffer's absolute file range.
46#[derive(Clone, Copy)]
47struct BufferSpan {
48    offset: usize,
49    size: usize,
50}
51
52/// Quantization companions for an int8 weight: per-output-row f32 scales.
53struct QuantInfo {
54    scale_buffer: usize,
55    rows: usize,
56}
57
58/// ODML → HF name mapping for the Gemma family. Returns None for tensors
59/// we deliberately skip (activation scales, cache quant scales, per-layer
60/// embeddings in MatFormer-family exports — see gemma4 notes).
61fn map_name(odml: &str) -> Option<String> {
62    let rest = odml.strip_prefix("transformer.")?;
63    if rest == "embedder.input_embedding.w" {
64        return Some("model.embed_tokens.weight".into());
65    }
66    if rest == "final_norm.scale" {
67        return Some("model.norm.weight".into());
68    }
69    let layer = rest.strip_prefix("layer_")?;
70    let (idx, sub) = layer.split_once('.')?;
71    let p = format!("model.layers.{idx}");
72    let mapped = match sub {
73        "attn.q.w" => "self_attn.q_proj.weight",
74        "attn.k.w" => "self_attn.k_proj.weight",
75        "attn.v.w" => "self_attn.v_proj.weight",
76        "attn.attn_vec_einsum.w" => "self_attn.o_proj.weight",
77        "attn.q_norm.scale" => "self_attn.q_norm.weight",
78        "attn.k_norm.scale" => "self_attn.k_norm.weight",
79        "pre_attention_norm.scale" => "input_layernorm.weight",
80        "post_attention_norm.scale" => "post_attention_layernorm.weight",
81        "pre_ffw_norm.scale" => "pre_feedforward_layernorm.weight",
82        "post_ffw_norm.scale" => "post_feedforward_layernorm.weight",
83        "mlp.ff_gate.w" => "mlp.gate_proj.weight",
84        "mlp.ff1.w" | "mlp.ffn.w" => "mlp.up_proj.weight",
85        "mlp.linear.w" => "mlp.down_proj.weight",
86        _ => return None, // activation scales, caches, PLE (gemma4)…
87    };
88    Some(format!("{p}.{mapped}"))
89}
90
91/// Parsed `odml.infra.proto.LlmParameters` (fields decoded empirically —
92/// see formats-recon.md; the proto is not published as a .proto).
93#[derive(Default, Debug)]
94struct LlmParameters {
95    vocab_size: usize,
96    hidden_size: usize,
97    intermediate_size: usize,
98    num_hidden_layers: usize,
99    num_attention_heads: usize,
100    num_key_value_heads: usize,
101    head_dim: usize,
102    sliding_window: Option<usize>,
103    sliding_window_pattern: usize,
104    rope_theta: f64,
105    rope_local_theta: f64,
106    query_pre_attn_scalar: Option<f64>,
107    eos_token_ids: Vec<u32>,
108    bos_token_id: Option<u32>,
109}
110
111/// The model-params sub-message of LlmParameters (field 1). Field numbers
112/// were recovered by decoding a gemma-4 export and matching known config
113/// values; calibrated per model family at load time by the E2E tests.
114fn parse_llm_parameters(buf: &[u8]) -> Result<LlmParameters> {
115    let mut out = LlmParameters::default();
116    let mut p = Proto::new(buf);
117    while let Some((field, wire)) = p.tag()? {
118        match (field, wire) {
119            (1, 2) => {
120                let sub = p.bytes()?;
121                let mut sp = Proto::new(sub);
122                while let Some((f, w)) = sp.tag()? {
123                    match (f, w) {
124                        (3, 0) => out.hidden_size = sp.varint()? as usize,
125                        (4, 0) => out.intermediate_size = sp.varint()? as usize,
126                        (5, 0) => out.head_dim = sp.varint()? as usize,
127                        (6, 0) => out.num_attention_heads = sp.varint()? as usize,
128                        (7, 0) => out.num_hidden_layers = sp.varint()? as usize,
129                        (9, 0) => out.num_key_value_heads = sp.varint()? as usize,
130                        (_, w) => sp.skip(w)?,
131                    }
132                }
133            }
134            (2, 0) => out.vocab_size = p.varint()? as usize,
135            (4, 0) => out.bos_token_id = Some(p.varint()? as u32),
136            (_, w) => p.skip(w)?,
137        }
138    }
139    Ok(out)
140}
141
142/// A TFLite model file as a [`ModelSource`] (Gemma-family exports).
143pub struct TfliteSource {
144    _mmap: Mmap,
145    spans: Vec<BufferSpan>,
146    /// HF name → (raw tensor index, quant info).
147    tensors: HashMap<String, (usize, Option<QuantInfo>)>,
148    raws: Vec<RawTensor>,
149    metadata: ModelMetadata,
150    tokenizer_json: PathBuf,
151    added_tokens: HashMap<u32, String>,
152}
153
154impl TfliteSource {
155    /// Opens a raw `.tflite`/`.task` file (TFL3 flatbuffer).
156    pub fn load(path: impl AsRef<Path>) -> Result<Self> {
157        Self::load_at(path.as_ref(), 0)
158    }
159
160    /// Opens a TFL3 flatbuffer at byte offset `base` of `path` (base != 0
161    /// when the flatbuffer is a section inside a `.litertlm` container).
162    /// Buffer spans are section-relative in that case and are rebased to
163    /// absolute file offsets, so tensor reads stay zero-copy.
164    pub(crate) fn load_at(path: &Path, base: usize) -> Result<Self> {
165        Self::load_at_with_spm(path, base, None)
166    }
167
168    /// [`TfliteSource::load_at`] with an optional tokenizer blob override
169    /// (a `.litertlm` container can carry the tokenizer as its own
170    /// section when the TFLite section's metadata doesn't).
171    pub(crate) fn load_at_with_spm(
172        path: &Path,
173        base: usize,
174        spm_override: Option<&[u8]>,
175    ) -> Result<Self> {
176        let file = std::fs::File::open(path)?;
177        let mmap = unsafe { Mmap::map(&file)? };
178        let fb = FlatBuffer::new(&mmap[base..], Some(b"TFL3"))?;
179        let root = fb.root();
180
181        // Buffers: { data(0) inline | offset(1), size(2) } — rebased to
182        // absolute file offsets.
183        let buffer_tables = fb.table_vector(root, 4)?;
184        let mut spans = Vec::with_capacity(buffer_tables.len());
185        for &b in &buffer_tables {
186            let offset = fb.scalar_u64(b, 1).unwrap_or(0) as usize;
187            let size = fb.scalar_u64(b, 2).unwrap_or(0) as usize;
188            spans.push(BufferSpan { offset: base + offset, size });
189        }
190
191        // Subgraph 0 tensors.
192        let subgraphs = fb.table_vector(root, 2)?;
193        let sg = subgraphs
194            .first()
195            .ok_or_else(|| bad("no subgraphs"))?;
196        let tensor_tables = fb.table_vector(*sg, 0)?;
197        let mut raws = Vec::with_capacity(tensor_tables.len());
198        for &t in &tensor_tables {
199            let name = fb.string(t, 3)?.unwrap_or_default();
200            let (sp, sn) = fb.vector(t, 0)?;
201            let shape = match sp {
202                Some(p) => fb
203                    .i32_slice(p, sn)?
204                    .into_iter()
205                    .map(|d| d as usize)
206                    .collect(),
207                None => Vec::new(),
208            };
209            let dtype = fb.scalar_u8(t, 1).unwrap_or(TFLITE_F32);
210            let buffer = fb.scalar_u32(t, 2).unwrap_or(0) as usize;
211            raws.push(RawTensor { name, shape, dtype, buffer });
212        }
213
214        // Metadata entries: name → buffer index.
215        let mut meta_buffers: HashMap<String, usize> = HashMap::new();
216        for &m in &fb.table_vector(root, 6)? {
217            if let Some(name) = fb.string(m, 0)? {
218                let idx = fb.scalar_u32(m, 1).unwrap_or(0) as usize;
219                meta_buffers.insert(name, idx);
220            }
221        }
222
223        // Config proto → ModelMetadata.
224        let params = {
225            let idx = meta_buffers
226                .get("odml.infra.proto.LlmParameters")
227                .ok_or_else(|| bad("no LlmParameters metadata"))?;
228            let bytes = read_span(&mmap, spans[*idx])?;
229            parse_llm_parameters(bytes)?
230        };
231        let metadata = metadata_from_params(&params)?;
232
233        // Tokenizer: spm blob → cached tokenizer.json (the U0 block).
234        // Source: container section override, else the TFLite metadata.
235        let spm_bytes: Vec<u8> = match spm_override {
236            Some(b) => b.to_vec(),
237            None => {
238                let spm_idx = meta_buffers
239                    .get("spm_vocab_model")
240                    .ok_or_else(|| bad("no spm_vocab_model metadata"))?;
241                read_span(&mmap, spans[*spm_idx])?.to_vec()
242            }
243        };
244        let spm_path = path.with_extension("extracted.spm.model");
245        if !spm_path.exists() {
246            std::fs::write(&spm_path, &spm_bytes)?;
247        }
248        let tokenizer_json = crate::spm::ensure_tokenizer_json_from_spm(&spm_path)?;
249        let added_tokens = crate::spm::spm_added_tokens(&spm_path)?;
250
251        // Name mapping + quantization companions.
252        let mut tensors: HashMap<String, (usize, Option<QuantInfo>)> = HashMap::new();
253        for (i, raw) in raws.iter().enumerate() {
254            let Some(hf) = map_name(&raw.name) else { continue };
255            let quant = if raw.dtype == TFLITE_INT8 {
256                let scale_name = format!("{}_quantized_scale", raw.name);
257                let scale_idx = raws
258                    .iter()
259                    .position(|r| r.name == scale_name)
260                    .ok_or_else(|| bad(format!("missing scales for {}", raw.name)))?;
261                Some(QuantInfo {
262                    scale_buffer: raws[scale_idx].buffer,
263                    rows: raws[scale_idx].shape.first().copied().unwrap_or(0),
264                })
265            } else {
266                None
267            };
268            tensors.insert(hf, (i, quant));
269        }
270
271        Ok(TfliteSource {
272            _mmap: mmap,
273            spans,
274            tensors,
275            raws,
276            metadata,
277            tokenizer_json,
278            added_tokens,
279        })
280    }
281}
282
283fn read_span(mmap: &Mmap, span: BufferSpan) -> Result<&[u8]> {
284    mmap.get(span.offset..span.offset + span.size)
285        .ok_or_else(|| bad(format!("buffer out of bounds @{}+{}", span.offset, span.size)))
286}
287
288fn metadata_from_params(p: &LlmParameters) -> Result<ModelMetadata> {
289    if p.hidden_size == 0 || p.num_hidden_layers == 0 || p.vocab_size == 0 {
290        return Err(FormatError::MissingField(format!(
291            "LlmParameters incomplete: {p:?}"
292        )));
293    }
294    Ok(ModelMetadata {
295        architecture: "gemma3_text".to_string(),
296        hidden_size: p.hidden_size,
297        intermediate_size: p.intermediate_size,
298        num_hidden_layers: p.num_hidden_layers,
299        num_attention_heads: p.num_attention_heads,
300        num_key_value_heads: p.num_key_value_heads,
301        vocab_size: p.vocab_size,
302        max_position_embeddings: 32768,
303        rms_norm_eps: 1e-6,
304        rope_theta: if p.rope_theta > 0.0 { p.rope_theta } else { 1_000_000.0 },
305        tie_word_embeddings: true, // ODML exports carry a single embedding table
306        head_dim: p.head_dim,
307        attention_bias: false,
308        bos_token_id: p.bos_token_id,
309        eos_token_ids: p.eos_token_ids.clone(),
310        vision: None,
311        attention_pattern: AttentionPattern {
312            sliding_window: p.sliding_window.or(Some(512)),
313            pattern: if p.sliding_window_pattern > 0 { p.sliding_window_pattern } else { 6 },
314            rope_local_theta: if p.rope_local_theta > 0.0 { p.rope_local_theta } else { 10_000.0 },
315            query_pre_attn_scalar: p.query_pre_attn_scalar,
316            max_window_layers: None,
317        },
318        // ODML text exports are gemma-family (gelu-tanh MLPs).
319        activation: crate::metadata::Activation::GeluTanh,
320        rope_scaling: crate::metadata::RopeScaling::None,
321    })
322}
323
324impl ModelSource for TfliteSource {
325    fn metadata(&self) -> &ModelMetadata {
326        &self.metadata
327    }
328
329    fn tensor_names(&self) -> Vec<String> {
330        self.tensors.keys().cloned().collect()
331    }
332
333    fn open_tensor(&self, name: &str) -> Result<TensorReader<'_>> {
334        let (idx, quant) = self
335            .tensors
336            .get(name)
337            .ok_or_else(|| FormatError::TensorNotFound(name.to_string()))?;
338        let raw = &self.raws[*idx];
339        let data = read_span(&self._mmap, self.spans[raw.buffer])?;
340        let n: usize = raw.shape.iter().product();
341        // ODML exports flatten weights to 1-D; the logical [in, out] shape
342        // is rebuilt from the output dim (scale-companion length or the
343        // architecture config).
344        let out_dim = match quant {
345            Some(q) => q.rows,
346            None => logical_out_dim(&self.metadata, name, n)?,
347        };
348        let logical = vec![n / out_dim, out_dim];
349
350        match raw.dtype {
351            TFLITE_F32 => {
352                let values: Vec<f32> = data
353                    .chunks_exact(4)
354                    .take(n)
355                    .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
356                    .collect();
357                let (values, shape) = reorient(&self.metadata, name, values, &logical)?;
358                let bytes: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
359                Ok(TensorReader::owned(name.to_string(), shape, bytes))
360            }
361            TFLITE_INT8 => {
362                let q = quant
363                    .as_ref()
364                    .ok_or_else(|| bad(format!("int8 tensor {} without scales", raw.name)))?;
365                let scales_raw = read_span(&self._mmap, self.spans[q.scale_buffer])?;
366                let scales: Vec<f32> = scales_raw
367                    .chunks_exact(4)
368                    .take(q.rows)
369                    .map(|c| f32::from_le_bytes([c[0], c[1], c[2], c[3]]))
370                    .collect();
371                let values = dequant_i8(data, &scales, &logical)?;
372                let (values, shape) = reorient(&self.metadata, name, values, &logical)?;
373                let bytes: Vec<u8> = values.iter().flat_map(|v| v.to_le_bytes()).collect();
374                Ok(TensorReader::owned(name.to_string(), shape, bytes))
375            }
376            other => Err(FormatError::UnsupportedDtype {
377                tensor: raw.name.clone(),
378                dtype: format!("tflite type {other} (int4 packed: follow-up)"),
379            }),
380        }
381    }
382
383    fn tokenizer(&self) -> Result<TokenizerSpec> {
384        Ok(TokenizerSpec {
385            tokenizer_json: self.tokenizer_json.clone(),
386            added_tokens: self.added_tokens.clone(),
387            chat_template: None,
388            add_bos: None,
389        })
390    }
391
392    fn sampler_defaults(&self) -> Option<crate::SamplerConfig> {
393        None
394    }
395}
396
397/// Output dimension for a mapped HF weight, from the architecture config
398/// (used when no scale companion exists, e.g. f32 exports).
399fn logical_out_dim(m: &ModelMetadata, hf_name: &str, n: usize) -> Result<usize> {
400    if hf_name.ends_with("_layernorm.weight")
401        || hf_name.ends_with("_norm.weight")
402        || hf_name == "model.norm.weight"
403    {
404        return Ok(n); // 1-D: shape is [hidden] already
405    }
406    if hf_name == "model.embed_tokens.weight" || hf_name == "lm_head.weight" {
407        return Ok(m.hidden_size); // [vocab, hidden] — no transpose below
408    }
409    Ok(if hf_name.ends_with("q_proj.weight") {
410        m.num_attention_heads * m.head_dim
411    } else if hf_name.ends_with("k_proj.weight") || hf_name.ends_with("v_proj.weight") {
412        m.num_key_value_heads * m.head_dim
413    } else if hf_name.ends_with("o_proj.weight") {
414        m.hidden_size
415    } else if hf_name.ends_with("gate_proj.weight") || hf_name.ends_with("up_proj.weight") {
416        m.intermediate_size
417    } else if hf_name.ends_with("down_proj.weight") {
418        m.hidden_size
419    } else {
420        return Err(bad(format!("no logical shape rule for {hf_name}")));
421    })
422}
423
424/// Reorients a logical [in, out] weight to HF [out, in] (transpose).
425/// Embeddings and 1-D tensors pass through.
426fn reorient(
427    metadata: &ModelMetadata,
428    hf_name: &str,
429    values: Vec<f32>,
430    logical: &[usize],
431) -> Result<(Vec<f32>, Vec<usize>)> {
432    if hf_name == "model.embed_tokens.weight" || hf_name == "lm_head.weight" {
433        return Ok((values, logical.to_vec()));
434    }
435    if logical[0] == 1 || logical[1] == 1 {
436        // 1-D tensors (norms): keep them 1-D, not [1, hidden].
437        let shape = if logical[0] == 1 { vec![logical[1]] } else { logical.to_vec() };
438        return Ok((values, shape));
439    }
440    if logical[0] == logical[1] && logical[0] == metadata.hidden_size && hf_name.ends_with("norm.weight") {
441        return Ok((values, logical.to_vec()));
442    }
443    let (rows, cols) = (logical[0], logical[1]);
444    let mut out = vec![0f32; values.len()];
445    for r in 0..rows {
446        for c in 0..cols {
447            out[c * rows + r] = values[r * cols + c];
448        }
449    }
450    Ok((out, vec![cols, rows]))
451}
452
453/// int8 per-output-row dequant: `w[i,j] = q[i,j] * scale[j]` on the ODML
454/// [in, out] layout (scales are per output channel = last dim).
455fn dequant_i8(data: &[u8], scales: &[f32], logical: &[usize]) -> Result<Vec<f32>> {
456    let (rows, cols) = (logical[0], logical[1]);
457    if scales.len() != cols {
458        return Err(bad(format!(
459            "scale count {} != out dim {cols}",
460            scales.len()
461        )));
462    }
463    let mut out = Vec::with_capacity(rows * cols);
464    for r in 0..rows {
465        for c in 0..cols {
466            let q = data[r * cols + c] as i8;
467            out.push(q as f32 * scales[c]);
468        }
469    }
470    Ok(out)
471}