Skip to main content

interscript_rs/imf/
infer.rs

1//! ONNX inference for IMF v1 artifacts: the greedy single-row decode
2//! loop (the artifact contract per CONFORMANCE.md) over a mockable
3//! `InferenceEngine` trait, plus the `ort`-backed implementation.
4//!
5//! Byte-exactness contract (mirror of
6//! `pipeline.conformance.ort_single_row_decoder`, the reference the
7//! fixtures were generated with):
8//! * encode: ids = UTF-8 byte + 3, one trailing EOS; int64, one row;
9//! * encoder: one row per call, no padding, no attention mask;
10//! * decoder: greedy argmax until EOS, 512-step cap, prefix
11//!   `[PAD]` then growing;
12//! * output: `max(0, t - 3)` per id, lossy UTF-8.
13
14use crate::error::{Error, Result};
15use crate::imf::loader::ArtifactGraphs;
16use crate::imf::tokens;
17
18fn model_err(what: impl Into<String>) -> Error {
19    Error::Model(what.into())
20}
21
22/// The engine abstraction: one encoder pass + one decoder step at a
23/// time. `ort` sessions implement it; tests drive the loop with a
24/// deterministic mock.
25pub trait InferenceEngine {
26    /// Encoder: single-row `input_ids` -> hidden states, shape
27    /// (1, seq, d) flattened, row-major.
28    fn encode(&mut self, input_ids: &[i64]) -> Result<Vec<f32>>;
29    /// Hidden-state tensor dims (1, seq, d) — needed to build the
30    /// decoder's `encoder_hidden_states` input.
31    fn hidden_dims(&self, seq_len: usize) -> Vec<i64>;
32    /// Decoder step: full prefix `decoder_ids` -> logits of the last
33    /// position (vocab-sized).
34    fn decode_step(&mut self, decoder_ids: &[i64], hidden: &[f32]) -> Result<Vec<f32>>;
35}
36
37/// The greedy single-row decode loop (model.generate + tokens.decode
38/// parity). Empty input (only the trailing EOS) short-circuits to no
39/// tokens, exactly like the reference loader.
40pub fn greedy_generate(
41    engine: &mut dyn InferenceEngine,
42    text: &str,
43    max_len: usize,
44) -> Result<Vec<i64>> {
45    let ids = tokens::encode(text);
46    if ids.len() == 1 {
47        // only the trailing EOS: empty input
48        return Ok(Vec::new());
49    }
50    let hidden = engine.encode(&ids)?;
51    let mut decoder_ids = vec![tokens::PAD_ID];
52    let mut generated = Vec::new();
53    for _ in 0..max_len {
54        let logits = engine.decode_step(&decoder_ids, &hidden)?;
55        // numpy argmax parity: first index of the maximum wins.
56        let token = argmax(&logits).ok_or_else(|| model_err("decoder produced empty logits"))?;
57        if token == tokens::EOS_ID {
58            break;
59        }
60        generated.push(token);
61        decoder_ids.push(token);
62    }
63    Ok(generated)
64}
65
66/// `translate(text)`: greedy ids -> text. Conformance fixtures were
67/// generated with the `max(0, t - 3)` byte rule (see
68/// `ids_to_fixture_bytes`); `tokens::decode` implements the SPEC §4
69/// table (skip pad/unk, mod 256). This function uses the SPEC table.
70pub fn translate(engine: &mut dyn InferenceEngine, text: &str, max_len: usize) -> Result<String> {
71    let ids = greedy_generate(engine, text, max_len)?;
72    Ok(tokens::decode(&ids))
73}
74
75/// The fixture-generation byte rule: `max(0, t - 3)` per id (no
76/// pad/unk skipping — a PAD argmax yields byte 0x00 there), then
77/// lossy UTF-8. Used by the conformance harness so frozen vectors are
78/// compared byte-exactly under the rule that produced them.
79pub fn ids_to_fixture_bytes(ids: &[i64]) -> Vec<u8> {
80    ids.iter()
81        .map(|&t| std::cmp::max(0, t - tokens::BYTE_OFFSET) as u8)
82        .collect()
83}
84
85pub fn ids_to_fixture_string(ids: &[i64]) -> String {
86    String::from_utf8_lossy(&ids_to_fixture_bytes(ids)).into_owned()
87}
88
89fn argmax(data: &[f32]) -> Option<i64> {
90    let mut best: Option<(usize, f32)> = None;
91    for (i, &v) in data.iter().enumerate() {
92        match best {
93            Some((_, bv)) if bv >= v => {}
94            _ => best = Some((i, v)),
95        }
96    }
97    best.map(|(i, _)| i as i64)
98}
99
100/// Decode-step step cap. CONFORMANCE.md: "greedy argmax until EOS,
101/// 512-step cap".
102pub const MAX_DECODE_STEPS: usize = 512;
103
104// ----------------------------------------------------------------------
105// ort-backed engine
106// ----------------------------------------------------------------------
107
108/// An `ort`-loaded IMF artifact: encoder + decoder sessions plus the
109/// ONNX Runtime environment they share.
110pub struct OrtEngine {
111    encoder: ort::session::Session,
112    decoder: ort::session::Session,
113    encoder_input: String,
114    decoder_inputs: [String; 2],
115    hidden_seq: usize,
116    hidden_dim: usize,
117}
118
119/// onnxruntime allows ONE environment per process (CreateEnv twice
120/// errors out), and sessions keep their environment alive internally —
121/// so it lives in a process-global slot (the Result caches init
122/// failures so a broken install fails identically on every call).
123fn ort_environment() -> Result<&'static ort::environment::Environment> {
124    static ENV: std::sync::OnceLock<std::result::Result<ort::environment::Environment, String>> =
125        std::sync::OnceLock::new();
126    ENV.get_or_init(|| {
127        ort::init()
128            .with_name("iscrs")
129            .build()
130            .map_err(|e| e.to_string())
131    })
132    .as_ref()
133    .map_err(|e| model_err(format!("onnxruntime environment init failed: {e}")))
134}
135
136impl OrtEngine {
137    /// Build sessions from verified graphs (`decoder-kv.onnx` is
138    /// selected for kv artifacts per the reference loader; this slice
139    /// implements the plain greedy loop — kv sessions decode through
140    /// the plain graph).
141    pub fn from_graphs(graphs: &ArtifactGraphs) -> Result<OrtEngine> {
142        let intra_threads = std::thread::available_parallelism()
143            .map(|n| n.get())
144            .unwrap_or(1);
145        let intra_threads = std::cmp::max(1, intra_threads / 2);
146
147        let env = ort_environment()?;
148
149        let commit = |bytes: &[u8]| -> Result<ort::session::Session> {
150            let builder = ort::session::Session::builder(env)
151                .map_err(|e| model_err(format!("session builder failed: {e}")))?;
152            let builder = builder
153                .with_optimization_level(
154                    ort::session::builder::GraphOptimizationLevel::Level3,
155                )
156                .map_err(|e| model_err(format!("optimization level failed: {e}")))?;
157            let builder = builder
158                .with_intra_threads(intra_threads)
159                .map_err(|e| model_err(format!("intra threads failed: {e}")))?;
160            let mut builder = builder;
161            builder
162                .commit_from_memory(bytes)
163                .map_err(|e| model_err(format!("onnx session load failed: {e}")))
164        };
165
166        let encoder = commit(&graphs.graphs["encoder.onnx"])?;
167        let decoder_name = graphs.decoder_member()?;
168        let decoder = commit(&graphs
169            .graphs
170            .get(decoder_name)
171            .ok_or_else(|| model_err(format!("missing verified member {decoder_name}")))?)?;
172
173        let encoder_input = encoder
174            .inputs()
175            .first()
176            .map(|i| i.name().to_string())
177            .ok_or_else(|| model_err("encoder graph has no inputs"))?;
178        let dec_inputs: Vec<String> = decoder
179            .inputs()
180            .iter()
181            .take(2)
182            .map(|i| i.name().to_string())
183            .collect();
184        if dec_inputs.len() != 2 {
185            return Err(model_err(
186                "decoder graph lacks input_ids/encoder_hidden_states inputs",
187            ));
188        }
189        let decoder_inputs = [dec_inputs[0].clone(), dec_inputs[1].clone()];
190
191        Ok(OrtEngine {
192            encoder,
193            decoder,
194            encoder_input,
195            decoder_inputs,
196            hidden_seq: 0,
197            hidden_dim: 0,
198        })
199    }
200}
201
202impl InferenceEngine for OrtEngine {
203    fn encode(&mut self, input_ids: &[i64]) -> Result<Vec<f32>> {
204        let seq = input_ids.len();
205        let tensor = ort::value::Tensor::from_array(([1usize, seq], input_ids.to_vec()))
206            .map_err(|e| model_err(format!("input_ids tensor build failed: {e}")))?;
207        let key = self
208            .encoder
209            .outputs()
210            .first()
211            .map(|o| o.name().to_string())
212            .ok_or_else(|| model_err("encoder graph has no outputs"))?;
213        let outputs = self
214            .encoder
215            .run(ort::inputs![self.encoder_input.as_str() => tensor])
216            .map_err(|e| model_err(format!("encoder run failed: {e}")))?;
217        let (shape, data) = outputs
218            .get(key.as_str())
219            .ok_or_else(|| model_err(format!("encoder output {key:?} missing")))?
220            .try_extract_tensor::<f32>()
221            .map_err(|e| model_err(format!("encoder output extract failed: {e}")))?;
222        // shape = [1, seq, d]
223        let dims: Vec<i64> = shape.iter().copied().collect();
224        self.hidden_seq = dims.get(1).copied().unwrap_or(seq as i64) as usize;
225        self.hidden_dim = dims
226            .get(2)
227            .copied()
228            .unwrap_or((data.len() / seq.max(1)) as i64) as usize;
229        Ok(data.to_vec())
230    }
231
232    fn hidden_dims(&self, seq_len: usize) -> Vec<i64> {
233        let seq = if self.hidden_seq == 0 { seq_len } else { self.hidden_seq };
234        let d = if self.hidden_dim == 0 { 1 } else { self.hidden_dim };
235        vec![1, seq as i64, d as i64]
236    }
237
238    fn decode_step(&mut self, decoder_ids: &[i64], hidden: &[f32]) -> Result<Vec<f32>> {
239        let cur_len = decoder_ids.len();
240        let ids = ort::value::Tensor::from_array(([1usize, cur_len], decoder_ids.to_vec()))
241            .map_err(|e| model_err(format!("decoder input_ids tensor build failed: {e}")))?;
242        let dims = self.hidden_dims(hidden.len());
243        let states = ort::value::Tensor::from_array((dims, hidden.to_vec()))
244            .map_err(|e| model_err(format!("encoder_hidden_states tensor build failed: {e}")))?;
245        let key = self
246            .decoder
247            .outputs()
248            .first()
249            .map(|o| o.name().to_string())
250            .ok_or_else(|| model_err("decoder graph has no outputs"))?;
251        let outputs = self
252            .decoder
253            .run(ort::inputs![
254                self.decoder_inputs[0].as_str() => ids,
255                self.decoder_inputs[1].as_str() => states,
256            ])
257            .map_err(|e| model_err(format!("decoder run failed: {e}")))?;
258        let (shape, data) = outputs
259            .get(key.as_str())
260            .ok_or_else(|| model_err(format!("decoder output {key:?} missing")))?
261            .try_extract_tensor::<f32>()
262            .map_err(|e| model_err(format!("decoder output extract failed: {e}")))?;
263        // shape = [1, cur_len, vocab]; the greedy step reads the last
264        // position only.
265        let dims: Vec<i64> = shape.iter().copied().collect();
266        let vocab = dims
267            .get(2)
268            .copied()
269            .unwrap_or((data.len() / cur_len.max(1)) as i64) as usize;
270        let start = (cur_len - 1) * vocab;
271        let end = (start + vocab).min(data.len());
272        if start >= data.len() {
273            return Err(model_err("decoder logits too short for prefix length"));
274        }
275        Ok(data[start..end].to_vec())
276    }
277}
278
279#[cfg(test)]
280mod tests {
281    use super::*;
282
283    /// Deterministic mock: emits `aa` for input "x", then EOS. Lets
284    /// the loop be tested without onnxruntime.
285    struct MockEngine {
286        steps: std::cell::RefCell<Vec<Vec<i64>>>,
287    }
288
289    impl MockEngine {
290        fn new() -> Self {
291            MockEngine {
292                steps: std::cell::RefCell::new(Vec::new()),
293            }
294        }
295    }
296
297    impl InferenceEngine for MockEngine {
298        fn encode(&mut self, input_ids: &[i64]) -> Result<Vec<f32>> {
299            assert_eq!(input_ids.last(), Some(&tokens::EOS_ID));
300            assert_eq!(input_ids[0], b'x' as i64 + tokens::BYTE_OFFSET);
301            Ok(vec![1.0])
302        }
303        fn hidden_dims(&self, _seq: usize) -> Vec<i64> {
304            vec![1, 1, 1]
305        }
306        fn decode_step(&mut self, decoder_ids: &[i64], _hidden: &[f32]) -> Result<Vec<f32>> {
307            self.steps.borrow_mut().push(decoder_ids.to_vec());
308            let vocab = 300i64;
309            let mut logits = vec![0.0f32; vocab as usize];
310            match decoder_ids.len() {
311                1 => logits['a' as i64 as usize] = 5.0, // -> "a"
312                2 => logits['a' as i64 as usize] = 4.0, // -> "a" (tie-free repeat)
313                _ => logits[tokens::EOS_ID as usize] = 3.0, // -> EOS
314            }
315            Ok(logits)
316        }
317    }
318
319    #[test]
320    fn greedy_loop_stops_at_eos_and_grows_prefix() {
321        let mut engine = MockEngine::new();
322        let ids = greedy_generate(&mut engine, "x", MAX_DECODE_STEPS).unwrap();
323        assert_eq!(ids, vec!['a' as i64, 'a' as i64]);
324        // prefix starts at [PAD] and grows with each emitted token
325        assert_eq!(engine.steps.borrow()[0], vec![tokens::PAD_ID]);
326        assert_eq!(
327            engine.steps.borrow()[1],
328            vec![tokens::PAD_ID, 'a' as i64]
329        );
330    }
331
332    #[test]
333    fn empty_input_short_circuits() {
334        let mut engine = MockEngine::new();
335        assert!(greedy_generate(&mut engine, "", MAX_DECODE_STEPS)
336            .unwrap()
337            .is_empty());
338        assert!(engine.steps.borrow().is_empty());
339    }
340
341    #[test]
342    fn max_len_cap_bounds_the_loop() {
343        struct NeverEos;
344        impl InferenceEngine for NeverEos {
345            fn encode(&mut self, _ids: &[i64]) -> Result<Vec<f32>> {
346                Ok(vec![1.0])
347            }
348            fn hidden_dims(&self, _s: usize) -> Vec<i64> {
349                vec![1, 1, 1]
350            }
351            fn decode_step(&mut self, _ids: &[i64], _h: &[f32]) -> Result<Vec<f32>> {
352                let mut logits = vec![0.0f32; 300];
353                logits[100] = 1.0;
354                Ok(logits)
355            }
356        }
357        let mut engine = NeverEos;
358        let ids = greedy_generate(&mut engine, "x", 10).unwrap();
359        assert_eq!(ids.len(), 10);
360        assert!(ids.iter().all(|&t| t == 100));
361    }
362
363    #[test]
364    fn fixture_byte_rule_and_argmax_tiebreak() {
365        // max(0, t-3): token 97 -> byte 94 ('^'); pad/unk are NOT
366        // skipped by the fixture rule (byte 0x00), unlike tokens::decode.
367        assert_eq!(ids_to_fixture_bytes(&[97, 2, 0, 101]), b"^\x00\x00b");
368        // the SPEC tokens::decode DOES skip them: (97-3)='^', 0/2 skipped
369        assert_eq!(crate::imf::tokens::decode(&[97, 2, 0, 101]), "^b");
370        assert_eq!(argmax(&[1.0, 3.0, 3.0]), Some(1)); // first max wins
371        assert_eq!(argmax(&[]), None);
372    }
373}