Skip to main content

infotheory/backends/
match_model.rs

1use ahash::AHashMap;
2
3#[derive(Clone, Debug)]
4/// Local match predictor with configurable contiguous or gapped matching.
5pub struct MatchModel {
6    hash_bits: usize,
7    min_len: usize,
8    max_len: usize,
9    stride_min: usize,
10    stride_max: usize,
11    base_mix: f64,
12    confidence_scale: f64,
13    history: Vec<u8>,
14    frozen_anchor: usize,
15    tables: Vec<AHashMap<u64, (usize, usize)>>,
16    pdf: [f64; 256],
17    cdf: [f64; 257],
18    valid: bool,
19    cdf_valid: bool,
20    predicted: Option<u8>,
21    match_len: usize,
22}
23
24#[derive(Clone, Debug)]
25pub(crate) struct MatchModelLifecycleSnapshot {
26    history: Vec<u8>,
27    frozen_anchor: usize,
28    pdf: [f64; 256],
29    cdf: [f64; 257],
30    valid: bool,
31    cdf_valid: bool,
32    predicted: Option<u8>,
33    match_len: usize,
34}
35
36impl MatchModel {
37    /// Create a match model with an inclusive stride range `[gap_min+1, gap_max+1]`.
38    pub fn new(
39        hash_bits: usize,
40        min_len: usize,
41        max_len: usize,
42        gap_min: usize,
43        gap_max: usize,
44        base_mix: f64,
45        confidence_scale: f64,
46    ) -> Self {
47        let stride_min = gap_min.saturating_add(1);
48        let stride_max = gap_max.saturating_add(1).max(stride_min);
49        let mut tables = Vec::new();
50        for _ in stride_min..=stride_max {
51            tables.push(AHashMap::new());
52        }
53        Self {
54            hash_bits,
55            min_len: min_len.max(1),
56            max_len: max_len.max(min_len.max(1)),
57            stride_min,
58            stride_max,
59            base_mix: base_mix.clamp(1e-6, 0.99),
60            confidence_scale: confidence_scale.max(0.0),
61            history: Vec::new(),
62            frozen_anchor: 0,
63            tables,
64            pdf: [1.0 / 256.0; 256],
65            cdf: uniform_cdf(),
66            valid: false,
67            cdf_valid: false,
68            predicted: None,
69            match_len: 0,
70        }
71    }
72
73    /// Convenience constructor for contiguous matching (`gap_min = gap_max = 0`).
74    pub fn new_contiguous(
75        hash_bits: usize,
76        min_len: usize,
77        max_len: usize,
78        base_mix: f64,
79        confidence_scale: f64,
80    ) -> Self {
81        Self::new(
82            hash_bits,
83            min_len,
84            max_len,
85            0,
86            0,
87            base_mix,
88            confidence_scale,
89        )
90    }
91
92    /// Fill `out` with the current normalized byte PDF.
93    pub fn fill_pdf(&mut self, out: &mut [f64; 256]) {
94        self.ensure_pdf_inner(false);
95        out.copy_from_slice(&self.pdf);
96    }
97
98    /// Borrow the current normalized byte PDF.
99    pub fn pdf(&mut self) -> &[f64; 256] {
100        self.ensure_pdf_inner(false);
101        &self.pdf
102    }
103
104    /// Borrow the cumulative distribution derived from the current PDF.
105    pub fn cdf(&mut self) -> &[f64; 257] {
106        self.ensure_pdf_inner(true);
107        &self.cdf
108    }
109
110    /// Return `ln(max(P(symbol), min_prob))`.
111    pub fn log_prob(&mut self, symbol: u8, min_prob: f64) -> f64 {
112        self.ensure_pdf_inner(false);
113        self.pdf[symbol as usize].max(min_prob).ln()
114    }
115
116    /// Observe one symbol and update match tables/history.
117    pub fn update(&mut self, symbol: u8) {
118        if self.frozen_anchor > 0 {
119            self.frozen_anchor = 0;
120        }
121        self.history.push(symbol);
122        for stride in self.stride_min..=self.stride_max {
123            if let Some(key) = self.suffix_key(stride) {
124                let end = self.history.len() - 1;
125                self.tables[stride - self.stride_min]
126                    .entry(key)
127                    .and_modify(|entry| {
128                        entry.1 = entry.0;
129                        entry.0 = end;
130                    })
131                    .or_insert((end, usize::MAX));
132            }
133        }
134        self.valid = false;
135        self.cdf_valid = false;
136    }
137
138    /// Reset conditioning while preserving learned match tables and fitted corpus bytes.
139    pub fn reset_history(&mut self) {
140        if self.frozen_anchor > 0 {
141            self.history.truncate(self.frozen_anchor);
142        } else {
143            self.frozen_anchor = self.history.len();
144        }
145        self.valid = false;
146        self.cdf_valid = false;
147        self.predicted = None;
148        self.match_len = 0;
149        self.pdf.fill(1.0 / 256.0);
150        self.cdf = uniform_cdf();
151    }
152
153    pub(crate) fn lifecycle_snapshot(&self) -> MatchModelLifecycleSnapshot {
154        MatchModelLifecycleSnapshot {
155            history: self.history.clone(),
156            frozen_anchor: self.frozen_anchor,
157            pdf: self.pdf,
158            cdf: self.cdf,
159            valid: self.valid,
160            cdf_valid: self.cdf_valid,
161            predicted: self.predicted,
162            match_len: self.match_len,
163        }
164    }
165
166    pub(crate) fn restore_lifecycle_snapshot(&mut self, snapshot: MatchModelLifecycleSnapshot) {
167        self.history = snapshot.history;
168        self.frozen_anchor = snapshot.frozen_anchor;
169        self.pdf = snapshot.pdf;
170        self.cdf = snapshot.cdf;
171        self.valid = snapshot.valid;
172        self.cdf_valid = snapshot.cdf_valid;
173        self.predicted = snapshot.predicted;
174        self.match_len = snapshot.match_len;
175    }
176
177    /// Advance conditioning history without updating learned match tables.
178    pub fn update_history_only(&mut self, symbol: u8) {
179        if self.frozen_anchor == 0 {
180            self.frozen_anchor = self.history.len();
181        }
182        self.history.push(symbol);
183        self.valid = false;
184        self.cdf_valid = false;
185    }
186
187    /// Length of the best match used for the last computed distribution.
188    pub fn match_len(&mut self) -> usize {
189        self.ensure_pdf_inner(false);
190        self.match_len
191    }
192
193    /// Predicted next byte from the best match, if any.
194    pub fn predicted_byte(&mut self) -> Option<u8> {
195        self.ensure_pdf_inner(false);
196        self.predicted
197    }
198
199    fn ensure_pdf_inner(&mut self, want_cdf: bool) {
200        if self.valid {
201            if want_cdf && !self.cdf_valid {
202                build_cdf_from_pdf(&self.pdf, &mut self.cdf);
203                self.cdf_valid = true;
204            }
205            return;
206        }
207        self.predicted = None;
208        self.match_len = 0;
209        self.pdf.fill(1.0 / 256.0);
210        let active_len = self.history.len().saturating_sub(self.frozen_anchor);
211        if active_len < self.min_len {
212            self.valid = true;
213            if want_cdf {
214                self.cdf = uniform_cdf();
215                self.cdf_valid = true;
216            } else {
217                self.cdf_valid = false;
218            }
219            return;
220        }
221
222        let mut best = None;
223        let history_limit = if self.frozen_anchor > 0 {
224            self.frozen_anchor
225        } else {
226            self.history.len()
227        };
228        for stride in self.stride_min..=self.stride_max {
229            let Some(key) = self.suffix_key(stride) else {
230                continue;
231            };
232            let Some(&(latest, previous)) = self.tables[stride - self.stride_min].get(&key) else {
233                continue;
234            };
235            let current_end = self.history.len() - 1;
236            let candidate_end = if latest == current_end {
237                previous
238            } else {
239                latest
240            };
241            if self.frozen_anchor > 0 && candidate_end >= self.frozen_anchor {
242                continue;
243            }
244            if candidate_end == usize::MAX || candidate_end + stride >= history_limit {
245                continue;
246            }
247            let matched = self.extend_match(candidate_end, stride);
248            if matched < self.min_len {
249                continue;
250            }
251            let predicted = self.history[candidate_end + stride];
252            match best {
253                Some((best_len, _, _)) if matched <= best_len => {}
254                _ => best = Some((matched, predicted, stride)),
255            }
256        }
257
258        if let Some((match_len, predicted, _stride)) = best {
259            self.predicted = Some(predicted);
260            self.match_len = match_len;
261            let base = 1.0 / 256.0;
262            let span = self.max_len.saturating_sub(self.min_len).max(1);
263            let covered = match_len.saturating_sub(self.min_len).min(span);
264            let confidence = ((covered as f64) / (span as f64)).sqrt() * self.confidence_scale;
265            let p_copy = (base + confidence.clamp(0.0, 1.0) * ((1.0 - self.base_mix) - base))
266                .clamp(base, 1.0 - self.base_mix);
267            let rest = ((1.0 - p_copy) / 255.0).max(0.0);
268            self.pdf.fill(rest);
269            self.pdf[predicted as usize] = p_copy;
270        }
271        if want_cdf {
272            build_cdf_from_pdf(&self.pdf, &mut self.cdf);
273        }
274        self.valid = true;
275        self.cdf_valid = want_cdf;
276    }
277
278    fn suffix_key(&self, stride: usize) -> Option<u64> {
279        let need = self
280            .min_len
281            .checked_sub(1)?
282            .saturating_mul(stride)
283            .saturating_add(1);
284        if self.history.len().saturating_sub(self.frozen_anchor) < need {
285            return None;
286        }
287        let mut h = 0x517C_C1B7_2722_0A95u64;
288        let mut idx = self.history.len() - 1;
289        for step in 0..self.min_len {
290            h ^= self.history[idx] as u64;
291            h = h.rotate_left(7).wrapping_mul(0x9E37_79B1);
292            if step + 1 == self.min_len {
293                break;
294            }
295            let boundary = self.frozen_anchor.saturating_add(stride);
296            if idx < boundary {
297                return None;
298            }
299            idx -= stride;
300        }
301        let bits = self.hash_bits.clamp(4, 63);
302        Some(h & ((1u64 << bits) - 1))
303    }
304
305    fn extend_match(&self, candidate_end: usize, stride: usize) -> usize {
306        let current_end = self.history.len() - 1;
307        let mut matched = self.min_len;
308        while matched < self.max_len {
309            let step = matched.saturating_mul(stride);
310            let Some(current_idx) = current_end.checked_sub(step) else {
311                break;
312            };
313            if current_idx < self.frozen_anchor {
314                break;
315            }
316            let Some(candidate_idx) = candidate_end.checked_sub(step) else {
317                break;
318            };
319            if self.history[current_idx] != self.history[candidate_idx] {
320                break;
321            }
322            matched += 1;
323        }
324        matched
325    }
326}
327
328#[inline]
329fn uniform_cdf() -> [f64; 257] {
330    let mut cdf = [0.0; 257];
331    let inv = 1.0 / 256.0;
332    for (i, slot) in cdf.iter_mut().enumerate() {
333        *slot = (i as f64) * inv;
334    }
335    cdf
336}
337
338#[inline]
339fn build_cdf_from_pdf(pdf: &[f64; 256], cdf: &mut [f64; 257]) {
340    cdf[0] = 0.0;
341    for i in 0..256 {
342        cdf[i + 1] = cdf[i] + pdf[i];
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::MatchModel;
349
350    #[test]
351    fn reset_history_preserves_fit_corpus_for_frozen_conditioning() {
352        let mut model = MatchModel::new_contiguous(32, 3, 16, 0.02, 1.0);
353        for &b in b"abcabcX" {
354            model.update(b);
355        }
356
357        model.reset_history();
358        for &b in b"abcabc" {
359            model.update_history_only(b);
360        }
361
362        assert_eq!(model.predicted_byte(), Some(b'X'));
363        assert!(model.match_len() >= 3);
364    }
365
366    #[test]
367    fn reset_history_drops_previous_conditioning() {
368        let mut model = MatchModel::new_contiguous(32, 3, 16, 0.02, 1.0);
369        for &b in b"abcabcX" {
370            model.update(b);
371        }
372
373        model.reset_history();
374        for &b in b"abcabc" {
375            model.update_history_only(b);
376        }
377        assert_eq!(model.predicted_byte(), Some(b'X'));
378
379        model.reset_history();
380        assert_eq!(model.predicted_byte(), None);
381    }
382}