kopitiam_tensor/tensor/tessdata.rs
1//! Decoding Tesseract's int8 LSTM weight matrices to `f32`:
2//! [`Tensor::tessdata_int8_to_f32`].
3//!
4//! # Provenance (translation, not clean-room)
5//!
6//! Unlike the activations, the LSTM cell, and the conv/pool ops in this crate
7//! — which are standard math written from scratch — the int8 weight layout
8//! this module decodes is a **specific on-disk format** defined by Tesseract,
9//! so this is a *translation* of that format, credited here at the point of
10//! use per the project's attribution rules (`docs/ACKNOWLEDGEMENTS.md`,
11//! `docs/ai-decisions/AID-0051`). The layout follows
12//! `src/lstm/weightmatrix.cpp` — `WeightMatrix::ConvertToInt` (the encoder)
13//! and `Debug2D`/`MatrixDotVector` (the `wi * scale` recovery) — from
14//! Tesseract at commit `db0ec62`, Apache-2.0, (C) Google Inc., Author: Ray
15//! Smith. Apache-2.0 is one-way compatible with KOPITIAM's AGPL-3.0-only.
16//! No Tesseract code is copied; the byte-layout algorithm is reimplemented.
17//!
18//! # The layout
19//!
20//! A Tesseract int8 weight matrix is `[num_outputs, num_inputs]` (row-major,
21//! one row per output unit). Every *row* is quantized independently:
22//!
23//! * `ConvertToInt` finds `max_abs`, the largest absolute weight in the row,
24//! sets `scale = max_abs / INT8_MAX` (`INT8_MAX == 127`), and stores each
25//! weight as `IntCastRounded(w / scale)` — round-half-away-from-zero, which
26//! is exactly Rust's `f32::round` — clamped to the int8 range.
27//! * The per-row `scale` is what recovers the weight: `w ~= i8 * scale`.
28//! (`Debug2D` reconstructs weights as `wi_[i][j] * scales_[i]`; the fused
29//! integer `MatrixDotVector` multiplies each row's integer dot product by
30//! that same per-row scale.)
31//!
32//! This function takes that recovery scale directly — one `scale` per row,
33//! the value for which `w ~= i8 * scale` — and returns the dequantized
34//! `[rows, cols]` `f32` [`Tensor`], which the recognizer can then feed to the
35//! ordinary [`Tensor::matmul`]. Recovering to `f32` (rather than a fused int8
36//! matmul) is the "correct before fast" choice this crate makes everywhere
37//! else too (see [`Tensor::quantized_matmul`]'s docs for the same reasoning);
38//! a fused int8 path can be added later against a real model if the memory
39//! saving proves to matter for tessdata's (comparatively small) LSTM weights.
40//!
41//! # Uncertainty flagged for the recognizer phase
42//!
43//! The int8 -> f32 recovery here (`w = i8 * scale`, one scale per row, with
44//! `ConvertToInt`'s exact rounding/clamping validated by the round-trip test
45//! below) is the unambiguous part. What a caller wiring this to a *real*
46//! tessdata file must still pin down — out of scope for this tensor-op phase
47//! because it needs a real model to check against — is precisely **which
48//! scale value the deserializer hands us**. Tesseract keeps two different
49//! scalings of the same number: the on-disk value is `max_abs / 127` (its
50//! `Serialize` multiplies the in-memory scale back up by `INT8_MAX` before
51//! writing), while the in-memory `scales_` carries an extra `/ 127` factor
52//! (`max_abs / 127^2`) so its *fused* int8 path can multiply an int8-quantized
53//! *activation* dot product without re-dividing. This function wants the
54//! **on-disk** `max_abs / 127` scale — the one that reconstructs a weight on
55//! its own. Two further tessdata details are likewise deferred to that phase,
56//! as they are serializer/SIMD concerns, not part of the per-row decode:
57//! (1) `MatrixDotVector` treats each weight row as having a trailing **bias**
58//! column (an extra input pinned to 1.0), so the recognizer must decide
59//! whether a given matrix's last column is a bias; and (2) the SIMD path pads
60//! `num_outputs` up to a rounded count (`IntSimdMatrix::Init`) and can leave
61//! extra scale entries — the caller passes the true, unpadded `rows`/`cols`.
62
63use kopitiam_core::{Error, Result, Shape};
64
65use super::Tensor;
66
67impl Tensor {
68 /// Decodes a Tesseract int8 LSTM weight matrix to an `f32` [`Tensor`].
69 ///
70 /// `weights` is the row-major `[rows, cols]` int8 matrix (`rows`
71 /// = `num_outputs`, `cols` = `num_inputs`), and `scales` is the per-row
72 /// recovery scale — one entry per row, the value for which
73 /// `w ~= weights[i][j] * scales[i]` (Tesseract's on-disk `max_abs / 127`;
74 /// see the module docs for the two-scaling subtlety). Returns the
75 /// dequantized `[rows, cols]` tensor `out[i][j] = weights[i*cols + j] as
76 /// f32 * scales[i]`, ready for [`Tensor::matmul`].
77 ///
78 /// # Errors
79 ///
80 /// [`Error::ShapeMismatch`] if `weights.len() != rows * cols` or
81 /// `scales.len() != rows`.
82 pub fn tessdata_int8_to_f32(
83 weights: &[i8],
84 scales: &[f32],
85 rows: usize,
86 cols: usize,
87 ) -> Result<Tensor> {
88 if weights.len() != rows * cols {
89 return Err(Error::ShapeMismatch {
90 expected: Shape::new([rows, cols]),
91 actual: Shape::new([weights.len()]),
92 });
93 }
94 if scales.len() != rows {
95 return Err(Error::ShapeMismatch {
96 expected: Shape::new([rows]),
97 actual: Shape::new([scales.len()]),
98 });
99 }
100
101 let mut out = vec![0f32; rows * cols];
102 for i in 0..rows {
103 let scale = scales[i];
104 for j in 0..cols {
105 out[i * cols + j] = weights[i * cols + j] as f32 * scale;
106 }
107 }
108 Tensor::from_f32(out, Shape::new([rows, cols]))
109 }
110}
111
112#[cfg(test)]
113mod tests {
114 use super::*;
115
116 /// The int8 range endpoint; `INT8_MAX` in `weightmatrix.cpp`.
117 const INT8_MAX: f32 = 127.0;
118
119 /// Encodes one weight row exactly as `WeightMatrix::ConvertToInt` does,
120 /// returning `(int8 weights, on-disk scale)`. Test-only, mirroring the
121 /// reference encoder so the round-trip can be checked against it — this
122 /// crate has no public `f32 -> int8` weight encoder (weight quantization
123 /// is a model-export concern, same stance as the GGUF side).
124 fn encode_row(row: &[f32]) -> (Vec<i8>, f32) {
125 let max_abs = row.iter().fold(0f32, |m, &v| m.max(v.abs()));
126 let scale = max_abs / INT8_MAX; // the on-disk recovery scale.
127 // ConvertToInt substitutes 1.0 for a zero scale to avoid /0; the row is
128 // then all zeros anyway.
129 let div = if scale == 0.0 { 1.0 } else { scale };
130 let q: Vec<i8> = row
131 .iter()
132 // IntCastRounded == round-half-away-from-zero == f32::round.
133 .map(|&w| (w / div).round().clamp(-INT8_MAX, INT8_MAX) as i8)
134 .collect();
135 (q, scale)
136 }
137
138 #[test]
139 fn decode_round_trips_a_known_row_within_quantization_error() {
140 let row = vec![0.10, -0.40, 0.25, 0.80, -0.80, 0.05, 0.55, -0.30];
141 let (q, scale) = encode_row(&row);
142
143 // max_abs = 0.80 -> scale = 0.80/127. The extreme values (+/-0.80) are
144 // representable exactly (they map to +/-127); every other weight is
145 // within half a step, i.e. scale/2, of its original.
146 let decoded = Tensor::tessdata_int8_to_f32(&q, &[scale], 1, row.len())
147 .unwrap()
148 .to_vec_f32()
149 .unwrap();
150
151 let half_step = scale / 2.0;
152 for (got, want) in decoded.iter().zip(&row) {
153 assert!(
154 (got - want).abs() <= half_step + 1e-7,
155 "decoded {got} too far from {want} (half-step {half_step})"
156 );
157 }
158 // The two saturating extremes recover exactly.
159 assert!((decoded[3] - 0.80).abs() < 1e-6);
160 assert!((decoded[4] + 0.80).abs() < 1e-6);
161 }
162
163 #[test]
164 fn decode_recovers_an_exact_multiple_row_bit_for_bit() {
165 // A row whose values are exact integer multiples of a chosen scale:
166 // no rounding, so decode reproduces it exactly. scale = 0.5, values
167 // = {-2,-1,0,1,2} * 0.5 -> int8 {-127? no}. Pick max_abs = 1.0 so
168 // scale = 1/127 and values are k/127 for integer k in [-127,127].
169 let scale = 1.0 / INT8_MAX;
170 let q: Vec<i8> = vec![-127, -64, 0, 33, 127];
171 let expected: Vec<f32> = q.iter().map(|&k| k as f32 * scale).collect();
172 let decoded = Tensor::tessdata_int8_to_f32(&q, &[scale], 1, q.len())
173 .unwrap()
174 .to_vec_f32()
175 .unwrap();
176 assert_eq!(decoded, expected);
177 }
178
179 #[test]
180 fn decode_uses_an_independent_scale_per_row() {
181 // Two rows, different scales; each row's int8 values recover against
182 // its own scale.
183 let weights: Vec<i8> = vec![10, -20, 30, 1, -2, 3];
184 let scales = [0.5f32, 2.0f32];
185 let decoded = Tensor::tessdata_int8_to_f32(&weights, &scales, 2, 3)
186 .unwrap()
187 .to_vec_f32()
188 .unwrap();
189 assert_eq!(decoded, vec![5.0, -10.0, 15.0, 2.0, -4.0, 6.0]);
190 }
191
192 #[test]
193 fn decoded_weight_matmul_matches_a_known_small_matrix() {
194 // Decode a small [2,3] int8 weight matrix, then matmul it against a
195 // known [3,2] input the way the recognizer would (W . x).
196 // scale row0 = 0.5, row1 = 1.0
197 // W = [[5, -10, 15], [2, -4, 6]] (after decode)
198 let weights: Vec<i8> = vec![10, -20, 30, 2, -4, 6];
199 let scales = [0.5f32, 1.0f32];
200 let w = Tensor::tessdata_int8_to_f32(&weights, &scales, 2, 3).unwrap();
201
202 // x is [3, 2]; result W @ x is [2, 2].
203 // row0 . col0 = 5*1 + (-10)*0 + 15*1 = 20 ; row0 . col1 = 5*2 + (-10)*1 + 15*0 = 0
204 // row1 . col0 = 2*1 + (-4)*0 + 6*1 = 8 ; row1 . col1 = 2*2 + (-4)*1 + 6*0 = 0
205 let x = Tensor::from_f32(vec![1.0, 2.0, 0.0, 1.0, 1.0, 0.0], [3, 2]).unwrap();
206 let out = w.matmul(&x).unwrap();
207 assert_eq!(out.shape().dims(), &[2, 2]);
208 assert_eq!(out.to_vec_f32().unwrap(), vec![20.0, 0.0, 8.0, 0.0]);
209 }
210
211 #[test]
212 fn decode_rejects_a_weight_length_mismatch() {
213 let weights = vec![1i8, 2, 3];
214 assert!(matches!(
215 Tensor::tessdata_int8_to_f32(&weights, &[1.0, 1.0], 2, 3),
216 Err(Error::ShapeMismatch { .. })
217 ));
218 }
219
220 #[test]
221 fn decode_rejects_a_scale_count_mismatch() {
222 let weights = vec![1i8, 2, 3, 4, 5, 6];
223 assert!(matches!(
224 Tensor::tessdata_int8_to_f32(&weights, &[1.0], 2, 3),
225 Err(Error::ShapeMismatch { .. })
226 ));
227 }
228
229 #[test]
230 fn decode_handles_an_all_zero_row() {
231 // encode_row gives scale 0 for an all-zero row; decoding 0 * anything
232 // is 0 regardless, so the row round-trips exactly.
233 let row = vec![0.0f32; 4];
234 let (q, scale) = encode_row(&row);
235 assert_eq!(scale, 0.0);
236 let decoded = Tensor::tessdata_int8_to_f32(&q, &[scale], 1, 4)
237 .unwrap()
238 .to_vec_f32()
239 .unwrap();
240 assert_eq!(decoded, vec![0.0; 4]);
241 }
242}