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, UserPrefixDictionary};
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    /// The sentence's characters, materialized once per call because the
337    /// char-wise trie consumes `&[char]` (byte offsets come from
338    /// `char_info_buffer`, which is built in the same pass).
339    chars_buf: Vec<char>,
340    /// Per-position system-dictionary matches (match end byte offset, word
341    /// entry), buffered so they can be replayed in reverse discovery order --
342    /// the order the retired whole-text pre-scan's head-inserted list drained
343    /// in, which decides the winner among equal-cost edges.
344    sys_matches: Vec<(u32, WordEntry)>,
345}
346
347/// Upper bound applied to every stored `path_cost` so the relaxation loops
348/// can use plain addition: one connection cost plus one penalty per step is
349/// at most 2 * 32,767, which cannot overflow from this clamp.
350const PATH_COST_CLAMP: i32 = i32::MAX - 131_072;
351
352#[derive(Clone, Copy, Debug, Default)]
353struct CharData {
354    byte_offset: u32,
355    is_kanji: bool,
356    categories_start: u32,
357    categories_len: u16,
358    kanji_run_byte_len: u32,
359}
360
361#[inline]
362pub fn is_kanji(c: char) -> bool {
363    let c = c as u32;
364    // CJK Unified Ideographs (4E00-9FAF) and Extension A (3400-4DBF)
365    (0x4E00..=0x9FAF).contains(&c) || (0x3400..=0x4DBF).contains(&c)
366}
367
368impl Lattice {
369    /// Helper method to create an edge efficiently
370    #[inline]
371    fn create_edge(word_entry: WordEntry, start: usize, stop: usize, kanji_only: bool) -> Edge {
372        Edge {
373            word_entry,
374            left_index: u16::MAX,
375            start_index: start as u32,
376            stop_index: stop as u32,
377            path_cost: i32::MAX,
378            kanji_only,
379        }
380    }
381
382    pub fn clear(&mut self) {
383        // Only slots up to the previous sentence's length can hold entries:
384        // every `ends_at`/`all_paths` write in set_text/set_text_nbest
385        // targets an index <= that call's text length (BOS at 0, edges at
386        // stop_index <= len, EOS at len), which `set_capacity` recorded in
387        // `last_text_len`, and every slot past it was left empty by the
388        // previous clear(). Walking only this prefix keeps clear() O(previous
389        // sentence) instead of O(historical max capacity), which matters
390        // once one long sentence has grown the lattice (#877).
391        let bound = self.last_text_len + 1;
392        for edge_vec in self.ends_at.iter_mut().take(bound) {
393            edge_vec.clear();
394        }
395        debug_assert!(
396            self.ends_at.iter().skip(bound).all(|v| v.is_empty()),
397            "ends_at slot beyond last_text_len must be empty"
398        );
399        for path_vec in self.all_paths.iter_mut().take(bound) {
400            path_vec.clear();
401        }
402        debug_assert!(
403            self.all_paths.iter().skip(bound).all(|v| v.is_empty()),
404            "all_paths slot beyond last_text_len must be empty"
405        );
406        self.char_info_buffer.clear();
407        self.categories_buffer.clear();
408    }
409
410    #[inline]
411    fn is_kanji_all(&self, char_idx: usize, byte_len: usize) -> bool {
412        self.char_info_buffer[char_idx].kanji_run_byte_len >= byte_len as u32
413    }
414
415    #[inline]
416    fn get_cached_category(&self, char_idx: usize, category_ord: usize) -> CategoryId {
417        let char_data = &self.char_info_buffer[char_idx];
418        self.categories_buffer[char_data.categories_start as usize + category_ord]
419    }
420
421    fn set_capacity(&mut self, text_len: usize) {
422        self.clear();
423        self.last_text_len = text_len;
424        if self.capacity <= text_len {
425            self.capacity = text_len;
426            // Pre-size newly-grown slots (like Vibrato's reset_vec) to
427            // avoid a couple of small reallocations the first time a busy
428            // position accumulates several edges. `resize_with` is required
429            // here: `resize` fills new slots with clones of its template
430            // value, and cloning an empty Vec allocates capacity 0, so only
431            // the moved-in last slot would actually be pre-sized (#827).
432            self.ends_at
433                .resize_with(text_len + 1, || Vec::with_capacity(16));
434        }
435    }
436
437    fn set_capacity_nbest(&mut self, text_len: usize) {
438        self.set_capacity(text_len);
439        if self.nbest_capacity <= text_len {
440            self.nbest_capacity = text_len;
441            self.all_paths.resize(text_len + 1, Vec::new());
442        }
443    }
444
445    /// Returns the lattice's current slot capacity: the largest sentence
446    /// length (in bytes) whose `ends_at` slots are already allocated.
447    ///
448    /// # 戻り値
449    ///
450    /// The capacity in bytes. `0` for a fresh lattice.
451    pub fn capacity(&self) -> usize {
452        self.capacity
453    }
454
455    /// Shrinks the internal buffers down to what a sentence of `text_len`
456    /// bytes needs, releasing memory retained after processing a long
457    /// sentence.
458    ///
459    /// The lattice grows monotonically (`set_capacity` never shrinks), so a
460    /// single long sentence pins its worst-case allocation for the lifetime
461    /// of the lattice. Long-lived holders (e.g. a reusable worker) can call
462    /// this to bound retention. This is never called on the hot path:
463    /// `clear()`/`set_text` stay shrink-free (#877/#884).
464    ///
465    /// Invariants preserved:
466    /// - Every remaining `ends_at` slot keeps a capacity of at least 16, the
467    ///   pre-size that avoids first-growth reallocations (#827/#841).
468    /// - `clear()` runs first, so all slots are empty and the
469    ///   `last_text_len` bound (#877) stays valid after truncation.
470    ///
471    /// # 引数
472    ///
473    /// * `text_len` - Target sentence length in bytes; buffers are reduced
474    ///   to what a sentence of this length requires. Buffers already at or
475    ///   below the target are left untouched.
476    pub fn shrink_to(&mut self, text_len: usize) {
477        self.clear();
478        let slots = text_len + 1;
479        if self.capacity > text_len {
480            self.ends_at.truncate(slots);
481            self.ends_at.shrink_to(slots);
482            for slot in &mut self.ends_at {
483                // Keep the per-slot pre-size intact (#841); only release
484                // growth beyond it.
485                slot.shrink_to(16);
486            }
487            self.capacity = text_len;
488        }
489        if self.nbest_capacity > text_len {
490            self.all_paths.truncate(slots);
491            self.all_paths.shrink_to(slots);
492            for paths in &mut self.all_paths {
493                paths.shrink_to(0);
494            }
495            self.nbest_capacity = text_len;
496        }
497        // All slots are empty after clear() + truncate, so lowering the
498        // clear()/backtrace bound is safe (its debug_asserts hold trivially).
499        self.last_text_len = self.last_text_len.min(text_len);
500        // Scratch buffers are sized per sentence content, not per slot; the
501        // bounds below are heuristics (roughly: categories per char, matches
502        // per start position), not correctness requirements — set_text
503        // regrows them on demand.
504        self.char_info_buffer.shrink_to(text_len);
505        self.categories_buffer.shrink_to(4 * text_len);
506        self.matches_head.shrink_to(slots);
507        self.matches_store.shrink_to(8 * slots);
508        self.chars_buf.shrink_to(text_len);
509        self.sys_matches.shrink_to(64);
510    }
511
512    #[inline(never)]
513    // Forward Viterbi implementation:
514    // Constructs the lattice and calculates the path costs simultaneously.
515    // This improves performance by avoiding a separate lattice traversal pass.
516    #[allow(clippy::too_many_arguments)]
517    pub fn set_text(
518        &mut self,
519        dict: &PrefixDictionary,
520        user_dict: &Option<&UserPrefixDictionary>,
521        char_definitions: &CharacterDefinition,
522        unknown_dictionary: &UnknownDictionary,
523        cost_matrix: &ConnectionCostMatrix,
524        text: &str,
525        search_mode: &Mode,
526    ) {
527        let len = text.len();
528        self.set_capacity(len);
529
530        // Pre-calculate character information for the text
531        self.char_info_buffer.clear();
532        self.categories_buffer.clear();
533        self.chars_buf.clear();
534
535        for (byte_offset, c) in text.char_indices() {
536            let categories_start = self.categories_buffer.len() as u32;
537
538            // Category lookup is O(1) for BMP codepoints via the flat table
539            // built at dictionary load (#878), so no per-lattice cache is
540            // needed.
541            let categories = char_definitions.lookup_categories(c);
542            for &category in categories {
543                self.categories_buffer.push(category);
544            }
545
546            let categories_len = (self.categories_buffer.len() as u32 - categories_start) as u16;
547
548            self.char_info_buffer.push(CharData {
549                byte_offset: byte_offset as u32,
550                is_kanji: is_kanji(c),
551                categories_start,
552                categories_len,
553                kanji_run_byte_len: 0,
554            });
555            self.chars_buf.push(c);
556        }
557        // Sentinel for end of text
558        self.char_info_buffer.push(CharData {
559            byte_offset: len as u32,
560            is_kanji: false,
561            categories_start: 0,
562            categories_len: 0,
563            kanji_run_byte_len: 0,
564        });
565
566        // Pre-calculate Kanji run lengths (backwards)
567        for i in (0..self.char_info_buffer.len() - 1).rev() {
568            if self.char_info_buffer[i].is_kanji {
569                let next_byte_offset = self.char_info_buffer[i + 1].byte_offset;
570                let char_byte_len = next_byte_offset - self.char_info_buffer[i].byte_offset;
571                self.char_info_buffer[i].kanji_run_byte_len =
572                    char_byte_len + self.char_info_buffer[i + 1].kanji_run_byte_len;
573            } else {
574                self.char_info_buffer[i].kanji_run_byte_len = 0;
575            }
576        }
577
578        let start_edge = Edge {
579            path_cost: 0,
580            left_index: u16::MAX,
581            ..Default::default()
582        };
583        self.ends_at[0].push(start_edge);
584
585        // Index of the last character of unknown word
586        let mut unknown_word_end: Option<usize> = None;
587
588        // Pre-scan text with Aho-Corasick to report all matches
589        // Optimization: Use flat vectors instead of Vec<Vec<_>> to avoid many small allocations.
590        // Linked list structure: matches_head[start_idx] -> index in matches_store
591        // Buffers are Lattice fields reused across calls; refill matches_head (its
592        // contents are meaningful, unlike ends_at's empty-Vec slots) and clear
593        // matches_store (a plain append-only pool).
594        // The pool now holds only user-dictionary matches: the system
595        // dictionary is searched per lattice-reachable position instead of
596        // pre-scanned (#882). daachorse's API shape still forces a whole-text
597        // scan for the user automaton, so its matches keep the linked list;
598        // with no user dictionary the head table stays empty and the drain
599        // below is skipped by its `start < matches_head.len()` guard.
600        self.matches_head.clear();
601        self.matches_store.clear();
602
603        // User dictionary scan
604        if let Some(ud) = user_dict {
605            self.matches_head.resize(len + 1, u32::MAX);
606            let ud_vals: &[u8] = &ud.vals_data;
607            for m in ud.da.find_overlapping_iter(text) {
608                let start = m.start();
609                let (offset, count) = ud.decode_val(m.value());
610                let offset_bytes = (offset as usize) * WordEntry::SERIALIZED_LEN;
611
612                if start < self.matches_head.len() {
613                    let avail = ud_vals.len().saturating_sub(offset_bytes);
614                    let n = (count as usize).min(avail / WordEntry::SERIALIZED_LEN);
615                    let block =
616                        &ud_vals[offset_bytes..offset_bytes + n * WordEntry::SERIALIZED_LEN];
617                    let end = m.end() as u32;
618                    for chunk in block.chunks_exact(WordEntry::SERIALIZED_LEN) {
619                        let entry = WordEntry::deserialize(chunk, false);
620                        let next = self.matches_head[start];
621                        self.matches_head[start] = self.matches_store.len() as u32;
622                        self.matches_store.push((end, entry, next));
623                    }
624                }
625            }
626        }
627
628        for char_idx in 0..self.char_info_buffer.len() - 1 {
629            let start = self.char_info_buffer[char_idx].byte_offset as usize;
630
631            // No arc is ending here.
632            // No need to check if a valid word starts here.
633            if self.ends_at[start].is_empty() {
634                continue;
635            }
636
637            let mut found: bool = false;
638
639            // Drain user-dictionary matches (reverse discovery order, from
640            // the head-inserted list).
641            if start < self.matches_head.len() {
642                let mut match_idx = self.matches_head[start];
643                while match_idx != u32::MAX {
644                    let (end, word_entry, next) = self.matches_store[match_idx as usize];
645
646                    let prefix_len = end as usize - start;
647                    let kanji_only = self.is_kanji_all(char_idx, prefix_len);
648                    let edge = Self::create_edge(
649                        word_entry, // WordEntry is Copy
650                        start,
651                        end as usize,
652                        kanji_only,
653                    );
654                    self.add_edge_in_lattice(edge, cost_matrix, search_mode);
655                    found = true;
656
657                    match_idx = next;
658                }
659            }
660
661            // System dictionary: per-position common-prefix search over the
662            // in-place trie, run only at lattice-reachable positions (the
663            // gate above). Matches are buffered and replayed in reverse so
664            // equal-cost tie-breaks keep choosing the same winner as the
665            // retired whole-text pre-scan; user matches were drained first
666            // for the same reason (the old shared list held them on top).
667            self.sys_matches.clear();
668            {
669                let suffix = &self.chars_buf[char_idx..];
670                for (entries, end_char_offset) in dict.common_prefix_search(suffix) {
671                    let end_char_idx = char_idx + end_char_offset;
672                    let end = self.char_info_buffer[end_char_idx].byte_offset;
673                    for chunk in entries.chunks_exact(WordEntry::SERIALIZED_LEN) {
674                        self.sys_matches
675                            .push((end, WordEntry::deserialize(chunk, true)));
676                    }
677                }
678            }
679            for i in (0..self.sys_matches.len()).rev() {
680                let (end, word_entry) = self.sys_matches[i];
681                let end = end as usize;
682                let prefix_len = end - start;
683                let kanji_only = self.is_kanji_all(char_idx, prefix_len);
684                let edge = Self::create_edge(word_entry, start, end, kanji_only);
685                self.add_edge_in_lattice(edge, cost_matrix, search_mode);
686                found = true;
687            }
688
689            // In the case of normal mode, it doesn't process unknown word greedily.
690            if (search_mode.is_search()
691                || unknown_word_end.map(|index| index <= start).unwrap_or(true))
692                && char_idx < self.char_info_buffer.len() - 1
693            {
694                let num_categories = self.char_info_buffer[char_idx].categories_len as usize;
695                for category_ord in 0..num_categories {
696                    let category = self.get_cached_category(char_idx, category_ord);
697                    unknown_word_end = self.process_unknown_word(
698                        char_definitions,
699                        unknown_dictionary,
700                        cost_matrix,
701                        search_mode,
702                        category,
703                        category_ord,
704                        unknown_word_end,
705                        start,
706                        char_idx,
707                        found,
708                    );
709                }
710            }
711        }
712
713        // Connect EOS
714        if !self.ends_at[len].is_empty() {
715            let mut eos_edge = Edge {
716                start_index: len as u32,
717                stop_index: len as u32,
718                ..Default::default()
719            };
720            // Calculate cost for EOS with the row hoisted (#880).
721            let left_edges = &self.ends_at[len];
722            let mut best_cost = i32::MAX;
723            let mut best_left = None;
724            let cost_row = cost_matrix.row(0); // EOS default left_id
725
726            for (i, left_edge) in left_edges.iter().enumerate() {
727                let path_cost =
728                    left_edge.path_cost + cost_row[left_edge.word_entry.right_id() as usize] as i32;
729                if path_cost < best_cost {
730                    best_cost = path_cost;
731                    best_left = Some(i as u16);
732                }
733            }
734            if let Some(left_idx) = best_left {
735                eos_edge.left_index = left_idx;
736                eos_edge.path_cost = best_cost;
737                self.ends_at[len].push(eos_edge);
738            }
739        }
740    }
741
742    #[allow(clippy::too_many_arguments)]
743    fn process_unknown_word(
744        &mut self,
745        char_definitions: &CharacterDefinition,
746        unknown_dictionary: &UnknownDictionary,
747        cost_matrix: &ConnectionCostMatrix,
748        search_mode: &Mode,
749        category: CategoryId,
750        category_ord: usize,
751        unknown_word_index: Option<usize>,
752        start: usize,
753        char_idx: usize,
754        found: bool,
755    ) -> Option<usize> {
756        let mut unknown_word_num_chars: usize = 0;
757        let category_data = char_definitions.lookup_definition(category);
758        if category_data.invoke || !found {
759            unknown_word_num_chars = 1;
760            if category_data.group {
761                for i in 1.. {
762                    let next_idx = char_idx + i;
763                    if next_idx >= self.char_info_buffer.len() - 1 {
764                        break;
765                    }
766                    let num_categories = self.char_info_buffer[next_idx].categories_len as usize;
767                    let mut found_cat = false;
768                    if category_ord < num_categories {
769                        let cat = self.get_cached_category(next_idx, category_ord);
770                        if cat == category {
771                            unknown_word_num_chars += 1;
772                            found_cat = true;
773                        }
774                    }
775                    if !found_cat {
776                        break;
777                    }
778                }
779            }
780        }
781        if unknown_word_num_chars > 0 {
782            let byte_end_offset =
783                self.char_info_buffer[char_idx + unknown_word_num_chars].byte_offset;
784            let byte_len = byte_end_offset as usize - start;
785
786            // Check Kanji status using pre-calculated buffer
787            let kanji_only = self.is_kanji_all(char_idx, byte_len);
788
789            for &word_id in unknown_dictionary.lookup_word_ids(category) {
790                let word_entry = unknown_dictionary.word_entry(word_id);
791                let edge = Self::create_edge(word_entry, start, start + byte_len, kanji_only);
792                self.add_edge_in_lattice(edge, cost_matrix, search_mode);
793            }
794            return Some(start + byte_len);
795        }
796        unknown_word_index
797    }
798
799    // Adds an edge to the lattice and calculates the minimum cost to reach it.
800    fn add_edge_in_lattice(
801        &mut self,
802        mut edge: Edge,
803        cost_matrix: &ConnectionCostMatrix,
804        mode: &Mode,
805    ) {
806        let start_index = edge.start_index as usize;
807        let stop_index = edge.stop_index as usize;
808        let right_left_id = edge.word_entry.left_id();
809
810        if self.ends_at[start_index].is_empty() {
811            return;
812        }
813
814        let mut best_cost = i32::MAX;
815        let mut best_left = None;
816
817        match mode {
818            Mode::Normal => {
819                // Matrix row hoisted out of the loop; the plain additions
820                // cannot overflow thanks to PATH_COST_CLAMP (#880).
821                let left_edges = &self.ends_at[start_index];
822                let cost_row = cost_matrix.row(right_left_id);
823                for (i, left_edge) in left_edges.iter().enumerate() {
824                    let conn_cost = cost_row[left_edge.word_entry.right_id() as usize] as i32;
825                    let total_cost = left_edge.path_cost + conn_cost;
826
827                    if total_cost < best_cost {
828                        best_cost = total_cost;
829                        best_left = Some(i as u16);
830                    }
831                }
832            }
833            Mode::Decompose(penalty) => {
834                let left_edges = &self.ends_at[start_index];
835                for (i, left_edge) in left_edges.iter().enumerate() {
836                    let left_right_id = left_edge.word_entry.right_id();
837                    let conn_cost = cost_matrix.cost(left_right_id, right_left_id);
838                    let penalty_cost = penalty.penalty(left_edge);
839                    let total_cost = left_edge
840                        .path_cost
841                        .saturating_add(conn_cost)
842                        .saturating_add(penalty_cost);
843
844                    if total_cost < best_cost {
845                        best_cost = total_cost;
846                        best_left = Some(i as u16);
847                    }
848                }
849            }
850        }
851
852        if let Some(best_left_idx) = best_left {
853            edge.path_cost = best_cost
854                .saturating_add(edge.word_entry.word_cost as i32)
855                .min(PATH_COST_CLAMP);
856            edge.left_index = best_left_idx;
857            self.ends_at[stop_index].push(edge);
858        }
859    }
860
861    /// Backtraces the best path and returns `(start_byte_offset, word_id)`
862    /// pairs for each token in reading order (BOS/EOS excluded).
863    ///
864    /// # Returns
865    ///
866    /// A freshly allocated offsets vector; empty when the lattice holds no
867    /// complete path. Prefer [`Lattice::tokens_offset_into`] in per-sentence
868    /// loops to reuse one allocation across sentences.
869    pub fn tokens_offset(&self) -> Vec<(usize, WordId)> {
870        let mut offsets = Vec::new();
871        self.tokens_offset_into(&mut offsets);
872        offsets
873    }
874
875    /// Backtraces the best path into a caller-provided buffer, clearing it
876    /// first, so the allocation can be reused across sentences.
877    ///
878    /// # Arguments
879    ///
880    /// * `offsets` - The buffer to fill with `(start_byte_offset, word_id)`
881    ///   pairs in reading order (BOS/EOS excluded). Cleared on entry; left
882    ///   empty when the lattice holds no complete path.
883    pub fn tokens_offset_into(&self, offsets: &mut Vec<(usize, WordId)>) {
884        offsets.clear();
885
886        if self.ends_at.is_empty() {
887            return;
888        }
889
890        // The EOS edge, when present, sits at `ends_at[last_text_len]`
891        // (see set_text), and every slot past it is always empty, so the
892        // backward scan starts there rather than at the historical
893        // capacity end (#877).
894        let mut last_idx = self.last_text_len.min(self.ends_at.len() - 1);
895        while last_idx > 0 && self.ends_at[last_idx].is_empty() {
896            last_idx -= 1;
897        }
898
899        if self.ends_at[last_idx].is_empty() {
900            return;
901        }
902
903        let idx = self.ends_at[last_idx].len() - 1;
904        let mut edge = &self.ends_at[last_idx][idx];
905
906        if edge.left_index == u16::MAX {
907            return;
908        }
909
910        loop {
911            if edge.left_index == u16::MAX {
912                break;
913            }
914
915            offsets.push((edge.start_index as usize, edge.word_entry.word_id));
916
917            let left_idx = edge.left_index as usize;
918            let start_idx = edge.start_index as usize;
919
920            edge = &self.ends_at[start_idx][left_idx];
921        }
922
923        offsets.reverse();
924        offsets.pop(); // Remove EOS
925    }
926
927    // --- N-Best support ---
928
929    /// Returns the text length (in bytes) from the last set_text/set_text_nbest call.
930    pub fn text_len(&self) -> usize {
931        self.last_text_len
932    }
933
934    /// Returns the edges at a given byte position.
935    pub fn edges_at(&self, byte_pos: usize) -> &[Edge] {
936        &self.ends_at[byte_pos]
937    }
938
939    /// Returns the N-Best path entries at a given byte position.
940    pub fn paths_at(&self, byte_pos: usize) -> &[PathEntry] {
941        if byte_pos < self.all_paths.len() {
942            &self.all_paths[byte_pos]
943        } else {
944            &[]
945        }
946    }
947
948    /// Adds an edge to the lattice, recording ALL predecessor transitions for N-Best.
949    fn add_edge_in_lattice_nbest(
950        &mut self,
951        mut edge: Edge,
952        cost_matrix: &ConnectionCostMatrix,
953        mode: &Mode,
954    ) {
955        let start_index = edge.start_index as usize;
956        let stop_index = edge.stop_index as usize;
957        let right_left_id = edge.word_entry.left_id();
958
959        if self.ends_at[start_index].is_empty() {
960            return;
961        }
962
963        let mut best_cost = i32::MAX;
964        let mut best_left = None;
965
966        // The edge_index of the new edge being added
967        let new_edge_index = self.ends_at[stop_index].len() as u16;
968
969        match mode {
970            Mode::Normal => {
971                // Same hoisted-row scan as add_edge_in_lattice (#880).
972                let cost_row = cost_matrix.row(right_left_id);
973                for i in 0..self.ends_at[start_index].len() {
974                    let left_edge = &self.ends_at[start_index][i];
975                    let total_cost = left_edge.path_cost
976                        + cost_row[left_edge.word_entry.right_id() as usize] as i32;
977
978                    // Record ALL transitions for N-Best
979                    self.all_paths[stop_index].push(PathEntry {
980                        edge_index: new_edge_index,
981                        left_pos: start_index as u32,
982                        left_index: i as u16,
983                        cost: total_cost,
984                    });
985
986                    if total_cost < best_cost {
987                        best_cost = total_cost;
988                        best_left = Some(i as u16);
989                    }
990                }
991            }
992            Mode::Decompose(penalty) => {
993                for i in 0..self.ends_at[start_index].len() {
994                    let left_edge = &self.ends_at[start_index][i];
995                    let left_right_id = left_edge.word_entry.right_id();
996                    let conn_cost = cost_matrix.cost(left_right_id, right_left_id);
997                    let penalty_cost = penalty.penalty(left_edge);
998                    let total_cost = left_edge
999                        .path_cost
1000                        .saturating_add(conn_cost)
1001                        .saturating_add(penalty_cost);
1002
1003                    // Record ALL transitions for N-Best
1004                    self.all_paths[stop_index].push(PathEntry {
1005                        edge_index: new_edge_index,
1006                        left_pos: start_index as u32,
1007                        left_index: i as u16,
1008                        cost: total_cost,
1009                    });
1010
1011                    if total_cost < best_cost {
1012                        best_cost = total_cost;
1013                        best_left = Some(i as u16);
1014                    }
1015                }
1016            }
1017        }
1018
1019        if let Some(best_left_idx) = best_left {
1020            edge.path_cost = best_cost
1021                .saturating_add(edge.word_entry.word_cost as i32)
1022                .min(PATH_COST_CLAMP);
1023            edge.left_index = best_left_idx;
1024            self.ends_at[stop_index].push(edge);
1025        }
1026    }
1027
1028    #[allow(clippy::too_many_arguments)]
1029    fn process_unknown_word_nbest(
1030        &mut self,
1031        char_definitions: &CharacterDefinition,
1032        unknown_dictionary: &UnknownDictionary,
1033        cost_matrix: &ConnectionCostMatrix,
1034        search_mode: &Mode,
1035        category: CategoryId,
1036        category_ord: usize,
1037        unknown_word_index: Option<usize>,
1038        start: usize,
1039        char_idx: usize,
1040        found: bool,
1041    ) -> Option<usize> {
1042        let mut unknown_word_num_chars: usize = 0;
1043        let category_data = char_definitions.lookup_definition(category);
1044        if category_data.invoke || !found {
1045            unknown_word_num_chars = 1;
1046            if category_data.group {
1047                for i in 1.. {
1048                    let next_idx = char_idx + i;
1049                    if next_idx >= self.char_info_buffer.len() - 1 {
1050                        break;
1051                    }
1052                    let num_categories = self.char_info_buffer[next_idx].categories_len as usize;
1053                    let mut found_cat = false;
1054                    if category_ord < num_categories {
1055                        let cat = self.get_cached_category(next_idx, category_ord);
1056                        if cat == category {
1057                            unknown_word_num_chars += 1;
1058                            found_cat = true;
1059                        }
1060                    }
1061                    if !found_cat {
1062                        break;
1063                    }
1064                }
1065            }
1066        }
1067        if unknown_word_num_chars > 0 {
1068            let byte_end_offset =
1069                self.char_info_buffer[char_idx + unknown_word_num_chars].byte_offset;
1070            let byte_len = byte_end_offset as usize - start;
1071
1072            let kanji_only = self.is_kanji_all(char_idx, byte_len);
1073
1074            for &word_id in unknown_dictionary.lookup_word_ids(category) {
1075                let word_entry = unknown_dictionary.word_entry(word_id);
1076                let edge = Self::create_edge(word_entry, start, start + byte_len, kanji_only);
1077                self.add_edge_in_lattice_nbest(edge, cost_matrix, search_mode);
1078            }
1079            return Some(start + byte_len);
1080        }
1081        unknown_word_index
1082    }
1083
1084    /// Forward Viterbi implementation for N-Best mode.
1085    /// Same as set_text() but records ALL predecessor transitions in all_paths.
1086    #[inline(never)]
1087    #[allow(clippy::too_many_arguments)]
1088    pub fn set_text_nbest(
1089        &mut self,
1090        dict: &PrefixDictionary,
1091        user_dict: &Option<&UserPrefixDictionary>,
1092        char_definitions: &CharacterDefinition,
1093        unknown_dictionary: &UnknownDictionary,
1094        cost_matrix: &ConnectionCostMatrix,
1095        text: &str,
1096        search_mode: &Mode,
1097    ) {
1098        let len = text.len();
1099        self.set_capacity_nbest(len);
1100
1101        // Pre-calculate character information for the text
1102        self.char_info_buffer.clear();
1103        self.categories_buffer.clear();
1104        self.chars_buf.clear();
1105
1106        for (byte_offset, c) in text.char_indices() {
1107            let categories_start = self.categories_buffer.len() as u32;
1108
1109            // Category lookup is O(1) for BMP codepoints via the flat table
1110            // built at dictionary load (#878), so no per-lattice cache is
1111            // needed.
1112            let categories = char_definitions.lookup_categories(c);
1113            for &category in categories {
1114                self.categories_buffer.push(category);
1115            }
1116
1117            let categories_len = (self.categories_buffer.len() as u32 - categories_start) as u16;
1118
1119            self.char_info_buffer.push(CharData {
1120                byte_offset: byte_offset as u32,
1121                is_kanji: is_kanji(c),
1122                categories_start,
1123                categories_len,
1124                kanji_run_byte_len: 0,
1125            });
1126            self.chars_buf.push(c);
1127        }
1128        // Sentinel for end of text
1129        self.char_info_buffer.push(CharData {
1130            byte_offset: len as u32,
1131            is_kanji: false,
1132            categories_start: 0,
1133            categories_len: 0,
1134            kanji_run_byte_len: 0,
1135        });
1136
1137        // Pre-calculate Kanji run lengths (backwards)
1138        for i in (0..self.char_info_buffer.len() - 1).rev() {
1139            if self.char_info_buffer[i].is_kanji {
1140                let next_byte_offset = self.char_info_buffer[i + 1].byte_offset;
1141                let char_byte_len = next_byte_offset - self.char_info_buffer[i].byte_offset;
1142                self.char_info_buffer[i].kanji_run_byte_len =
1143                    char_byte_len + self.char_info_buffer[i + 1].kanji_run_byte_len;
1144            } else {
1145                self.char_info_buffer[i].kanji_run_byte_len = 0;
1146            }
1147        }
1148
1149        let start_edge = Edge {
1150            path_cost: 0,
1151            left_index: u16::MAX,
1152            ..Default::default()
1153        };
1154        self.ends_at[0].push(start_edge);
1155
1156        let mut unknown_word_end: Option<usize> = None;
1157
1158        // Pre-scan text with Aho-Corasick
1159        // Buffers are Lattice fields reused across calls; refill matches_head (its
1160        // contents are meaningful, unlike ends_at's empty-Vec slots) and clear
1161        // matches_store (a plain append-only pool).
1162        // The pool now holds only user-dictionary matches; see set_text.
1163        self.matches_head.clear();
1164        self.matches_store.clear();
1165
1166        // User dictionary scan
1167        if let Some(ud) = user_dict {
1168            self.matches_head.resize(len + 1, u32::MAX);
1169            let ud_vals: &[u8] = &ud.vals_data;
1170            for m in ud.da.find_overlapping_iter(text) {
1171                let start = m.start();
1172                let (offset, count) = ud.decode_val(m.value());
1173                let offset_bytes = (offset as usize) * WordEntry::SERIALIZED_LEN;
1174
1175                if start < self.matches_head.len() {
1176                    let avail = ud_vals.len().saturating_sub(offset_bytes);
1177                    let n = (count as usize).min(avail / WordEntry::SERIALIZED_LEN);
1178                    let block =
1179                        &ud_vals[offset_bytes..offset_bytes + n * WordEntry::SERIALIZED_LEN];
1180                    let end = m.end() as u32;
1181                    for chunk in block.chunks_exact(WordEntry::SERIALIZED_LEN) {
1182                        let entry = WordEntry::deserialize(chunk, false);
1183                        let next = self.matches_head[start];
1184                        self.matches_head[start] = self.matches_store.len() as u32;
1185                        self.matches_store.push((end, entry, next));
1186                    }
1187                }
1188            }
1189        }
1190
1191        for char_idx in 0..self.char_info_buffer.len() - 1 {
1192            let start = self.char_info_buffer[char_idx].byte_offset as usize;
1193
1194            if self.ends_at[start].is_empty() {
1195                continue;
1196            }
1197
1198            let mut found: bool = false;
1199
1200            // Drain user-dictionary matches first; see set_text for why the
1201            // order matters.
1202            if start < self.matches_head.len() {
1203                let mut match_idx = self.matches_head[start];
1204                while match_idx != u32::MAX {
1205                    let (end, word_entry, next) = self.matches_store[match_idx as usize];
1206
1207                    let prefix_len = end as usize - start;
1208                    let kanji_only = self.is_kanji_all(char_idx, prefix_len);
1209                    let edge = Self::create_edge(word_entry, start, end as usize, kanji_only);
1210                    self.add_edge_in_lattice_nbest(edge, cost_matrix, search_mode);
1211                    found = true;
1212
1213                    match_idx = next;
1214                }
1215            }
1216
1217            // System dictionary: per-position common-prefix search over the
1218            // in-place trie, run only at lattice-reachable positions (the
1219            // gate above). Matches are buffered and replayed in reverse so
1220            // equal-cost tie-breaks keep choosing the same winner as the
1221            // retired whole-text pre-scan; user matches were drained first
1222            // for the same reason (the old shared list held them on top).
1223            self.sys_matches.clear();
1224            {
1225                let suffix = &self.chars_buf[char_idx..];
1226                for (entries, end_char_offset) in dict.common_prefix_search(suffix) {
1227                    let end_char_idx = char_idx + end_char_offset;
1228                    let end = self.char_info_buffer[end_char_idx].byte_offset;
1229                    for chunk in entries.chunks_exact(WordEntry::SERIALIZED_LEN) {
1230                        self.sys_matches
1231                            .push((end, WordEntry::deserialize(chunk, true)));
1232                    }
1233                }
1234            }
1235            for i in (0..self.sys_matches.len()).rev() {
1236                let (end, word_entry) = self.sys_matches[i];
1237                let end = end as usize;
1238                let prefix_len = end - start;
1239                let kanji_only = self.is_kanji_all(char_idx, prefix_len);
1240                let edge = Self::create_edge(word_entry, start, end, kanji_only);
1241                self.add_edge_in_lattice_nbest(edge, cost_matrix, search_mode);
1242                found = true;
1243            }
1244
1245            if (search_mode.is_search()
1246                || unknown_word_end.map(|index| index <= start).unwrap_or(true))
1247                && char_idx < self.char_info_buffer.len() - 1
1248            {
1249                let num_categories = self.char_info_buffer[char_idx].categories_len as usize;
1250                for category_ord in 0..num_categories {
1251                    let category = self.get_cached_category(char_idx, category_ord);
1252                    unknown_word_end = self.process_unknown_word_nbest(
1253                        char_definitions,
1254                        unknown_dictionary,
1255                        cost_matrix,
1256                        search_mode,
1257                        category,
1258                        category_ord,
1259                        unknown_word_end,
1260                        start,
1261                        char_idx,
1262                        found,
1263                    );
1264                }
1265            }
1266        }
1267
1268        // Connect EOS with all-path recording
1269        if !self.ends_at[len].is_empty() {
1270            let eos_edge_index = self.ends_at[len].len() as u16;
1271            let mut eos_edge = Edge {
1272                start_index: len as u32,
1273                stop_index: len as u32,
1274                ..Default::default()
1275            };
1276            let mut best_cost = i32::MAX;
1277            let mut best_left = None;
1278            let cost_row = cost_matrix.row(0); // EOS default left_id
1279
1280            for i in 0..self.ends_at[len].len() {
1281                let left_edge = &self.ends_at[len][i];
1282                let path_cost =
1283                    left_edge.path_cost + cost_row[left_edge.word_entry.right_id() as usize] as i32;
1284
1285                // Record all transitions to EOS
1286                self.all_paths[len].push(PathEntry {
1287                    edge_index: eos_edge_index,
1288                    left_pos: len as u32,
1289                    left_index: i as u16,
1290                    cost: path_cost,
1291                });
1292
1293                if path_cost < best_cost {
1294                    best_cost = path_cost;
1295                    best_left = Some(i as u16);
1296                }
1297            }
1298            if let Some(left_idx) = best_left {
1299                eos_edge.left_index = left_idx;
1300                eos_edge.path_cost = best_cost;
1301                self.ends_at[len].push(eos_edge);
1302            }
1303        }
1304    }
1305
1306    /// Returns the top-N paths through the lattice.
1307    /// Each result is a (path, cost) pair where path is a Vec of (byte_start, WordId) pairs.
1308    /// The first result (index 0) is the 1-best path.
1309    /// If `unique` is true, paths with the same segmentation (same byte_start sequence)
1310    /// are deduplicated, keeping only the first (lowest cost) variant.
1311    /// If `cost_threshold` is Some(t), paths whose cost exceeds best_cost + t are discarded.
1312    /// Requires set_text_nbest() to have been called first.
1313    pub fn nbest_tokens_offset(
1314        &self,
1315        n: usize,
1316        unique: bool,
1317        cost_threshold: Option<i64>,
1318    ) -> Vec<(Vec<(usize, WordId)>, i64)> {
1319        use std::collections::HashSet;
1320
1321        use crate::nbest::NBestGenerator;
1322        let mut generator = NBestGenerator::new(self);
1323        let mut results = Vec::with_capacity(n);
1324        let mut best_cost: Option<i64> = None;
1325
1326        if unique {
1327            let mut seen: HashSet<Vec<usize>> = HashSet::new();
1328            while results.len() < n {
1329                match generator.next() {
1330                    Some((path, cost)) => {
1331                        // Record best cost from first result
1332                        let bc = *best_cost.get_or_insert(cost);
1333                        // Skip if cost exceeds threshold
1334                        if let Some(threshold) = cost_threshold
1335                            && cost > bc + threshold
1336                        {
1337                            break;
1338                        }
1339                        let key: Vec<usize> = path.iter().map(|(start, _)| *start).collect();
1340                        if seen.insert(key) {
1341                            results.push((path, cost));
1342                        }
1343                    }
1344                    None => break,
1345                }
1346            }
1347        } else {
1348            while results.len() < n {
1349                match generator.next() {
1350                    Some((path, cost)) => {
1351                        let bc = *best_cost.get_or_insert(cost);
1352                        if let Some(threshold) = cost_threshold
1353                            && cost > bc + threshold
1354                        {
1355                            break;
1356                        }
1357                        results.push((path, cost));
1358                    }
1359                    None => break,
1360                }
1361            }
1362        }
1363        results
1364    }
1365}
1366
1367#[cfg(test)]
1368mod tests {
1369    use crate::viterbi::{Edge, Lattice, LexType, WordEntry, WordId};
1370
1371    /// Builds an edge whose backtrace fields are set explicitly, for
1372    /// hand-assembled lattices in tests.
1373    fn test_edge(word_id: u32, start: usize, stop: usize, left_index: u16) -> Edge {
1374        let mut edge = Lattice::create_edge(
1375            WordEntry::new(WordId::new(LexType::System, word_id), 0, 0, 0),
1376            start,
1377            stop,
1378            false,
1379        );
1380        edge.left_index = left_index;
1381        edge.path_cost = 0;
1382        edge
1383    }
1384
1385    #[test]
1386    fn test_word_entry() {
1387        let mut buffer = Vec::new();
1388        let word_entry =
1389            WordEntry::new(WordId::new(LexType::System, 1u32), -17i16, 1411u16, 1412u16);
1390        word_entry.serialize(&mut buffer).unwrap();
1391        assert_eq!(WordEntry::SERIALIZED_LEN, buffer.len());
1392        let word_entry2 = WordEntry::deserialize(&buffer[..], true);
1393        assert_eq!(word_entry, word_entry2);
1394    }
1395
1396    /// Regression test for #827: `Vec::resize` clones its template value
1397    /// into all but the last new slot, and cloning an empty Vec yields
1398    /// capacity 0, so only the last slot was actually pre-sized. Every
1399    /// newly-grown `ends_at` slot must get the intended pre-size, both on
1400    /// the initial growth and on a later, larger growth.
1401    #[test]
1402    fn test_set_capacity_presizes_all_new_slots() {
1403        let mut lattice = Lattice::default();
1404
1405        lattice.set_capacity(5);
1406        assert_eq!(lattice.ends_at.len(), 6);
1407        for (i, slot) in lattice.ends_at.iter().enumerate() {
1408            assert!(
1409                slot.capacity() >= 16,
1410                "slot {} has capacity {} < 16 after initial growth",
1411                i,
1412                slot.capacity()
1413            );
1414        }
1415
1416        // Growing an already-used lattice must pre-size the appended slots too.
1417        lattice.set_capacity(10);
1418        assert_eq!(lattice.ends_at.len(), 11);
1419        for (i, slot) in lattice.ends_at.iter().enumerate() {
1420            assert!(
1421                slot.capacity() >= 16,
1422                "slot {} has capacity {} < 16 after second growth",
1423                i,
1424                slot.capacity()
1425            );
1426        }
1427    }
1428
1429    /// Regression test for #877: `clear()` walks only `..=last_text_len`
1430    /// instead of the historical max capacity, so it must still clear every
1431    /// slot the previous sentence could have written — including the
1432    /// boundary slot at exactly `last_text_len` (EOS position).
1433    #[test]
1434    fn test_clear_after_shrink_leaves_no_stale_edges() {
1435        let mut lattice = Lattice::default();
1436
1437        // Long sentence: capacity grows to 101 slots, writes up to index 100.
1438        lattice.set_capacity(100);
1439        lattice.ends_at[0].push(test_edge(1, 0, 0, u16::MAX));
1440        lattice.ends_at[57].push(test_edge(2, 0, 57, 0));
1441        lattice.ends_at[100].push(test_edge(3, 57, 100, 0)); // boundary slot
1442
1443        // Shorter sentence: clear() runs bounded by the previous
1444        // last_text_len (100), then records the new length.
1445        lattice.set_capacity(10);
1446        assert!(
1447            lattice.ends_at.iter().all(|v| v.is_empty()),
1448            "stale edges survived a bounded clear"
1449        );
1450
1451        // A second shrink exercises the induction step: nothing past the
1452        // new bound (10) may hold entries, and slots within it are cleared.
1453        lattice.ends_at[10].push(test_edge(4, 0, 10, 0)); // boundary slot again
1454        lattice.set_capacity(3);
1455        assert!(
1456            lattice.ends_at.iter().all(|v| v.is_empty()),
1457            "stale edge at the previous boundary slot survived"
1458        );
1459    }
1460
1461    /// Regression test for #877: the `tokens_offset` backward scan starts at
1462    /// `last_text_len`, which must still find the EOS edge at exactly that
1463    /// index after the capacity has grown far beyond the current sentence.
1464    #[test]
1465    fn test_tokens_offset_finds_eos_at_last_text_len_after_shrink() {
1466        let mut lattice = Lattice::default();
1467
1468        // Grow capacity well past the sentence we are about to assemble.
1469        lattice.set_capacity(100);
1470
1471        // Hand-assembled best path for a 3-byte sentence:
1472        // BOS(ends_at[0]) <- token A (0..3) <- EOS(ends_at[3]).
1473        lattice.set_capacity(3);
1474        lattice.ends_at[0].push(test_edge(0, 0, 0, u16::MAX)); // BOS
1475        lattice.ends_at[3].push(test_edge(42, 0, 3, 0)); // token A
1476        lattice.ends_at[3].push(test_edge(0, 3, 3, 0)); // EOS -> token A
1477
1478        let offsets = lattice.tokens_offset();
1479        assert_eq!(offsets.len(), 1);
1480        assert_eq!(offsets[0].0, 0);
1481        assert_eq!(offsets[0].1, WordId::new(LexType::System, 42));
1482    }
1483
1484    /// `shrink_to` must release slots beyond the target while preserving
1485    /// the #841 per-slot pre-size on the remaining slots, and the lattice
1486    /// must regrow correctly (pre-sized) afterwards.
1487    #[test]
1488    fn test_shrink_to_truncates_and_keeps_presize() {
1489        let mut lattice = Lattice::default();
1490
1491        lattice.set_capacity(100);
1492        lattice.ends_at[0].push(test_edge(1, 0, 0, u16::MAX));
1493        lattice.ends_at[100].push(test_edge(2, 0, 100, 0));
1494
1495        lattice.shrink_to(10);
1496        assert_eq!(lattice.capacity(), 10);
1497        assert_eq!(lattice.ends_at.len(), 11);
1498        assert!(
1499            lattice.ends_at.iter().all(|v| v.is_empty()),
1500            "shrink_to must clear all slots"
1501        );
1502        for (i, slot) in lattice.ends_at.iter().enumerate() {
1503            assert!(
1504                slot.capacity() >= 16,
1505                "slot {} lost its pre-size after shrink_to (capacity {})",
1506                i,
1507                slot.capacity()
1508            );
1509        }
1510
1511        // Regrowth after a shrink must pre-size the appended slots again.
1512        lattice.set_capacity(50);
1513        assert_eq!(lattice.ends_at.len(), 51);
1514        for (i, slot) in lattice.ends_at.iter().enumerate() {
1515            assert!(
1516                slot.capacity() >= 16,
1517                "slot {} not pre-sized after regrowth (capacity {})",
1518                i,
1519                slot.capacity()
1520            );
1521        }
1522    }
1523
1524    /// `shrink_to` with a target at or above the current capacity must be a
1525    /// no-op for the slot vectors (no truncation, no capacity change).
1526    #[test]
1527    fn test_shrink_to_noop_when_target_not_smaller() {
1528        let mut lattice = Lattice::default();
1529        lattice.set_capacity(5);
1530
1531        lattice.shrink_to(100);
1532        assert_eq!(lattice.capacity(), 5);
1533        assert_eq!(lattice.ends_at.len(), 6);
1534
1535        lattice.shrink_to(5);
1536        assert_eq!(lattice.capacity(), 5);
1537        assert_eq!(lattice.ends_at.len(), 6);
1538
1539        // A fresh lattice tolerates shrink_to without panicking.
1540        let mut fresh = Lattice::default();
1541        fresh.shrink_to(0);
1542        assert_eq!(fresh.capacity(), 0);
1543        assert!(fresh.ends_at.is_empty());
1544    }
1545
1546    /// A lattice must produce a correct backtrace when used again after
1547    /// `shrink_to`: the `last_text_len` bound and the EOS scan start must
1548    /// stay consistent (same guarantee as the #877 regression tests, with a
1549    /// shrink in between).
1550    #[test]
1551    fn test_backtrace_works_after_shrink_to() {
1552        let mut lattice = Lattice::default();
1553        lattice.set_capacity(100);
1554        lattice.ends_at[0].push(test_edge(1, 0, 0, u16::MAX));
1555        lattice.ends_at[100].push(test_edge(2, 0, 100, 0));
1556
1557        lattice.shrink_to(10);
1558
1559        // Hand-assemble a 3-byte sentence path, as in the #877 tests.
1560        lattice.set_capacity(3);
1561        lattice.ends_at[0].push(test_edge(0, 0, 0, u16::MAX)); // BOS
1562        lattice.ends_at[3].push(test_edge(42, 0, 3, 0)); // token A
1563        lattice.ends_at[3].push(test_edge(0, 3, 3, 0)); // EOS -> token A
1564
1565        let offsets = lattice.tokens_offset();
1566        assert_eq!(offsets.len(), 1);
1567        assert_eq!(offsets[0].0, 0);
1568        assert_eq!(offsets[0].1, WordId::new(LexType::System, 42));
1569
1570        // clear() after the shrink must leave nothing behind.
1571        lattice.clear();
1572        assert!(lattice.ends_at.iter().all(|v| v.is_empty()));
1573    }
1574
1575    /// `shrink_to` must also release the N-Best `all_paths` slots.
1576    #[test]
1577    fn test_shrink_to_releases_nbest_paths() {
1578        let mut lattice = Lattice::default();
1579        lattice.set_capacity_nbest(100);
1580        assert_eq!(lattice.all_paths.len(), 101);
1581
1582        lattice.shrink_to(10);
1583        assert_eq!(lattice.all_paths.len(), 11);
1584        assert_eq!(lattice.nbest_capacity, 10);
1585        assert!(lattice.all_paths.iter().all(|v| v.is_empty()));
1586
1587        // Regrowth of the nbest side after a shrink.
1588        lattice.set_capacity_nbest(20);
1589        assert_eq!(lattice.all_paths.len(), 21);
1590    }
1591
1592    /// `tokens_offset_into` must clear the caller's buffer and produce the
1593    /// same result as `tokens_offset`, including on a pathless lattice.
1594    #[test]
1595    fn test_tokens_offset_into_matches_tokens_offset() {
1596        let mut lattice = Lattice::default();
1597        lattice.set_capacity(3);
1598        lattice.ends_at[0].push(test_edge(0, 0, 0, u16::MAX)); // BOS
1599        lattice.ends_at[3].push(test_edge(7, 0, 3, 0)); // token A
1600        lattice.ends_at[3].push(test_edge(0, 3, 3, 0)); // EOS -> token A
1601
1602        let mut reused = vec![(999usize, WordId::default())]; // stale content
1603        lattice.tokens_offset_into(&mut reused);
1604        assert_eq!(reused, lattice.tokens_offset());
1605        assert_eq!(reused.len(), 1);
1606
1607        // A cleared (pathless) lattice must leave the reused buffer empty.
1608        lattice.clear();
1609        lattice.tokens_offset_into(&mut reused);
1610        assert!(reused.is_empty());
1611        assert!(lattice.tokens_offset().is_empty());
1612    }
1613}