Skip to main content

lindera_dictionary/
viterbi.rs

1use std::io;
2
3use byteorder::{ByteOrder, LittleEndian, WriteBytesExt};
4use rkyv::{Archive, Deserialize as RkyvDeserialize, Serialize as RkyvSerialize};
5use serde::{Deserialize, Serialize};
6
7use crate::dictionary::character_definition::{CategoryId, CharacterDefinition};
8use crate::dictionary::connection_cost_matrix::ConnectionCostMatrix;
9use crate::dictionary::prefix_dictionary::PrefixDictionary;
10use crate::dictionary::unknown_dictionary::UnknownDictionary;
11use crate::mode::Mode;
12
13/// Type of lexicon containing the word
14#[derive(
15    Clone,
16    Copy,
17    Debug,
18    Eq,
19    PartialEq,
20    Serialize,
21    Deserialize,
22    Default,
23    Archive,
24    RkyvSerialize,
25    RkyvDeserialize,
26)]
27
28pub enum LexType {
29    /// System dictionary (base dictionary)
30    #[default]
31    System,
32    /// User dictionary (additional vocabulary)
33    User,
34    /// Unknown words (OOV handling)
35    Unknown,
36}
37
38#[derive(
39    Clone,
40    Copy,
41    Debug,
42    Eq,
43    PartialEq,
44    Serialize,
45    Deserialize,
46    Archive,
47    RkyvDeserialize,
48    RkyvSerialize,
49)]
50
51pub struct WordId {
52    /// Numeric identifier of the word within its lexicon.
53    id: u32,
54    /// Whether the word originates from the system dictionary.
55    is_system: bool,
56    /// Lexicon type the word belongs to.
57    lex_type: LexType,
58}
59
60impl WordId {
61    /// Creates a new WordId with specified lexicon type
62    pub fn new(lex_type: LexType, id: u32) -> Self {
63        WordId {
64            id,
65            is_system: matches!(lex_type, LexType::System),
66            lex_type,
67        }
68    }
69
70    /// Returns the numeric identifier of the word within its lexicon.
71    ///
72    /// # 戻り値
73    ///
74    /// The lexicon-local word id.
75    #[inline]
76    pub fn id(&self) -> u32 {
77        self.id
78    }
79
80    /// Returns `true` when the word is an unknown-word entry.
81    #[inline]
82    pub fn is_unknown(&self) -> bool {
83        matches!(self.lex_type, LexType::Unknown)
84    }
85
86    /// Returns `true` when the word originates from the system dictionary.
87    #[inline]
88    pub fn is_system(&self) -> bool {
89        self.is_system
90    }
91
92    /// Returns the lexicon type of the word.
93    #[inline]
94    pub fn lex_type(&self) -> LexType {
95        self.lex_type
96    }
97}
98
99impl Default for WordId {
100    fn default() -> Self {
101        WordId {
102            id: u32::MAX,
103            is_system: true,
104            lex_type: LexType::System,
105        }
106    }
107}
108
109#[derive(
110    Default,
111    Clone,
112    Copy,
113    Debug,
114    Eq,
115    PartialEq,
116    Serialize,
117    Deserialize,
118    Archive,
119    RkyvSerialize,
120    RkyvDeserialize,
121)]
122
123pub struct WordEntry {
124    /// Word id identifying this entry in the dictionary.
125    word_id: WordId,
126    /// Emission (word) cost of the entry.
127    word_cost: i16,
128    /// Left context id used by the connection matrix.
129    left_id: u16,
130    /// Right context id used by the connection matrix.
131    right_id: u16,
132}
133
134impl WordEntry {
135    /// Length in bytes of the serialized representation.
136    pub(crate) const SERIALIZED_LEN: usize = 10;
137
138    /// Creates a new word entry from its raw components.
139    ///
140    /// # 引数
141    ///
142    /// * `word_id` - The word id identifying the entry.
143    /// * `word_cost` - The emission cost of the word.
144    /// * `left_id` - The left context id.
145    /// * `right_id` - The right context id.
146    #[inline]
147    pub fn new(word_id: WordId, word_cost: i16, left_id: u16, right_id: u16) -> Self {
148        WordEntry {
149            word_id,
150            word_cost,
151            left_id,
152            right_id,
153        }
154    }
155
156    /// Returns the word id of this entry.
157    #[inline]
158    pub fn word_id(&self) -> WordId {
159        self.word_id
160    }
161
162    /// Returns the emission (word) cost of this entry.
163    #[inline]
164    pub fn word_cost(&self) -> i16 {
165        self.word_cost
166    }
167
168    /// Returns the left context id, widened to `u32`.
169    #[inline]
170    pub fn left_id(&self) -> u32 {
171        self.left_id as u32
172    }
173
174    /// Returns the right context id, widened to `u32`.
175    #[inline]
176    pub fn right_id(&self) -> u32 {
177        self.right_id as u32
178    }
179
180    /// Serializes this entry into `wtr` in little-endian byte order.
181    pub(crate) fn serialize<W: io::Write>(&self, wtr: &mut W) -> io::Result<()> {
182        wtr.write_u32::<LittleEndian>(self.word_id.id)?;
183        wtr.write_i16::<LittleEndian>(self.word_cost)?;
184        wtr.write_u16::<LittleEndian>(self.left_id)?;
185        wtr.write_u16::<LittleEndian>(self.right_id)?;
186        Ok(())
187    }
188
189    /// Deserializes a word entry from `data`.
190    pub(crate) fn deserialize(data: &[u8], is_system_entry: bool) -> WordEntry {
191        let word_id = WordId::new(
192            if is_system_entry {
193                LexType::System
194            } else {
195                LexType::User
196            },
197            LittleEndian::read_u32(&data[0..4]),
198        );
199        let word_cost = LittleEndian::read_i16(&data[4..6]);
200        let left_id = LittleEndian::read_u16(&data[6..8]);
201        let right_id = LittleEndian::read_u16(&data[8..10]);
202        WordEntry {
203            word_id,
204            word_cost,
205            left_id,
206            right_id,
207        }
208    }
209}
210
211#[derive(Default, Clone, Debug)]
212pub struct Edge {
213    /// Word entry backing this edge.
214    word_entry: WordEntry,
215
216    /// Best forward path cost reaching this edge.
217    path_cost: i32,
218    /// Index of the chosen left edge in the previous position's vector.
219    left_index: u16,
220
221    /// Start byte position of the edge.
222    start_index: u32,
223    /// Stop byte position of the edge.
224    stop_index: u32,
225
226    /// Whether the edge surface consists solely of kanji.
227    kanji_only: bool,
228}
229
230impl Edge {
231    /// Returns the number of characters spanned by this edge.
232    pub fn num_chars(&self) -> usize {
233        (self.stop_index - self.start_index) as usize / 3
234    }
235
236    /// Returns the word entry backing this edge.
237    #[inline]
238    pub(crate) fn word_entry(&self) -> &WordEntry {
239        &self.word_entry
240    }
241
242    /// Returns the best forward path cost reaching this edge.
243    #[inline]
244    pub(crate) fn path_cost(&self) -> i32 {
245        self.path_cost
246    }
247
248    /// Returns the index of the chosen left edge in the previous position.
249    #[inline]
250    pub(crate) fn left_index(&self) -> u16 {
251        self.left_index
252    }
253
254    /// Returns the start byte position of this edge.
255    #[inline]
256    pub(crate) fn start_index(&self) -> u32 {
257        self.start_index
258    }
259
260    /// Returns the stop byte position of this edge.
261    #[inline]
262    pub(crate) fn stop_index(&self) -> u32 {
263        self.stop_index
264    }
265
266    /// Returns whether the edge surface consists solely of kanji.
267    #[inline]
268    pub(crate) fn kanji_only(&self) -> bool {
269        self.kanji_only
270    }
271}
272
273/// Records a transition from a left edge to the current edge.
274/// Used in N-Best mode to store all predecessor transitions
275/// (not just the best one as in 1-best).
276#[derive(Clone, Debug)]
277pub struct PathEntry {
278    /// Index of this edge in ends_at[stop_index]
279    edge_index: u16,
280    /// Byte position where the left edge ends (= this edge's start_index)
281    left_pos: u32,
282    /// Index of the left edge in ends_at[left_pos]
283    left_index: u16,
284    /// Total forward cost: left_edge.path_cost + conn_cost + penalty_cost
285    cost: i32,
286}
287
288impl PathEntry {
289    /// Returns the index of this edge in `ends_at[stop_index]`.
290    #[inline]
291    pub(crate) fn edge_index(&self) -> u16 {
292        self.edge_index
293    }
294
295    /// Returns the byte position where the left edge ends.
296    #[inline]
297    pub(crate) fn left_pos(&self) -> u32 {
298        self.left_pos
299    }
300
301    /// Returns the index of the left edge in `ends_at[left_pos]`.
302    #[inline]
303    pub(crate) fn left_index(&self) -> u16 {
304        self.left_index
305    }
306
307    /// Returns the total forward cost of this transition.
308    #[inline]
309    pub(crate) fn cost(&self) -> i32 {
310        self.cost
311    }
312}
313
314#[derive(Clone, Default)]
315pub struct Lattice {
316    capacity: usize,
317    ends_at: Vec<Vec<Edge>>, // Now stores edges directly
318    char_info_buffer: Vec<CharData>,
319    categories_buffer: Vec<CategoryId>,
320
321    // N-Best fields (only populated when set_text_nbest is called)
322    all_paths: Vec<Vec<PathEntry>>,
323    nbest_capacity: usize,
324    /// The text length (in bytes) of the last set_text/set_text_nbest call
325    last_text_len: usize,
326
327    // Scratch buffers for the Aho-Corasick match pre-scan in set_text/set_text_nbest.
328    // Reused across calls (like the fields above) instead of being reallocated per
329    // call, since set_text runs once per sentence rather than once per document.
330    /// Linked-list head table: matches_head[start_idx] -> index into
331    /// matches_store; u32::MAX terminates a list (#880 shrank the element
332    /// widths to halve the per-sentence refill and walk traffic).
333    matches_head: Vec<u32>,
334    /// Linked-list node pool: (match end offset, word entry, next node index).
335    matches_store: Vec<(u32, WordEntry, u32)>,
336}
337
338/// Upper bound applied to every stored `path_cost` so the relaxation loops
339/// can use plain addition: one connection cost plus one penalty per step is
340/// at most 2 * 32,767, which cannot overflow from this clamp.
341const PATH_COST_CLAMP: i32 = i32::MAX - 131_072;
342
343#[derive(Clone, Copy, Debug, Default)]
344struct CharData {
345    byte_offset: u32,
346    is_kanji: bool,
347    categories_start: u32,
348    categories_len: u16,
349    kanji_run_byte_len: u32,
350}
351
352#[inline]
353pub fn is_kanji(c: char) -> bool {
354    let c = c as u32;
355    // CJK Unified Ideographs (4E00-9FAF) and Extension A (3400-4DBF)
356    (0x4E00..=0x9FAF).contains(&c) || (0x3400..=0x4DBF).contains(&c)
357}
358
359impl Lattice {
360    /// Helper method to create an edge efficiently
361    #[inline]
362    fn create_edge(word_entry: WordEntry, start: usize, stop: usize, kanji_only: bool) -> Edge {
363        Edge {
364            word_entry,
365            left_index: u16::MAX,
366            start_index: start as u32,
367            stop_index: stop as u32,
368            path_cost: i32::MAX,
369            kanji_only,
370        }
371    }
372
373    pub fn clear(&mut self) {
374        // Only slots up to the previous sentence's length can hold entries:
375        // every `ends_at`/`all_paths` write in set_text/set_text_nbest
376        // targets an index <= that call's text length (BOS at 0, edges at
377        // stop_index <= len, EOS at len), which `set_capacity` recorded in
378        // `last_text_len`, and every slot past it was left empty by the
379        // previous clear(). Walking only this prefix keeps clear() O(previous
380        // sentence) instead of O(historical max capacity), which matters
381        // once one long sentence has grown the lattice (#877).
382        let bound = self.last_text_len + 1;
383        for edge_vec in self.ends_at.iter_mut().take(bound) {
384            edge_vec.clear();
385        }
386        debug_assert!(
387            self.ends_at.iter().skip(bound).all(|v| v.is_empty()),
388            "ends_at slot beyond last_text_len must be empty"
389        );
390        for path_vec in self.all_paths.iter_mut().take(bound) {
391            path_vec.clear();
392        }
393        debug_assert!(
394            self.all_paths.iter().skip(bound).all(|v| v.is_empty()),
395            "all_paths slot beyond last_text_len must be empty"
396        );
397        self.char_info_buffer.clear();
398        self.categories_buffer.clear();
399    }
400
401    #[inline]
402    fn is_kanji_all(&self, char_idx: usize, byte_len: usize) -> bool {
403        self.char_info_buffer[char_idx].kanji_run_byte_len >= byte_len as u32
404    }
405
406    #[inline]
407    fn get_cached_category(&self, char_idx: usize, category_ord: usize) -> CategoryId {
408        let char_data = &self.char_info_buffer[char_idx];
409        self.categories_buffer[char_data.categories_start as usize + category_ord]
410    }
411
412    fn set_capacity(&mut self, text_len: usize) {
413        self.clear();
414        self.last_text_len = text_len;
415        if self.capacity <= text_len {
416            self.capacity = text_len;
417            // Pre-size newly-grown slots (like Vibrato's reset_vec) to
418            // avoid a couple of small reallocations the first time a busy
419            // position accumulates several edges. `resize_with` is required
420            // here: `resize` fills new slots with clones of its template
421            // value, and cloning an empty Vec allocates capacity 0, so only
422            // the moved-in last slot would actually be pre-sized (#827).
423            self.ends_at
424                .resize_with(text_len + 1, || Vec::with_capacity(16));
425        }
426    }
427
428    fn set_capacity_nbest(&mut self, text_len: usize) {
429        self.set_capacity(text_len);
430        if self.nbest_capacity <= text_len {
431            self.nbest_capacity = text_len;
432            self.all_paths.resize(text_len + 1, Vec::new());
433        }
434    }
435
436    /// Returns the lattice's current slot capacity: the largest sentence
437    /// length (in bytes) whose `ends_at` slots are already allocated.
438    ///
439    /// # 戻り値
440    ///
441    /// The capacity in bytes. `0` for a fresh lattice.
442    pub fn capacity(&self) -> usize {
443        self.capacity
444    }
445
446    /// Shrinks the internal buffers down to what a sentence of `text_len`
447    /// bytes needs, releasing memory retained after processing a long
448    /// sentence.
449    ///
450    /// The lattice grows monotonically (`set_capacity` never shrinks), so a
451    /// single long sentence pins its worst-case allocation for the lifetime
452    /// of the lattice. Long-lived holders (e.g. a reusable worker) can call
453    /// this to bound retention. This is never called on the hot path:
454    /// `clear()`/`set_text` stay shrink-free (#877/#884).
455    ///
456    /// Invariants preserved:
457    /// - Every remaining `ends_at` slot keeps a capacity of at least 16, the
458    ///   pre-size that avoids first-growth reallocations (#827/#841).
459    /// - `clear()` runs first, so all slots are empty and the
460    ///   `last_text_len` bound (#877) stays valid after truncation.
461    ///
462    /// # 引数
463    ///
464    /// * `text_len` - Target sentence length in bytes; buffers are reduced
465    ///   to what a sentence of this length requires. Buffers already at or
466    ///   below the target are left untouched.
467    pub fn shrink_to(&mut self, text_len: usize) {
468        self.clear();
469        let slots = text_len + 1;
470        if self.capacity > text_len {
471            self.ends_at.truncate(slots);
472            self.ends_at.shrink_to(slots);
473            for slot in &mut self.ends_at {
474                // Keep the per-slot pre-size intact (#841); only release
475                // growth beyond it.
476                slot.shrink_to(16);
477            }
478            self.capacity = text_len;
479        }
480        if self.nbest_capacity > text_len {
481            self.all_paths.truncate(slots);
482            self.all_paths.shrink_to(slots);
483            for paths in &mut self.all_paths {
484                paths.shrink_to(0);
485            }
486            self.nbest_capacity = text_len;
487        }
488        // All slots are empty after clear() + truncate, so lowering the
489        // clear()/backtrace bound is safe (its debug_asserts hold trivially).
490        self.last_text_len = self.last_text_len.min(text_len);
491        // Scratch buffers are sized per sentence content, not per slot; the
492        // bounds below are heuristics (roughly: categories per char, matches
493        // per start position), not correctness requirements — set_text
494        // regrows them on demand.
495        self.char_info_buffer.shrink_to(text_len);
496        self.categories_buffer.shrink_to(4 * text_len);
497        self.matches_head.shrink_to(slots);
498        self.matches_store.shrink_to(8 * slots);
499    }
500
501    #[inline(never)]
502    // Forward Viterbi implementation:
503    // Constructs the lattice and calculates the path costs simultaneously.
504    // This improves performance by avoiding a separate lattice traversal pass.
505    #[allow(clippy::too_many_arguments)]
506    pub fn set_text(
507        &mut self,
508        dict: &PrefixDictionary,
509        user_dict: &Option<&PrefixDictionary>,
510        char_definitions: &CharacterDefinition,
511        unknown_dictionary: &UnknownDictionary,
512        cost_matrix: &ConnectionCostMatrix,
513        text: &str,
514        search_mode: &Mode,
515    ) {
516        let len = text.len();
517        self.set_capacity(len);
518
519        // Pre-calculate character information for the text
520        self.char_info_buffer.clear();
521        self.categories_buffer.clear();
522
523        for (byte_offset, c) in text.char_indices() {
524            let categories_start = self.categories_buffer.len() as u32;
525
526            // Category lookup is O(1) for BMP codepoints via the flat table
527            // built at dictionary load (#878), so no per-lattice cache is
528            // needed.
529            let categories = char_definitions.lookup_categories(c);
530            for &category in categories {
531                self.categories_buffer.push(category);
532            }
533
534            let categories_len = (self.categories_buffer.len() as u32 - categories_start) as u16;
535
536            self.char_info_buffer.push(CharData {
537                byte_offset: byte_offset as u32,
538                is_kanji: is_kanji(c),
539                categories_start,
540                categories_len,
541                kanji_run_byte_len: 0,
542            });
543        }
544        // Sentinel for end of text
545        self.char_info_buffer.push(CharData {
546            byte_offset: len as u32,
547            is_kanji: false,
548            categories_start: 0,
549            categories_len: 0,
550            kanji_run_byte_len: 0,
551        });
552
553        // Pre-calculate Kanji run lengths (backwards)
554        for i in (0..self.char_info_buffer.len() - 1).rev() {
555            if self.char_info_buffer[i].is_kanji {
556                let next_byte_offset = self.char_info_buffer[i + 1].byte_offset;
557                let char_byte_len = next_byte_offset - self.char_info_buffer[i].byte_offset;
558                self.char_info_buffer[i].kanji_run_byte_len =
559                    char_byte_len + self.char_info_buffer[i + 1].kanji_run_byte_len;
560            } else {
561                self.char_info_buffer[i].kanji_run_byte_len = 0;
562            }
563        }
564
565        let start_edge = Edge {
566            path_cost: 0,
567            left_index: u16::MAX,
568            ..Default::default()
569        };
570        self.ends_at[0].push(start_edge);
571
572        // Index of the last character of unknown word
573        let mut unknown_word_end: Option<usize> = None;
574
575        // Pre-scan text with Aho-Corasick to report all matches
576        // Optimization: Use flat vectors instead of Vec<Vec<_>> to avoid many small allocations.
577        // Linked list structure: matches_head[start_idx] -> index in matches_store
578        // Buffers are Lattice fields reused across calls; refill matches_head (its
579        // contents are meaningful, unlike ends_at's empty-Vec slots) and clear
580        // matches_store (a plain append-only pool).
581        self.matches_head.clear();
582        self.matches_head.resize(len + 1, u32::MAX);
583        self.matches_store.clear();
584
585        // System dictionary scan
586        let vals: &[u8] = &dict.vals_data;
587        for m in dict.da.find_overlapping_iter(text) {
588            let start = m.start();
589            let (offset, count) = dict.decode_val(m.value());
590            let offset_bytes = (offset as usize) * WordEntry::SERIALIZED_LEN;
591
592            // Bounds check for safety, though daachorse should guarantee valid ids if built correctly
593            if start < self.matches_head.len() {
594                // Take the valid prefix of the entry block with one bounds
595                // computation instead of a length check per entry (#880).
596                let avail = vals.len().saturating_sub(offset_bytes);
597                let n = (count as usize).min(avail / WordEntry::SERIALIZED_LEN);
598                let block = &vals[offset_bytes..offset_bytes + n * WordEntry::SERIALIZED_LEN];
599                let end = m.end() as u32;
600                for chunk in block.chunks_exact(WordEntry::SERIALIZED_LEN) {
601                    let entry = WordEntry::deserialize(chunk, true);
602                    let next = self.matches_head[start];
603                    self.matches_head[start] = self.matches_store.len() as u32;
604                    self.matches_store.push((end, entry, next));
605                }
606            }
607        }
608
609        // User dictionary scan
610        if let Some(ud) = user_dict {
611            let ud_vals: &[u8] = &ud.vals_data;
612            for m in ud.da.find_overlapping_iter(text) {
613                let start = m.start();
614                let (offset, count) = ud.decode_val(m.value());
615                let offset_bytes = (offset as usize) * WordEntry::SERIALIZED_LEN;
616
617                if start < self.matches_head.len() {
618                    let avail = ud_vals.len().saturating_sub(offset_bytes);
619                    let n = (count as usize).min(avail / WordEntry::SERIALIZED_LEN);
620                    let block =
621                        &ud_vals[offset_bytes..offset_bytes + n * WordEntry::SERIALIZED_LEN];
622                    let end = m.end() as u32;
623                    for chunk in block.chunks_exact(WordEntry::SERIALIZED_LEN) {
624                        let entry = WordEntry::deserialize(chunk, false);
625                        let next = self.matches_head[start];
626                        self.matches_head[start] = self.matches_store.len() as u32;
627                        self.matches_store.push((end, entry, next));
628                    }
629                }
630            }
631        }
632
633        for char_idx in 0..self.char_info_buffer.len() - 1 {
634            let start = self.char_info_buffer[char_idx].byte_offset as usize;
635
636            // No arc is ending here.
637            // No need to check if a valid word starts here.
638            if self.ends_at[start].is_empty() {
639                continue;
640            }
641
642            let mut found: bool = false;
643
644            // Use cached matches
645            if start < self.matches_head.len() {
646                let mut match_idx = self.matches_head[start];
647                while match_idx != u32::MAX {
648                    let (end, word_entry, next) = self.matches_store[match_idx as usize];
649
650                    let prefix_len = end as usize - start;
651                    let kanji_only = self.is_kanji_all(char_idx, prefix_len);
652                    let edge = Self::create_edge(
653                        word_entry, // WordEntry is Copy
654                        start,
655                        end as usize,
656                        kanji_only,
657                    );
658                    self.add_edge_in_lattice(edge, cost_matrix, search_mode);
659                    found = true;
660
661                    match_idx = next;
662                }
663            }
664
665            // In the case of normal mode, it doesn't process unknown word greedily.
666            if (search_mode.is_search()
667                || unknown_word_end.map(|index| index <= start).unwrap_or(true))
668                && char_idx < self.char_info_buffer.len() - 1
669            {
670                let num_categories = self.char_info_buffer[char_idx].categories_len as usize;
671                for category_ord in 0..num_categories {
672                    let category = self.get_cached_category(char_idx, category_ord);
673                    unknown_word_end = self.process_unknown_word(
674                        char_definitions,
675                        unknown_dictionary,
676                        cost_matrix,
677                        search_mode,
678                        category,
679                        category_ord,
680                        unknown_word_end,
681                        start,
682                        char_idx,
683                        found,
684                    );
685                }
686            }
687        }
688
689        // Connect EOS
690        if !self.ends_at[len].is_empty() {
691            let mut eos_edge = Edge {
692                start_index: len as u32,
693                stop_index: len as u32,
694                ..Default::default()
695            };
696            // Calculate cost for EOS with the row hoisted (#880).
697            let left_edges = &self.ends_at[len];
698            let mut best_cost = i32::MAX;
699            let mut best_left = None;
700            let cost_row = cost_matrix.row(0); // EOS default left_id
701
702            for (i, left_edge) in left_edges.iter().enumerate() {
703                let path_cost =
704                    left_edge.path_cost + cost_row[left_edge.word_entry.right_id() as usize] as i32;
705                if path_cost < best_cost {
706                    best_cost = path_cost;
707                    best_left = Some(i as u16);
708                }
709            }
710            if let Some(left_idx) = best_left {
711                eos_edge.left_index = left_idx;
712                eos_edge.path_cost = best_cost;
713                self.ends_at[len].push(eos_edge);
714            }
715        }
716    }
717
718    #[allow(clippy::too_many_arguments)]
719    fn process_unknown_word(
720        &mut self,
721        char_definitions: &CharacterDefinition,
722        unknown_dictionary: &UnknownDictionary,
723        cost_matrix: &ConnectionCostMatrix,
724        search_mode: &Mode,
725        category: CategoryId,
726        category_ord: usize,
727        unknown_word_index: Option<usize>,
728        start: usize,
729        char_idx: usize,
730        found: bool,
731    ) -> Option<usize> {
732        let mut unknown_word_num_chars: usize = 0;
733        let category_data = char_definitions.lookup_definition(category);
734        if category_data.invoke || !found {
735            unknown_word_num_chars = 1;
736            if category_data.group {
737                for i in 1.. {
738                    let next_idx = char_idx + i;
739                    if next_idx >= self.char_info_buffer.len() - 1 {
740                        break;
741                    }
742                    let num_categories = self.char_info_buffer[next_idx].categories_len as usize;
743                    let mut found_cat = false;
744                    if category_ord < num_categories {
745                        let cat = self.get_cached_category(next_idx, category_ord);
746                        if cat == category {
747                            unknown_word_num_chars += 1;
748                            found_cat = true;
749                        }
750                    }
751                    if !found_cat {
752                        break;
753                    }
754                }
755            }
756        }
757        if unknown_word_num_chars > 0 {
758            let byte_end_offset =
759                self.char_info_buffer[char_idx + unknown_word_num_chars].byte_offset;
760            let byte_len = byte_end_offset as usize - start;
761
762            // Check Kanji status using pre-calculated buffer
763            let kanji_only = self.is_kanji_all(char_idx, byte_len);
764
765            for &word_id in unknown_dictionary.lookup_word_ids(category) {
766                let word_entry = unknown_dictionary.word_entry(word_id);
767                let edge = Self::create_edge(word_entry, start, start + byte_len, kanji_only);
768                self.add_edge_in_lattice(edge, cost_matrix, search_mode);
769            }
770            return Some(start + byte_len);
771        }
772        unknown_word_index
773    }
774
775    // Adds an edge to the lattice and calculates the minimum cost to reach it.
776    fn add_edge_in_lattice(
777        &mut self,
778        mut edge: Edge,
779        cost_matrix: &ConnectionCostMatrix,
780        mode: &Mode,
781    ) {
782        let start_index = edge.start_index as usize;
783        let stop_index = edge.stop_index as usize;
784        let right_left_id = edge.word_entry.left_id();
785
786        if self.ends_at[start_index].is_empty() {
787            return;
788        }
789
790        let mut best_cost = i32::MAX;
791        let mut best_left = None;
792
793        match mode {
794            Mode::Normal => {
795                // Matrix row hoisted out of the loop; the plain additions
796                // cannot overflow thanks to PATH_COST_CLAMP (#880).
797                let left_edges = &self.ends_at[start_index];
798                let cost_row = cost_matrix.row(right_left_id);
799                for (i, left_edge) in left_edges.iter().enumerate() {
800                    let conn_cost = cost_row[left_edge.word_entry.right_id() as usize] as i32;
801                    let total_cost = left_edge.path_cost + conn_cost;
802
803                    if total_cost < best_cost {
804                        best_cost = total_cost;
805                        best_left = Some(i as u16);
806                    }
807                }
808            }
809            Mode::Decompose(penalty) => {
810                let left_edges = &self.ends_at[start_index];
811                for (i, left_edge) in left_edges.iter().enumerate() {
812                    let left_right_id = left_edge.word_entry.right_id();
813                    let conn_cost = cost_matrix.cost(left_right_id, right_left_id);
814                    let penalty_cost = penalty.penalty(left_edge);
815                    let total_cost = left_edge
816                        .path_cost
817                        .saturating_add(conn_cost)
818                        .saturating_add(penalty_cost);
819
820                    if total_cost < best_cost {
821                        best_cost = total_cost;
822                        best_left = Some(i as u16);
823                    }
824                }
825            }
826        }
827
828        if let Some(best_left_idx) = best_left {
829            edge.path_cost = best_cost
830                .saturating_add(edge.word_entry.word_cost as i32)
831                .min(PATH_COST_CLAMP);
832            edge.left_index = best_left_idx;
833            self.ends_at[stop_index].push(edge);
834        }
835    }
836
837    /// Backtraces the best path and returns `(start_byte_offset, word_id)`
838    /// pairs for each token in reading order (BOS/EOS excluded).
839    ///
840    /// # Returns
841    ///
842    /// A freshly allocated offsets vector; empty when the lattice holds no
843    /// complete path. Prefer [`Lattice::tokens_offset_into`] in per-sentence
844    /// loops to reuse one allocation across sentences.
845    pub fn tokens_offset(&self) -> Vec<(usize, WordId)> {
846        let mut offsets = Vec::new();
847        self.tokens_offset_into(&mut offsets);
848        offsets
849    }
850
851    /// Backtraces the best path into a caller-provided buffer, clearing it
852    /// first, so the allocation can be reused across sentences.
853    ///
854    /// # Arguments
855    ///
856    /// * `offsets` - The buffer to fill with `(start_byte_offset, word_id)`
857    ///   pairs in reading order (BOS/EOS excluded). Cleared on entry; left
858    ///   empty when the lattice holds no complete path.
859    pub fn tokens_offset_into(&self, offsets: &mut Vec<(usize, WordId)>) {
860        offsets.clear();
861
862        if self.ends_at.is_empty() {
863            return;
864        }
865
866        // The EOS edge, when present, sits at `ends_at[last_text_len]`
867        // (see set_text), and every slot past it is always empty, so the
868        // backward scan starts there rather than at the historical
869        // capacity end (#877).
870        let mut last_idx = self.last_text_len.min(self.ends_at.len() - 1);
871        while last_idx > 0 && self.ends_at[last_idx].is_empty() {
872            last_idx -= 1;
873        }
874
875        if self.ends_at[last_idx].is_empty() {
876            return;
877        }
878
879        let idx = self.ends_at[last_idx].len() - 1;
880        let mut edge = &self.ends_at[last_idx][idx];
881
882        if edge.left_index == u16::MAX {
883            return;
884        }
885
886        loop {
887            if edge.left_index == u16::MAX {
888                break;
889            }
890
891            offsets.push((edge.start_index as usize, edge.word_entry.word_id));
892
893            let left_idx = edge.left_index as usize;
894            let start_idx = edge.start_index as usize;
895
896            edge = &self.ends_at[start_idx][left_idx];
897        }
898
899        offsets.reverse();
900        offsets.pop(); // Remove EOS
901    }
902
903    // --- N-Best support ---
904
905    /// Returns the text length (in bytes) from the last set_text/set_text_nbest call.
906    pub fn text_len(&self) -> usize {
907        self.last_text_len
908    }
909
910    /// Returns the edges at a given byte position.
911    pub fn edges_at(&self, byte_pos: usize) -> &[Edge] {
912        &self.ends_at[byte_pos]
913    }
914
915    /// Returns the N-Best path entries at a given byte position.
916    pub fn paths_at(&self, byte_pos: usize) -> &[PathEntry] {
917        if byte_pos < self.all_paths.len() {
918            &self.all_paths[byte_pos]
919        } else {
920            &[]
921        }
922    }
923
924    /// Adds an edge to the lattice, recording ALL predecessor transitions for N-Best.
925    fn add_edge_in_lattice_nbest(
926        &mut self,
927        mut edge: Edge,
928        cost_matrix: &ConnectionCostMatrix,
929        mode: &Mode,
930    ) {
931        let start_index = edge.start_index as usize;
932        let stop_index = edge.stop_index as usize;
933        let right_left_id = edge.word_entry.left_id();
934
935        if self.ends_at[start_index].is_empty() {
936            return;
937        }
938
939        let mut best_cost = i32::MAX;
940        let mut best_left = None;
941
942        // The edge_index of the new edge being added
943        let new_edge_index = self.ends_at[stop_index].len() as u16;
944
945        match mode {
946            Mode::Normal => {
947                // Same hoisted-row scan as add_edge_in_lattice (#880).
948                let cost_row = cost_matrix.row(right_left_id);
949                for i in 0..self.ends_at[start_index].len() {
950                    let left_edge = &self.ends_at[start_index][i];
951                    let total_cost = left_edge.path_cost
952                        + cost_row[left_edge.word_entry.right_id() as usize] as i32;
953
954                    // Record ALL transitions for N-Best
955                    self.all_paths[stop_index].push(PathEntry {
956                        edge_index: new_edge_index,
957                        left_pos: start_index as u32,
958                        left_index: i as u16,
959                        cost: total_cost,
960                    });
961
962                    if total_cost < best_cost {
963                        best_cost = total_cost;
964                        best_left = Some(i as u16);
965                    }
966                }
967            }
968            Mode::Decompose(penalty) => {
969                for i in 0..self.ends_at[start_index].len() {
970                    let left_edge = &self.ends_at[start_index][i];
971                    let left_right_id = left_edge.word_entry.right_id();
972                    let conn_cost = cost_matrix.cost(left_right_id, right_left_id);
973                    let penalty_cost = penalty.penalty(left_edge);
974                    let total_cost = left_edge
975                        .path_cost
976                        .saturating_add(conn_cost)
977                        .saturating_add(penalty_cost);
978
979                    // Record ALL transitions for N-Best
980                    self.all_paths[stop_index].push(PathEntry {
981                        edge_index: new_edge_index,
982                        left_pos: start_index as u32,
983                        left_index: i as u16,
984                        cost: total_cost,
985                    });
986
987                    if total_cost < best_cost {
988                        best_cost = total_cost;
989                        best_left = Some(i as u16);
990                    }
991                }
992            }
993        }
994
995        if let Some(best_left_idx) = best_left {
996            edge.path_cost = best_cost
997                .saturating_add(edge.word_entry.word_cost as i32)
998                .min(PATH_COST_CLAMP);
999            edge.left_index = best_left_idx;
1000            self.ends_at[stop_index].push(edge);
1001        }
1002    }
1003
1004    #[allow(clippy::too_many_arguments)]
1005    fn process_unknown_word_nbest(
1006        &mut self,
1007        char_definitions: &CharacterDefinition,
1008        unknown_dictionary: &UnknownDictionary,
1009        cost_matrix: &ConnectionCostMatrix,
1010        search_mode: &Mode,
1011        category: CategoryId,
1012        category_ord: usize,
1013        unknown_word_index: Option<usize>,
1014        start: usize,
1015        char_idx: usize,
1016        found: bool,
1017    ) -> Option<usize> {
1018        let mut unknown_word_num_chars: usize = 0;
1019        let category_data = char_definitions.lookup_definition(category);
1020        if category_data.invoke || !found {
1021            unknown_word_num_chars = 1;
1022            if category_data.group {
1023                for i in 1.. {
1024                    let next_idx = char_idx + i;
1025                    if next_idx >= self.char_info_buffer.len() - 1 {
1026                        break;
1027                    }
1028                    let num_categories = self.char_info_buffer[next_idx].categories_len as usize;
1029                    let mut found_cat = false;
1030                    if category_ord < num_categories {
1031                        let cat = self.get_cached_category(next_idx, category_ord);
1032                        if cat == category {
1033                            unknown_word_num_chars += 1;
1034                            found_cat = true;
1035                        }
1036                    }
1037                    if !found_cat {
1038                        break;
1039                    }
1040                }
1041            }
1042        }
1043        if unknown_word_num_chars > 0 {
1044            let byte_end_offset =
1045                self.char_info_buffer[char_idx + unknown_word_num_chars].byte_offset;
1046            let byte_len = byte_end_offset as usize - start;
1047
1048            let kanji_only = self.is_kanji_all(char_idx, byte_len);
1049
1050            for &word_id in unknown_dictionary.lookup_word_ids(category) {
1051                let word_entry = unknown_dictionary.word_entry(word_id);
1052                let edge = Self::create_edge(word_entry, start, start + byte_len, kanji_only);
1053                self.add_edge_in_lattice_nbest(edge, cost_matrix, search_mode);
1054            }
1055            return Some(start + byte_len);
1056        }
1057        unknown_word_index
1058    }
1059
1060    /// Forward Viterbi implementation for N-Best mode.
1061    /// Same as set_text() but records ALL predecessor transitions in all_paths.
1062    #[inline(never)]
1063    #[allow(clippy::too_many_arguments)]
1064    pub fn set_text_nbest(
1065        &mut self,
1066        dict: &PrefixDictionary,
1067        user_dict: &Option<&PrefixDictionary>,
1068        char_definitions: &CharacterDefinition,
1069        unknown_dictionary: &UnknownDictionary,
1070        cost_matrix: &ConnectionCostMatrix,
1071        text: &str,
1072        search_mode: &Mode,
1073    ) {
1074        let len = text.len();
1075        self.set_capacity_nbest(len);
1076
1077        // Pre-calculate character information for the text
1078        self.char_info_buffer.clear();
1079        self.categories_buffer.clear();
1080
1081        for (byte_offset, c) in text.char_indices() {
1082            let categories_start = self.categories_buffer.len() as u32;
1083
1084            // Category lookup is O(1) for BMP codepoints via the flat table
1085            // built at dictionary load (#878), so no per-lattice cache is
1086            // needed.
1087            let categories = char_definitions.lookup_categories(c);
1088            for &category in categories {
1089                self.categories_buffer.push(category);
1090            }
1091
1092            let categories_len = (self.categories_buffer.len() as u32 - categories_start) as u16;
1093
1094            self.char_info_buffer.push(CharData {
1095                byte_offset: byte_offset as u32,
1096                is_kanji: is_kanji(c),
1097                categories_start,
1098                categories_len,
1099                kanji_run_byte_len: 0,
1100            });
1101        }
1102        // Sentinel for end of text
1103        self.char_info_buffer.push(CharData {
1104            byte_offset: len as u32,
1105            is_kanji: false,
1106            categories_start: 0,
1107            categories_len: 0,
1108            kanji_run_byte_len: 0,
1109        });
1110
1111        // Pre-calculate Kanji run lengths (backwards)
1112        for i in (0..self.char_info_buffer.len() - 1).rev() {
1113            if self.char_info_buffer[i].is_kanji {
1114                let next_byte_offset = self.char_info_buffer[i + 1].byte_offset;
1115                let char_byte_len = next_byte_offset - self.char_info_buffer[i].byte_offset;
1116                self.char_info_buffer[i].kanji_run_byte_len =
1117                    char_byte_len + self.char_info_buffer[i + 1].kanji_run_byte_len;
1118            } else {
1119                self.char_info_buffer[i].kanji_run_byte_len = 0;
1120            }
1121        }
1122
1123        let start_edge = Edge {
1124            path_cost: 0,
1125            left_index: u16::MAX,
1126            ..Default::default()
1127        };
1128        self.ends_at[0].push(start_edge);
1129
1130        let mut unknown_word_end: Option<usize> = None;
1131
1132        // Pre-scan text with Aho-Corasick
1133        // Buffers are Lattice fields reused across calls; refill matches_head (its
1134        // contents are meaningful, unlike ends_at's empty-Vec slots) and clear
1135        // matches_store (a plain append-only pool).
1136        self.matches_head.clear();
1137        self.matches_head.resize(len + 1, u32::MAX);
1138        self.matches_store.clear();
1139
1140        // System dictionary scan
1141        let vals: &[u8] = &dict.vals_data;
1142        for m in dict.da.find_overlapping_iter(text) {
1143            let start = m.start();
1144            let (offset, count) = dict.decode_val(m.value());
1145            let offset_bytes = (offset as usize) * WordEntry::SERIALIZED_LEN;
1146
1147            if start < self.matches_head.len() {
1148                // Take the valid prefix of the entry block with one bounds
1149                // computation instead of a length check per entry (#880).
1150                let avail = vals.len().saturating_sub(offset_bytes);
1151                let n = (count as usize).min(avail / WordEntry::SERIALIZED_LEN);
1152                let block = &vals[offset_bytes..offset_bytes + n * WordEntry::SERIALIZED_LEN];
1153                let end = m.end() as u32;
1154                for chunk in block.chunks_exact(WordEntry::SERIALIZED_LEN) {
1155                    let entry = WordEntry::deserialize(chunk, true);
1156                    let next = self.matches_head[start];
1157                    self.matches_head[start] = self.matches_store.len() as u32;
1158                    self.matches_store.push((end, entry, next));
1159                }
1160            }
1161        }
1162
1163        // User dictionary scan
1164        if let Some(ud) = user_dict {
1165            let ud_vals: &[u8] = &ud.vals_data;
1166            for m in ud.da.find_overlapping_iter(text) {
1167                let start = m.start();
1168                let (offset, count) = ud.decode_val(m.value());
1169                let offset_bytes = (offset as usize) * WordEntry::SERIALIZED_LEN;
1170
1171                if start < self.matches_head.len() {
1172                    let avail = ud_vals.len().saturating_sub(offset_bytes);
1173                    let n = (count as usize).min(avail / WordEntry::SERIALIZED_LEN);
1174                    let block =
1175                        &ud_vals[offset_bytes..offset_bytes + n * WordEntry::SERIALIZED_LEN];
1176                    let end = m.end() as u32;
1177                    for chunk in block.chunks_exact(WordEntry::SERIALIZED_LEN) {
1178                        let entry = WordEntry::deserialize(chunk, false);
1179                        let next = self.matches_head[start];
1180                        self.matches_head[start] = self.matches_store.len() as u32;
1181                        self.matches_store.push((end, entry, next));
1182                    }
1183                }
1184            }
1185        }
1186
1187        for char_idx in 0..self.char_info_buffer.len() - 1 {
1188            let start = self.char_info_buffer[char_idx].byte_offset as usize;
1189
1190            if self.ends_at[start].is_empty() {
1191                continue;
1192            }
1193
1194            let mut found: bool = false;
1195
1196            if start < self.matches_head.len() {
1197                let mut match_idx = self.matches_head[start];
1198                while match_idx != u32::MAX {
1199                    let (end, word_entry, next) = self.matches_store[match_idx as usize];
1200
1201                    let prefix_len = end as usize - start;
1202                    let kanji_only = self.is_kanji_all(char_idx, prefix_len);
1203                    let edge = Self::create_edge(word_entry, start, end as usize, kanji_only);
1204                    self.add_edge_in_lattice_nbest(edge, cost_matrix, search_mode);
1205                    found = true;
1206
1207                    match_idx = next;
1208                }
1209            }
1210
1211            if (search_mode.is_search()
1212                || unknown_word_end.map(|index| index <= start).unwrap_or(true))
1213                && char_idx < self.char_info_buffer.len() - 1
1214            {
1215                let num_categories = self.char_info_buffer[char_idx].categories_len as usize;
1216                for category_ord in 0..num_categories {
1217                    let category = self.get_cached_category(char_idx, category_ord);
1218                    unknown_word_end = self.process_unknown_word_nbest(
1219                        char_definitions,
1220                        unknown_dictionary,
1221                        cost_matrix,
1222                        search_mode,
1223                        category,
1224                        category_ord,
1225                        unknown_word_end,
1226                        start,
1227                        char_idx,
1228                        found,
1229                    );
1230                }
1231            }
1232        }
1233
1234        // Connect EOS with all-path recording
1235        if !self.ends_at[len].is_empty() {
1236            let eos_edge_index = self.ends_at[len].len() as u16;
1237            let mut eos_edge = Edge {
1238                start_index: len as u32,
1239                stop_index: len as u32,
1240                ..Default::default()
1241            };
1242            let mut best_cost = i32::MAX;
1243            let mut best_left = None;
1244            let cost_row = cost_matrix.row(0); // EOS default left_id
1245
1246            for i in 0..self.ends_at[len].len() {
1247                let left_edge = &self.ends_at[len][i];
1248                let path_cost =
1249                    left_edge.path_cost + cost_row[left_edge.word_entry.right_id() as usize] as i32;
1250
1251                // Record all transitions to EOS
1252                self.all_paths[len].push(PathEntry {
1253                    edge_index: eos_edge_index,
1254                    left_pos: len as u32,
1255                    left_index: i as u16,
1256                    cost: path_cost,
1257                });
1258
1259                if path_cost < best_cost {
1260                    best_cost = path_cost;
1261                    best_left = Some(i as u16);
1262                }
1263            }
1264            if let Some(left_idx) = best_left {
1265                eos_edge.left_index = left_idx;
1266                eos_edge.path_cost = best_cost;
1267                self.ends_at[len].push(eos_edge);
1268            }
1269        }
1270    }
1271
1272    /// Returns the top-N paths through the lattice.
1273    /// Each result is a (path, cost) pair where path is a Vec of (byte_start, WordId) pairs.
1274    /// The first result (index 0) is the 1-best path.
1275    /// If `unique` is true, paths with the same segmentation (same byte_start sequence)
1276    /// are deduplicated, keeping only the first (lowest cost) variant.
1277    /// If `cost_threshold` is Some(t), paths whose cost exceeds best_cost + t are discarded.
1278    /// Requires set_text_nbest() to have been called first.
1279    pub fn nbest_tokens_offset(
1280        &self,
1281        n: usize,
1282        unique: bool,
1283        cost_threshold: Option<i64>,
1284    ) -> Vec<(Vec<(usize, WordId)>, i64)> {
1285        use std::collections::HashSet;
1286
1287        use crate::nbest::NBestGenerator;
1288        let mut generator = NBestGenerator::new(self);
1289        let mut results = Vec::with_capacity(n);
1290        let mut best_cost: Option<i64> = None;
1291
1292        if unique {
1293            let mut seen: HashSet<Vec<usize>> = HashSet::new();
1294            while results.len() < n {
1295                match generator.next() {
1296                    Some((path, cost)) => {
1297                        // Record best cost from first result
1298                        let bc = *best_cost.get_or_insert(cost);
1299                        // Skip if cost exceeds threshold
1300                        if let Some(threshold) = cost_threshold
1301                            && cost > bc + threshold
1302                        {
1303                            break;
1304                        }
1305                        let key: Vec<usize> = path.iter().map(|(start, _)| *start).collect();
1306                        if seen.insert(key) {
1307                            results.push((path, cost));
1308                        }
1309                    }
1310                    None => break,
1311                }
1312            }
1313        } else {
1314            while results.len() < n {
1315                match generator.next() {
1316                    Some((path, cost)) => {
1317                        let bc = *best_cost.get_or_insert(cost);
1318                        if let Some(threshold) = cost_threshold
1319                            && cost > bc + threshold
1320                        {
1321                            break;
1322                        }
1323                        results.push((path, cost));
1324                    }
1325                    None => break,
1326                }
1327            }
1328        }
1329        results
1330    }
1331}
1332
1333#[cfg(test)]
1334mod tests {
1335    use crate::viterbi::{Edge, Lattice, LexType, WordEntry, WordId};
1336
1337    /// Builds an edge whose backtrace fields are set explicitly, for
1338    /// hand-assembled lattices in tests.
1339    fn test_edge(word_id: u32, start: usize, stop: usize, left_index: u16) -> Edge {
1340        let mut edge = Lattice::create_edge(
1341            WordEntry::new(WordId::new(LexType::System, word_id), 0, 0, 0),
1342            start,
1343            stop,
1344            false,
1345        );
1346        edge.left_index = left_index;
1347        edge.path_cost = 0;
1348        edge
1349    }
1350
1351    #[test]
1352    fn test_word_entry() {
1353        let mut buffer = Vec::new();
1354        let word_entry =
1355            WordEntry::new(WordId::new(LexType::System, 1u32), -17i16, 1411u16, 1412u16);
1356        word_entry.serialize(&mut buffer).unwrap();
1357        assert_eq!(WordEntry::SERIALIZED_LEN, buffer.len());
1358        let word_entry2 = WordEntry::deserialize(&buffer[..], true);
1359        assert_eq!(word_entry, word_entry2);
1360    }
1361
1362    /// Regression test for #827: `Vec::resize` clones its template value
1363    /// into all but the last new slot, and cloning an empty Vec yields
1364    /// capacity 0, so only the last slot was actually pre-sized. Every
1365    /// newly-grown `ends_at` slot must get the intended pre-size, both on
1366    /// the initial growth and on a later, larger growth.
1367    #[test]
1368    fn test_set_capacity_presizes_all_new_slots() {
1369        let mut lattice = Lattice::default();
1370
1371        lattice.set_capacity(5);
1372        assert_eq!(lattice.ends_at.len(), 6);
1373        for (i, slot) in lattice.ends_at.iter().enumerate() {
1374            assert!(
1375                slot.capacity() >= 16,
1376                "slot {} has capacity {} < 16 after initial growth",
1377                i,
1378                slot.capacity()
1379            );
1380        }
1381
1382        // Growing an already-used lattice must pre-size the appended slots too.
1383        lattice.set_capacity(10);
1384        assert_eq!(lattice.ends_at.len(), 11);
1385        for (i, slot) in lattice.ends_at.iter().enumerate() {
1386            assert!(
1387                slot.capacity() >= 16,
1388                "slot {} has capacity {} < 16 after second growth",
1389                i,
1390                slot.capacity()
1391            );
1392        }
1393    }
1394
1395    /// Regression test for #877: `clear()` walks only `..=last_text_len`
1396    /// instead of the historical max capacity, so it must still clear every
1397    /// slot the previous sentence could have written — including the
1398    /// boundary slot at exactly `last_text_len` (EOS position).
1399    #[test]
1400    fn test_clear_after_shrink_leaves_no_stale_edges() {
1401        let mut lattice = Lattice::default();
1402
1403        // Long sentence: capacity grows to 101 slots, writes up to index 100.
1404        lattice.set_capacity(100);
1405        lattice.ends_at[0].push(test_edge(1, 0, 0, u16::MAX));
1406        lattice.ends_at[57].push(test_edge(2, 0, 57, 0));
1407        lattice.ends_at[100].push(test_edge(3, 57, 100, 0)); // boundary slot
1408
1409        // Shorter sentence: clear() runs bounded by the previous
1410        // last_text_len (100), then records the new length.
1411        lattice.set_capacity(10);
1412        assert!(
1413            lattice.ends_at.iter().all(|v| v.is_empty()),
1414            "stale edges survived a bounded clear"
1415        );
1416
1417        // A second shrink exercises the induction step: nothing past the
1418        // new bound (10) may hold entries, and slots within it are cleared.
1419        lattice.ends_at[10].push(test_edge(4, 0, 10, 0)); // boundary slot again
1420        lattice.set_capacity(3);
1421        assert!(
1422            lattice.ends_at.iter().all(|v| v.is_empty()),
1423            "stale edge at the previous boundary slot survived"
1424        );
1425    }
1426
1427    /// Regression test for #877: the `tokens_offset` backward scan starts at
1428    /// `last_text_len`, which must still find the EOS edge at exactly that
1429    /// index after the capacity has grown far beyond the current sentence.
1430    #[test]
1431    fn test_tokens_offset_finds_eos_at_last_text_len_after_shrink() {
1432        let mut lattice = Lattice::default();
1433
1434        // Grow capacity well past the sentence we are about to assemble.
1435        lattice.set_capacity(100);
1436
1437        // Hand-assembled best path for a 3-byte sentence:
1438        // BOS(ends_at[0]) <- token A (0..3) <- EOS(ends_at[3]).
1439        lattice.set_capacity(3);
1440        lattice.ends_at[0].push(test_edge(0, 0, 0, u16::MAX)); // BOS
1441        lattice.ends_at[3].push(test_edge(42, 0, 3, 0)); // token A
1442        lattice.ends_at[3].push(test_edge(0, 3, 3, 0)); // EOS -> token A
1443
1444        let offsets = lattice.tokens_offset();
1445        assert_eq!(offsets.len(), 1);
1446        assert_eq!(offsets[0].0, 0);
1447        assert_eq!(offsets[0].1, WordId::new(LexType::System, 42));
1448    }
1449
1450    /// `shrink_to` must release slots beyond the target while preserving
1451    /// the #841 per-slot pre-size on the remaining slots, and the lattice
1452    /// must regrow correctly (pre-sized) afterwards.
1453    #[test]
1454    fn test_shrink_to_truncates_and_keeps_presize() {
1455        let mut lattice = Lattice::default();
1456
1457        lattice.set_capacity(100);
1458        lattice.ends_at[0].push(test_edge(1, 0, 0, u16::MAX));
1459        lattice.ends_at[100].push(test_edge(2, 0, 100, 0));
1460
1461        lattice.shrink_to(10);
1462        assert_eq!(lattice.capacity(), 10);
1463        assert_eq!(lattice.ends_at.len(), 11);
1464        assert!(
1465            lattice.ends_at.iter().all(|v| v.is_empty()),
1466            "shrink_to must clear all slots"
1467        );
1468        for (i, slot) in lattice.ends_at.iter().enumerate() {
1469            assert!(
1470                slot.capacity() >= 16,
1471                "slot {} lost its pre-size after shrink_to (capacity {})",
1472                i,
1473                slot.capacity()
1474            );
1475        }
1476
1477        // Regrowth after a shrink must pre-size the appended slots again.
1478        lattice.set_capacity(50);
1479        assert_eq!(lattice.ends_at.len(), 51);
1480        for (i, slot) in lattice.ends_at.iter().enumerate() {
1481            assert!(
1482                slot.capacity() >= 16,
1483                "slot {} not pre-sized after regrowth (capacity {})",
1484                i,
1485                slot.capacity()
1486            );
1487        }
1488    }
1489
1490    /// `shrink_to` with a target at or above the current capacity must be a
1491    /// no-op for the slot vectors (no truncation, no capacity change).
1492    #[test]
1493    fn test_shrink_to_noop_when_target_not_smaller() {
1494        let mut lattice = Lattice::default();
1495        lattice.set_capacity(5);
1496
1497        lattice.shrink_to(100);
1498        assert_eq!(lattice.capacity(), 5);
1499        assert_eq!(lattice.ends_at.len(), 6);
1500
1501        lattice.shrink_to(5);
1502        assert_eq!(lattice.capacity(), 5);
1503        assert_eq!(lattice.ends_at.len(), 6);
1504
1505        // A fresh lattice tolerates shrink_to without panicking.
1506        let mut fresh = Lattice::default();
1507        fresh.shrink_to(0);
1508        assert_eq!(fresh.capacity(), 0);
1509        assert!(fresh.ends_at.is_empty());
1510    }
1511
1512    /// A lattice must produce a correct backtrace when used again after
1513    /// `shrink_to`: the `last_text_len` bound and the EOS scan start must
1514    /// stay consistent (same guarantee as the #877 regression tests, with a
1515    /// shrink in between).
1516    #[test]
1517    fn test_backtrace_works_after_shrink_to() {
1518        let mut lattice = Lattice::default();
1519        lattice.set_capacity(100);
1520        lattice.ends_at[0].push(test_edge(1, 0, 0, u16::MAX));
1521        lattice.ends_at[100].push(test_edge(2, 0, 100, 0));
1522
1523        lattice.shrink_to(10);
1524
1525        // Hand-assemble a 3-byte sentence path, as in the #877 tests.
1526        lattice.set_capacity(3);
1527        lattice.ends_at[0].push(test_edge(0, 0, 0, u16::MAX)); // BOS
1528        lattice.ends_at[3].push(test_edge(42, 0, 3, 0)); // token A
1529        lattice.ends_at[3].push(test_edge(0, 3, 3, 0)); // EOS -> token A
1530
1531        let offsets = lattice.tokens_offset();
1532        assert_eq!(offsets.len(), 1);
1533        assert_eq!(offsets[0].0, 0);
1534        assert_eq!(offsets[0].1, WordId::new(LexType::System, 42));
1535
1536        // clear() after the shrink must leave nothing behind.
1537        lattice.clear();
1538        assert!(lattice.ends_at.iter().all(|v| v.is_empty()));
1539    }
1540
1541    /// `shrink_to` must also release the N-Best `all_paths` slots.
1542    #[test]
1543    fn test_shrink_to_releases_nbest_paths() {
1544        let mut lattice = Lattice::default();
1545        lattice.set_capacity_nbest(100);
1546        assert_eq!(lattice.all_paths.len(), 101);
1547
1548        lattice.shrink_to(10);
1549        assert_eq!(lattice.all_paths.len(), 11);
1550        assert_eq!(lattice.nbest_capacity, 10);
1551        assert!(lattice.all_paths.iter().all(|v| v.is_empty()));
1552
1553        // Regrowth of the nbest side after a shrink.
1554        lattice.set_capacity_nbest(20);
1555        assert_eq!(lattice.all_paths.len(), 21);
1556    }
1557
1558    /// `tokens_offset_into` must clear the caller's buffer and produce the
1559    /// same result as `tokens_offset`, including on a pathless lattice.
1560    #[test]
1561    fn test_tokens_offset_into_matches_tokens_offset() {
1562        let mut lattice = Lattice::default();
1563        lattice.set_capacity(3);
1564        lattice.ends_at[0].push(test_edge(0, 0, 0, u16::MAX)); // BOS
1565        lattice.ends_at[3].push(test_edge(7, 0, 3, 0)); // token A
1566        lattice.ends_at[3].push(test_edge(0, 3, 3, 0)); // EOS -> token A
1567
1568        let mut reused = vec![(999usize, WordId::default())]; // stale content
1569        lattice.tokens_offset_into(&mut reused);
1570        assert_eq!(reused, lattice.tokens_offset());
1571        assert_eq!(reused.len(), 1);
1572
1573        // A cleared (pathless) lattice must leave the reused buffer empty.
1574        lattice.clear();
1575        lattice.tokens_offset_into(&mut reused);
1576        assert!(reused.is_empty());
1577        assert!(lattice.tokens_offset().is_empty());
1578    }
1579}