Skip to main content

rusty_alto/algebras/
string.rs

1use super::Algebra;
2use crate::{
3    BottomUpTa, CondensedTa, DetBottomUpTa, Explicit, FxHashMap, IndexedBottomUpTa, InvHom,
4    OutputCodec, ProbabilityScorer, Signature, SpaceJoinCodec, StateId, StateUniverse, Symbol,
5    SymbolSet, TextVisualizationCodec, TopDownTa, VisualRepresentation, WeightScorer,
6    heuristic::IntersectionHeuristic,
7    homomorphism::{HomLabel, Homomorphism},
8};
9use fixedbitset::FixedBitSet;
10use smallvec::SmallVec;
11use std::convert::Infallible;
12use std::fmt;
13
14/// Reserved concatenation operation name for [`StringAlgebra`].
15pub const CONCAT: &str = "*";
16
17/// A half-open input span `[start, end)`.
18#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
19pub struct Span {
20    /// Inclusive start position.
21    pub start: usize,
22    /// Exclusive end position.
23    pub end: usize,
24}
25
26impl fmt::Display for Span {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        write!(f, "[{}-{}]", self.start, self.end)
29    }
30}
31
32impl Span {
33    /// Construct a span.
34    pub fn new(start: usize, end: usize) -> Self {
35        Self { start, end }
36    }
37
38    /// Return the span length.
39    pub fn len(self) -> usize {
40        self.end - self.start
41    }
42
43    /// Return whether this span is empty.
44    pub fn is_empty(self) -> bool {
45        self.start == self.end
46    }
47}
48
49/// A finalized product item that can serve as a binary string sibling.
50#[derive(Clone, Copy, Debug, PartialEq, Eq)]
51pub(crate) struct SpanProductSibling {
52    pub(crate) product: StateId,
53    pub(crate) right_state: StateId,
54}
55
56/// Product-aware sibling finder for binary string span rules.
57///
58/// The index includes the required left state as well as the span boundary.
59/// Queries therefore return only finalized product states that can actually fill
60/// the sibling slot of the current left rule.
61#[derive(Debug, Default)]
62pub(crate) struct SpanProductSiblingFinder {
63    left_slots_by_end: FxHashMap<(usize, StateId), usize>,
64    right_slots_by_start: FxHashMap<(usize, StateId), usize>,
65    left_lists: Vec<Vec<SpanProductSibling>>,
66    right_lists: Vec<Vec<SpanProductSibling>>,
67    seen_left: FixedBitSet,
68    seen_right: FixedBitSet,
69}
70
71impl SpanProductSiblingFinder {
72    /// Activate `product` as available at child `position`.
73    ///
74    /// Returns `true` only the first time the `(position, product)` pair is seen.
75    pub(crate) fn activate(
76        &mut self,
77        product: StateId,
78        left_state: StateId,
79        right_state: StateId,
80        span: Span,
81        position: usize,
82    ) -> bool {
83        match position {
84            0 => {
85                if self.seen_left.len() <= product.index() {
86                    self.seen_left.grow(product.index() + 1);
87                }
88                if self.seen_left.contains(product.index()) {
89                    return false;
90                }
91                self.seen_left.set(product.index(), true);
92                let slot = *self
93                    .left_slots_by_end
94                    .entry((span.end, left_state))
95                    .or_insert_with(|| {
96                        let slot = self.left_lists.len();
97                        self.left_lists.push(Vec::new());
98                        slot
99                    });
100                self.left_lists[slot].push(SpanProductSibling {
101                    product,
102                    right_state,
103                });
104                true
105            }
106            1 => {
107                if self.seen_right.len() <= product.index() {
108                    self.seen_right.grow(product.index() + 1);
109                }
110                if self.seen_right.contains(product.index()) {
111                    return false;
112                }
113                self.seen_right.set(product.index(), true);
114                let slot = *self
115                    .right_slots_by_start
116                    .entry((span.start, left_state))
117                    .or_insert_with(|| {
118                        let slot = self.right_lists.len();
119                        self.right_lists.push(Vec::new());
120                        slot
121                    });
122                self.right_lists[slot].push(SpanProductSibling {
123                    product,
124                    right_state,
125                });
126                true
127            }
128            _ => false,
129        }
130    }
131
132    #[cfg(test)]
133    pub(crate) fn sibling_products_into(
134        &self,
135        span: Span,
136        position: usize,
137        required_left: StateId,
138        out: &mut Vec<SpanProductSibling>,
139    ) {
140        out.clear();
141        out.extend_from_slice(self.siblings_slice(span, position, required_left));
142    }
143
144    /// Borrow the active sibling products for `span` at `position`.
145    ///
146    /// The underlying per-`[boundary][left]` vectors are append-only, so a slice
147    /// length captured now stays valid for the same prefix later: the lazy A*
148    /// frontier records `snapshot_len` at generator creation and re-borrows the
149    /// stable prefix `[..snapshot_len]` on each rescan.
150    pub(crate) fn siblings_slice(
151        &self,
152        span: Span,
153        position: usize,
154        required_left: StateId,
155    ) -> &[SpanProductSibling] {
156        let slot = match position {
157            0 => self
158                .right_slots_by_start
159                .get(&(span.end, required_left))
160                .copied()
161                .and_then(|slot| self.right_lists.get(slot)),
162            1 => self
163                .left_slots_by_end
164                .get(&(span.start, required_left))
165                .copied()
166                .and_then(|slot| self.left_lists.get(slot)),
167            _ => None,
168        };
169        slot.map_or(&[], Vec::as_slice)
170    }
171}
172
173/// Binary string algebra over token symbols.
174///
175/// Values are token-symbol vectors. The reserved concat operation appends two
176/// vectors; every other nullary symbol evaluates to the one-token string
177/// containing that symbol.
178#[derive(Clone, Debug)]
179pub struct StringAlgebra {
180    signature: Signature,
181    concat: Symbol,
182    display_codec: TextVisualizationCodec<SpaceJoinCodec>,
183}
184
185impl StringAlgebra {
186    /// Create a string algebra with the reserved concat symbol.
187    pub fn new() -> Self {
188        let mut signature = Signature::new();
189        let concat = signature.intern(CONCAT.to_owned(), 2).unwrap();
190        Self {
191            signature,
192            concat,
193            display_codec: TextVisualizationCodec::new(SpaceJoinCodec),
194        }
195    }
196
197    /// Create a string algebra from an existing operation signature.
198    pub fn with_signature(mut signature: Signature) -> Self {
199        let concat = signature.intern(CONCAT.to_owned(), 2).unwrap();
200        Self {
201            signature,
202            concat,
203            display_codec: TextVisualizationCodec::new(SpaceJoinCodec),
204        }
205    }
206
207    /// Return the concat operation symbol.
208    pub fn concat_symbol(&self) -> Symbol {
209        self.concat
210    }
211
212    /// Intern a token symbol.
213    pub fn intern_word(&mut self, word: impl Into<String>) -> Symbol {
214        self.signature.intern(word.into(), 0).unwrap()
215    }
216
217    /// Parse a whitespace-separated string into token symbols.
218    pub fn parse_string(&mut self, input: &str) -> Vec<Symbol> {
219        input
220            .split_whitespace()
221            .map(|word| self.intern_word(word.to_owned()))
222            .collect()
223    }
224
225    /// Build the optimized lazy decomposition automaton for `sentence`.
226    pub fn decompose(&self, sentence: Vec<Symbol>) -> StringDecompositionAutomaton {
227        StringDecompositionAutomaton::new(self.concat, sentence)
228    }
229}
230
231impl Default for StringAlgebra {
232    fn default() -> Self {
233        Self::new()
234    }
235}
236
237impl Algebra for StringAlgebra {
238    type InternalValue = Vec<Symbol>;
239    type Value = Vec<String>;
240    type ParseError = Infallible;
241
242    fn signature(&self) -> &Signature {
243        &self.signature
244    }
245
246    fn evaluate(
247        &self,
248        symbol: Symbol,
249        children: &[Self::InternalValue],
250    ) -> Option<Self::InternalValue> {
251        if symbol == self.concat {
252            let [left, right] = children else {
253                return None;
254            };
255            let mut out = Vec::with_capacity(left.len() + right.len());
256            out.extend_from_slice(left);
257            out.extend_from_slice(right);
258            Some(out)
259        } else if children.is_empty() {
260            Some(vec![symbol])
261        } else {
262            None
263        }
264    }
265
266    fn parse_object(&mut self, input: &str) -> Result<Self::InternalValue, Self::ParseError> {
267        Ok(self.parse_string(input))
268    }
269
270    fn to_external(&self, value: &Self::InternalValue) -> Self::Value {
271        value
272            .iter()
273            .map(|&symbol| self.signature.resolve(symbol).to_owned())
274            .collect()
275    }
276
277    fn visualize(&self, value: &Self::Value) -> VisualRepresentation {
278        self.display_codec.encode(value)
279    }
280}
281
282/// Lazy CKY-style string decomposition automaton.
283#[derive(Clone, Debug)]
284pub struct StringDecompositionAutomaton {
285    concat: Symbol,
286    sentence: Vec<Symbol>,
287    positions_by_word: FxHashMap<Symbol, Vec<usize>>,
288}
289
290impl StringDecompositionAutomaton {
291    /// Build a lazy decomposition automaton for `sentence`.
292    pub fn new(concat: Symbol, sentence: Vec<Symbol>) -> Self {
293        let mut positions_by_word = FxHashMap::default();
294        for (position, &word) in sentence.iter().enumerate() {
295            positions_by_word
296                .entry(word)
297                .or_insert_with(Vec::new)
298                .push(position);
299        }
300        Self {
301            concat,
302            sentence,
303            positions_by_word,
304        }
305    }
306
307    /// Return the concat operation symbol.
308    pub fn concat_symbol(&self) -> Symbol {
309        self.concat
310    }
311
312    /// Return the sentence length.
313    pub fn len(&self) -> usize {
314        self.sentence.len()
315    }
316
317    /// Return the sentence tokens (used by the obligatory-leaf F heuristic to
318    /// build per-input terminal supply).
319    pub fn sentence(&self) -> &[Symbol] {
320        &self.sentence
321    }
322
323    /// Return whether the sentence is empty.
324    pub fn is_empty(&self) -> bool {
325        self.sentence.is_empty()
326    }
327
328    /// Count explicit CKY-style decomposition rules without materializing them.
329    pub fn rule_count(&self) -> usize {
330        let n = self.len();
331        n + n.saturating_sub(1) * n * (n + 1) / 6
332    }
333
334    fn valid_span(&self, span: Span) -> bool {
335        span.start < span.end && span.end <= self.len()
336    }
337}
338
339impl BottomUpTa for StringDecompositionAutomaton {
340    type State = Span;
341
342    fn step(&self, f: Symbol, children: &[Span], out: &mut dyn FnMut(Span)) {
343        if f == self.concat {
344            let [left, right] = children else {
345                return;
346            };
347            if self.valid_span(*left) && self.valid_span(*right) && left.end == right.start {
348                out(Span::new(left.start, right.end));
349            }
350            return;
351        }
352
353        if !children.is_empty() {
354            return;
355        }
356        if let Some(positions) = self.positions_by_word.get(&f) {
357            for &i in positions {
358                out(Span::new(i, i + 1));
359            }
360        }
361    }
362
363    fn is_accepting(&self, q: &Span) -> bool {
364        *q == Span::new(0, self.len())
365    }
366}
367
368impl DetBottomUpTa for StringDecompositionAutomaton {
369    fn step_det(&self, f: Symbol, children: &[Span]) -> Option<Span> {
370        if f == self.concat {
371            let [left, right] = children else {
372                return None;
373            };
374            return (self.valid_span(*left) && self.valid_span(*right) && left.end == right.start)
375                .then_some(Span::new(left.start, right.end));
376        }
377
378        if !children.is_empty() {
379            return None;
380        }
381
382        self.positions_by_word
383            .get(&f)
384            .and_then(|positions| (positions.len() == 1).then_some(positions[0]))
385            .map(|i| Span::new(i, i + 1))
386    }
387}
388
389impl StateUniverse for StringDecompositionAutomaton {
390    fn all_states(&self, out: &mut dyn FnMut(Span)) {
391        for start in 0..self.len() {
392            for end in start + 1..=self.len() {
393                out(Span::new(start, end));
394            }
395        }
396    }
397}
398
399impl IndexedBottomUpTa for StringDecompositionAutomaton {
400    fn step_partial(
401        &self,
402        f: Symbol,
403        position: usize,
404        state_at_position: &Span,
405        out: &mut dyn FnMut(&[Span], Span),
406    ) {
407        if f != self.concat || !self.valid_span(*state_at_position) {
408            return;
409        }
410
411        match position {
412            0 => {
413                let left = *state_at_position;
414                for end in left.end + 1..=self.len() {
415                    let right = Span::new(left.end, end);
416                    let children = [left, right];
417                    out(&children, Span::new(left.start, end));
418                }
419            }
420            1 => {
421                let right = *state_at_position;
422                for start in 0..right.start {
423                    let left = Span::new(start, right.start);
424                    let children = [left, right];
425                    out(&children, Span::new(start, right.end));
426                }
427            }
428            _ => {}
429        }
430    }
431}
432
433impl TopDownTa for StringDecompositionAutomaton {
434    fn step_topdown(&self, parent: &Span, out: &mut dyn FnMut(Symbol, &[Span])) {
435        if !self.valid_span(*parent) {
436            return;
437        }
438        if parent.len() == 1 {
439            let word = self.sentence[parent.start];
440            out(word, &[]);
441            return;
442        }
443        for split in parent.start + 1..parent.end {
444            let children = [Span::new(parent.start, split), Span::new(split, parent.end)];
445            out(self.concat, &children);
446        }
447    }
448
449    fn initial_states(&self, out: &mut dyn FnMut(Span)) {
450        if !self.is_empty() {
451            out(Span::new(0, self.len()));
452        }
453    }
454}
455
456impl CondensedTa for StringDecompositionAutomaton {
457    fn condensed_rules(&self, out: &mut dyn FnMut(&[Span], &SymbolSet, Span)) {
458        let mut lexical_children = SmallVec::<[Span; 0]>::new();
459        for (i, &word) in self.sentence.iter().enumerate() {
460            let mut symbols = SymbolSet::new();
461            symbols.insert(word);
462            out(&lexical_children, &symbols, Span::new(i, i + 1));
463            lexical_children.clear();
464        }
465
466        let mut concat_symbols = SymbolSet::new();
467        concat_symbols.insert(self.concat);
468        for start in 0..self.len() {
469            for split in start + 1..self.len() {
470                for end in split + 1..=self.len() {
471                    let children = [Span::new(start, split), Span::new(split, end)];
472                    out(&children, &concat_symbols, Span::new(start, end));
473                }
474            }
475        }
476    }
477
478    fn condensed_nullary_rules(&self, out: &mut dyn FnMut(&SymbolSet, Span)) {
479        for (i, &word) in self.sentence.iter().enumerate() {
480            let mut symbols = SymbolSet::new();
481            symbols.insert(word);
482            out(&symbols, Span::new(i, i + 1));
483        }
484    }
485
486    fn condensed_rules_by_child(
487        &self,
488        position: usize,
489        state: &Span,
490        out: &mut dyn FnMut(&[Span], &SymbolSet, Span),
491    ) {
492        if !self.valid_span(*state) {
493            return;
494        }
495
496        let mut symbols = SymbolSet::new();
497        symbols.insert(self.concat);
498        match position {
499            0 => {
500                let left = *state;
501                for end in left.end + 1..=self.len() {
502                    let children = [left, Span::new(left.end, end)];
503                    out(&children, &symbols, Span::new(left.start, end));
504                }
505            }
506            1 => {
507                let right = *state;
508                for start in 0..right.start {
509                    let children = [Span::new(start, right.start), right];
510                    out(&children, &symbols, Span::new(start, right.end));
511                }
512            }
513            _ => {}
514        }
515    }
516}
517
518// ---------------------------------------------------------------------------
519// SX heuristic (Klein & Manning 2003)
520// ---------------------------------------------------------------------------
521
522/// A token in the yield template for a grammar rule symbol.
523#[derive(Clone, Copy, Debug, PartialEq, Eq)]
524enum YieldToken {
525    /// A word constant (leaf in the homomorphism term).
526    Word,
527    /// A grammar child reference (variable in the homomorphism term).
528    Child(usize),
529}
530
531/// Walk the frontier of a homomorphism term left-to-right, producing yield tokens.
532fn walk_frontier(
533    arena: &packed_term_arena::tree::TreeArena<HomLabel>,
534    node: packed_term_arena::tree::Tree,
535) -> Vec<YieldToken> {
536    match *arena.get_label(node) {
537        HomLabel::Var(i) => vec![YieldToken::Child(i)],
538        HomLabel::Symbol(_) => {
539            let children = arena.get_children(node);
540            if children.is_empty() {
541                vec![YieldToken::Word]
542            } else {
543                children
544                    .iter()
545                    .flat_map(|&c| walk_frontier(arena, c))
546                    .collect()
547            }
548        }
549    }
550}
551
552/// A yield template derived from a homomorphism RHS term.
553#[derive(Clone, Debug)]
554struct YieldTemplate {
555    /// The flat frontier tokens in left-to-right order.
556    tokens: Vec<YieldToken>,
557}
558
559impl YieldTemplate {
560    /// Count the Word tokens.
561    fn word_count(&self) -> usize {
562        self.tokens
563            .iter()
564            .filter(|&&t| t == YieldToken::Word)
565            .count()
566    }
567
568    /// Return the child indices in order of their first appearance.
569    fn children_in_order(&self) -> Vec<usize> {
570        let mut seen = Vec::new();
571        for &t in &self.tokens {
572            if let YieldToken::Child(i) = t
573                && !seen.contains(&i)
574            {
575                seen.push(i);
576            }
577        }
578        seen
579    }
580
581    /// Count Word tokens strictly to the left of child position `p` in the frontier.
582    fn words_left_of_child(&self, p: usize) -> usize {
583        let mut count = 0;
584        for &t in &self.tokens {
585            match t {
586                YieldToken::Word => count += 1,
587                YieldToken::Child(i) if i == p => break,
588                _ => {}
589            }
590        }
591        count
592    }
593
594    /// Count Word tokens strictly to the right of child position `p` in the frontier.
595    fn words_right_of_child(&self, p: usize) -> usize {
596        let mut count = 0;
597        let mut after = false;
598        for &t in &self.tokens {
599            match t {
600                YieldToken::Child(i) if i == p => after = true,
601                YieldToken::Word if after => count += 1,
602                _ => {}
603            }
604        }
605        count
606    }
607
608    /// Returns the child indices that appear to the left of child `p` in the frontier.
609    fn children_left_of(&self, p: usize) -> Vec<usize> {
610        let mut result = Vec::new();
611        for &t in &self.tokens {
612            match t {
613                YieldToken::Child(i) if i == p => break,
614                YieldToken::Child(i) if !result.contains(&i) => {
615                    result.push(i);
616                }
617                _ => {}
618            }
619        }
620        result
621    }
622
623    /// Returns the child indices that appear to the right of child `p` in the frontier.
624    fn children_right_of(&self, p: usize) -> Vec<usize> {
625        let mut result = Vec::new();
626        let mut after = false;
627        for &t in &self.tokens {
628            match t {
629                YieldToken::Child(i) if i == p => after = true,
630                YieldToken::Child(i) if after && !result.contains(&i) => {
631                    result.push(i);
632                }
633                _ => {}
634            }
635        }
636        result
637    }
638}
639
640/// String-specific outside heuristic (Klein & Manning 2003).
641///
642/// `SxHeuristic` precomputes best inside weights `BI(X, w)` (the best weight
643/// to derive exactly `w` words from grammar state `X`) and outside weights
644/// `SX(X, l, r)` (the best context weight with `l` words to the left and `r`
645/// words to the right). The `outside_estimate` then looks up `SX(X, l, r)`
646/// where `l = span.start` and `r = n - span.end`.
647pub(crate) struct SxHeuristic {
648    /// sx[state_idx][l*(n+1) + r] = best outside weight
649    sx: Vec<Box<[f64]>>,
650    /// Sentence length.
651    n: usize,
652    /// Stride for the second dimension (= n+1).
653    stride: usize,
654    /// Score for impossible/out-of-range entries.
655    zero: f64,
656}
657
658impl IntersectionHeuristic<StringDecompositionAutomaton> for SxHeuristic {
659    fn outside_estimate(&self, left: StateId, span: &Span) -> f64 {
660        let l = span.start;
661        let r = self.n.saturating_sub(span.end);
662        self.sx
663            .get(left.index())
664            .and_then(|row| row.get(l * self.stride + r))
665            .copied()
666            .unwrap_or(self.zero)
667    }
668}
669
670impl IntersectionHeuristic<InvHom<'_, StringDecompositionAutomaton>> for SxHeuristic {
671    fn outside_estimate(&self, left: StateId, span: &Span) -> f64 {
672        // InvHom<StringDecompositionAutomaton>::State = Span, so delegate directly.
673        <Self as IntersectionHeuristic<StringDecompositionAutomaton>>::outside_estimate(
674            self, left, span,
675        )
676    }
677}
678
679impl SxHeuristic {
680    fn lookup_lr(&self, left: StateId, l: usize, r: usize) -> f64 {
681        self.sx
682            .get(left.index())
683            .and_then(|row| row.get(l * self.stride + r))
684            .copied()
685            .unwrap_or(self.zero)
686    }
687
688    /// Build the SX heuristic for a grammar intersected with a string of length `n`.
689    ///
690    /// `grammar` is the left (grammar) automaton; `hom` is the string homomorphism
691    /// mapping grammar symbols to string-algebra terms; `concat` is the concat symbol
692    /// in the string algebra.
693    pub fn new(grammar: &Explicit, hom: &Homomorphism, concat: Symbol, n: usize) -> Self {
694        Self::new_with(grammar, hom, concat, n, &ProbabilityScorer)
695    }
696
697    pub fn new_with<S: WeightScorer>(
698        grammar: &Explicit,
699        hom: &Homomorphism,
700        concat: Symbol,
701        n: usize,
702        scorer: &S,
703    ) -> Self {
704        let num_states = grammar.num_states() as usize;
705        let arena = hom.arena();
706
707        // Build yield templates for every grammar rule.
708        // For each rule, look up the homomorphic image of the rule symbol and
709        // walk its frontier to get the yield template.
710        let rules: Vec<_> = grammar.rules().collect();
711
712        // For each rule, compute the yield template.
713        // If the rule symbol has no homomorphic image, treat it as unknown (skip it).
714        let templates: Vec<Option<YieldTemplate>> = rules
715            .iter()
716            .map(|rule| {
717                hom.get(rule.symbol).map(|term| {
718                    let tokens = walk_frontier(arena, term);
719                    // Filter out any concat wrapper — the frontier walk already handles this
720                    // by recursing into concat nodes. But we need to remove concat symbol tokens
721                    // that are inner nodes (they will appear as Symbol(concat) nodes with children
722                    // and will be recursed into, not emitted as Word). So frontier is already correct.
723                    let _ = concat; // concat used for reference; walk_frontier handles it
724                    YieldTemplate { tokens }
725                })
726            })
727            .collect();
728
729        // -----------------------------------------------------------------------
730        // Precompute minwidth(X): minimum number of words any derivation from X
731        // must yield. Initialized to a large sentinel.
732        // -----------------------------------------------------------------------
733        const INF: usize = usize::MAX / 2;
734        let mut minwidth = vec![INF; num_states];
735
736        // Fixpoint: iterate until no change.
737        let mut changed = true;
738        while changed {
739            changed = false;
740            for (rule_idx, rule) in rules.iter().enumerate() {
741                let Some(tmpl) = &templates[rule_idx] else {
742                    continue;
743                };
744                let t_words = tmpl.word_count();
745                let children = tmpl.children_in_order();
746                let arity = children.len();
747
748                // Compute the minimum width contribution from children.
749                let child_min_sum: usize = if arity == 0 {
750                    0
751                } else {
752                    let mut sum: usize = 0;
753                    let mut feasible = true;
754                    for &ci in &children {
755                        let child_state = rule.children.get(ci).copied();
756                        let child_state = match child_state {
757                            Some(s) if !s.is_stuck() => s,
758                            _ => {
759                                feasible = false;
760                                break;
761                            }
762                        };
763                        let mw = minwidth[child_state.index()];
764                        if mw == INF {
765                            feasible = false;
766                            break;
767                        }
768                        sum = sum.saturating_add(mw);
769                    }
770                    if !feasible {
771                        continue;
772                    }
773                    sum
774                };
775
776                let new_min = t_words.saturating_add(child_min_sum);
777                let ri = rule.result.index();
778                if new_min < minwidth[ri] {
779                    minwidth[ri] = new_min;
780                    changed = true;
781                }
782            }
783        }
784
785        // -----------------------------------------------------------------------
786        // BI(X, w) = best inside weight to derive exactly w words from state X.
787        // Shape: [num_states][n+1]
788        // -----------------------------------------------------------------------
789        let mut bi = vec![vec![scorer.zero(); n + 1]; num_states];
790
791        // Process rules in increasing target width (outer loop = w).
792        // For binarized grammars this is O(n^2) per rule.
793
794        // Iterate over rules grouped by arity:
795        // 0: lexical
796        // 1, t_words=0: unary width-preserving
797        // general: iterate over splits
798
799        // We do multiple passes to handle unary chains. The outer loop runs n+1 times
800        // and for each width, we saturate unary rules. For binary rules, we enumerate splits.
801
802        // First pass: lexical rules (arity 0, t_words > 0)
803        for (rule_idx, rule) in rules.iter().enumerate() {
804            let Some(tmpl) = &templates[rule_idx] else {
805                continue;
806            };
807            let t_words = tmpl.word_count();
808            let children = tmpl.children_in_order();
809            if !children.is_empty() {
810                continue;
811            } // not lexical
812            if t_words == 0 || t_words > n {
813                continue;
814            }
815            let ri = rule.result.index();
816            let candidate = scorer.rule_score(rule.weight);
817            if scorer.better(candidate, bi[ri][t_words]) {
818                bi[ri][t_words] = candidate;
819            }
820        }
821
822        // For each width w, process rules that can produce exactly w words.
823        // We need to handle unary (width-preserving) rules iteratively.
824        for w in 0..=n {
825            // Saturate unary width-preserving rules at this width.
826            let mut inner_changed = true;
827            while inner_changed {
828                inner_changed = false;
829                for (rule_idx, rule) in rules.iter().enumerate() {
830                    let Some(tmpl) = &templates[rule_idx] else {
831                        continue;
832                    };
833                    let t_words = tmpl.word_count();
834                    let children = tmpl.children_in_order();
835                    if children.len() != 1 || t_words != 0 {
836                        continue;
837                    }
838                    let child_idx = children[0];
839                    let child_state = match rule.children.get(child_idx) {
840                        Some(&s) if !s.is_stuck() => s,
841                        _ => continue,
842                    };
843                    let child_bi = bi[child_state.index()][w];
844                    if child_bi == scorer.zero() {
845                        continue;
846                    }
847                    let candidate = scorer.times(scorer.rule_score(rule.weight), child_bi);
848                    let ri = rule.result.index();
849                    if scorer.better(candidate, bi[ri][w]) {
850                        bi[ri][w] = candidate;
851                        inner_changed = true;
852                    }
853                }
854            }
855
856            // General case: rules with arity >= 1 and t_words > 0, or arity >= 2.
857            // For each such rule, enumerate splits of w words among children + word tokens.
858            for (rule_idx, rule) in rules.iter().enumerate() {
859                let Some(tmpl) = &templates[rule_idx] else {
860                    continue;
861                };
862                let t_words = tmpl.word_count();
863                let children = tmpl.children_in_order();
864                let arity = children.len();
865
866                // Skip lexical (handled above) and unary-preserving (handled above)
867                if arity == 0 {
868                    continue;
869                }
870                if arity == 1 && t_words == 0 {
871                    continue;
872                }
873
874                // Check feasibility: total must be at least t_words + sum(minwidth(child))
875                if t_words > w {
876                    continue;
877                }
878                let remaining = w - t_words;
879
880                if arity == 1 {
881                    // Single child, specific word count contribution
882                    let child_idx = children[0];
883                    let child_state = match rule.children.get(child_idx) {
884                        Some(&s) if !s.is_stuck() => s,
885                        _ => continue,
886                    };
887                    let w_child = remaining;
888                    if w_child > n {
889                        continue;
890                    }
891                    let child_bi = bi[child_state.index()][w_child];
892                    if child_bi == scorer.zero() {
893                        continue;
894                    }
895                    let candidate = scorer.times(scorer.rule_score(rule.weight), child_bi);
896                    let ri = rule.result.index();
897                    if scorer.better(candidate, bi[ri][w]) {
898                        bi[ri][w] = candidate;
899                    }
900                } else if arity == 2 {
901                    // Binary: split `remaining` between child 0 and child 1
902                    let child0_idx = children[0];
903                    let child1_idx = children[1];
904                    let child0_state = match rule.children.get(child0_idx) {
905                        Some(&s) if !s.is_stuck() => s,
906                        _ => continue,
907                    };
908                    let child1_state = match rule.children.get(child1_idx) {
909                        Some(&s) if !s.is_stuck() => s,
910                        _ => continue,
911                    };
912                    let mw0 = minwidth[child0_state.index()];
913                    let mw1 = minwidth[child1_state.index()];
914
915                    for w0 in mw0..=remaining.saturating_sub(if mw1 < INF { mw1 } else { break }) {
916                        let w1 = remaining - w0;
917                        if w1 < mw1 || mw1 == INF {
918                            continue;
919                        }
920                        let bi0 = bi[child0_state.index()][w0];
921                        if bi0 == scorer.zero() {
922                            continue;
923                        }
924                        let bi1 = bi[child1_state.index()][w1];
925                        if bi1 == scorer.zero() {
926                            continue;
927                        }
928                        let candidate =
929                            scorer.times(scorer.times(scorer.rule_score(rule.weight), bi0), bi1);
930                        let ri = rule.result.index();
931                        if scorer.better(candidate, bi[ri][w]) {
932                            bi[ri][w] = candidate;
933                        }
934                    }
935                } else {
936                    // Higher arity: enumerate all splits
937                    let child_states: Vec<_> = children
938                        .iter()
939                        .map(|&ci| rule.children.get(ci).copied().filter(|s| !s.is_stuck()))
940                        .collect();
941                    if child_states.iter().any(|s| s.is_none()) {
942                        continue;
943                    }
944                    let child_states: Vec<StateId> =
945                        child_states.into_iter().map(|s| s.unwrap()).collect();
946                    let mins: Vec<usize> =
947                        child_states.iter().map(|s| minwidth[s.index()]).collect();
948                    if mins.contains(&INF) {
949                        continue;
950                    }
951                    let min_sum: usize = mins.iter().sum();
952                    if remaining < min_sum {
953                        continue;
954                    }
955
956                    // Enumerate all splits (recursive helper via stack)
957                    let mut best_prod = scorer.zero();
958                    enumerate_splits(
959                        &child_states,
960                        &mins,
961                        remaining,
962                        0,
963                        1.0,
964                        rule.weight,
965                        &bi,
966                        scorer,
967                        &mut |prod| {
968                            if scorer.better(prod, best_prod) {
969                                best_prod = prod;
970                            }
971                        },
972                    );
973                    if best_prod != scorer.zero() {
974                        let ri = rule.result.index();
975                        if scorer.better(best_prod, bi[ri][w]) {
976                            bi[ri][w] = best_prod;
977                        }
978                    }
979                }
980            }
981        }
982
983        // -----------------------------------------------------------------------
984        // SX(X, l, r) = best outside weight with l words left, r words right.
985        // Shape: [num_states][(n+1)*(n+1)]; stride = n+1.
986        // Process BFS by increasing l+r (= decreasing parent width n-l-r).
987        // -----------------------------------------------------------------------
988        let stride = n + 1;
989        let mut sx = vec![vec![scorer.zero(); stride * stride]; num_states];
990
991        // Seed: accepting states get SX(a, 0, 0) = 1.0
992        grammar.initial_states(&mut |state| {
993            if !state.is_stuck() && state.index() < num_states {
994                sx[state.index()][0] = scorer.one(); // l=0, r=0 -> index 0*stride+0=0
995            }
996        });
997
998        // Process BFS by level lr = l + r = 0, 1, ..., n
999        for lr in 0..=n {
1000            // At each level, we may need to fix unary width-preserving rules within the level.
1001            // For those, SX(child, l, r) >= SX(parent, l, r) * rule.weight when l+r = parent's lr.
1002            // Iterate until fixpoint for unary rules at this level.
1003            let mut level_changed = true;
1004            while level_changed {
1005                level_changed = false;
1006                for (rule_idx, rule) in rules.iter().enumerate() {
1007                    let Some(tmpl) = &templates[rule_idx] else {
1008                        continue;
1009                    };
1010                    let t_words = tmpl.word_count();
1011                    let children = tmpl.children_in_order();
1012                    if children.len() != 1 || t_words != 0 {
1013                        continue;
1014                    }
1015
1016                    let child_idx = children[0];
1017                    let child_state = match rule.children.get(child_idx) {
1018                        Some(&s) if !s.is_stuck() => s,
1019                        _ => continue,
1020                    };
1021
1022                    let parent_state = rule.result;
1023                    // For all (l, r) with l+r = lr
1024                    for l in 0..=lr {
1025                        let r = lr - l;
1026                        if l + r > n {
1027                            continue;
1028                        }
1029                        let idx = l * stride + r;
1030                        let parent_sx = sx[parent_state.index()][idx];
1031                        if parent_sx == scorer.zero() {
1032                            continue;
1033                        }
1034                        let candidate = scorer.times(parent_sx, scorer.rule_score(rule.weight));
1035                        if scorer.better(candidate, sx[child_state.index()][idx]) {
1036                            sx[child_state.index()][idx] = candidate;
1037                            level_changed = true;
1038                        }
1039                    }
1040                }
1041            }
1042
1043            // Now process non-unary rules: for each settled (A, l_A, r_A) with l_A+r_A = lr,
1044            // expand top-down to children.
1045            for (rule_idx, rule) in rules.iter().enumerate() {
1046                let Some(tmpl) = &templates[rule_idx] else {
1047                    continue;
1048                };
1049                let t_words = tmpl.word_count();
1050                let children_order = tmpl.children_in_order();
1051                let arity = children_order.len();
1052
1053                if arity == 0 {
1054                    continue;
1055                } // no children to update
1056
1057                // Unary width-preserving already handled above
1058                if arity == 1 && t_words == 0 {
1059                    continue;
1060                }
1061
1062                let parent_state = rule.result;
1063
1064                for l_a in 0..=lr {
1065                    let r_a = lr - l_a;
1066                    if l_a + r_a > n {
1067                        continue;
1068                    }
1069                    let parent_sx = sx[parent_state.index()][l_a * stride + r_a];
1070                    if parent_sx == scorer.zero() {
1071                        continue;
1072                    }
1073
1074                    let parent_width = n - l_a - r_a;
1075                    if t_words > parent_width {
1076                        continue;
1077                    }
1078                    let remaining_for_children = parent_width - t_words;
1079
1080                    if arity == 1 {
1081                        // arity=1, t_words > 0: child gets all remaining width
1082                        let child_idx = children_order[0];
1083                        let child_state = match rule.children.get(child_idx) {
1084                            Some(&s) if !s.is_stuck() => s,
1085                            _ => continue,
1086                        };
1087                        let wl_p = tmpl.words_left_of_child(child_idx);
1088                        let wr_p = tmpl.words_right_of_child(child_idx);
1089                        // Words to the left/right of the child from the parent perspective
1090                        let l_c = l_a + wl_p;
1091                        let r_c = r_a + wr_p;
1092                        if l_c + r_c > n {
1093                            continue;
1094                        }
1095                        let candidate = scorer.times(parent_sx, scorer.rule_score(rule.weight));
1096                        if scorer.better(candidate, sx[child_state.index()][l_c * stride + r_c]) {
1097                            sx[child_state.index()][l_c * stride + r_c] = candidate;
1098                        }
1099                    } else if arity == 2 {
1100                        // Binary fast path
1101                        let child0_idx = children_order[0];
1102                        let child1_idx = children_order[1];
1103                        let child0_state = match rule.children.get(child0_idx) {
1104                            Some(&s) if !s.is_stuck() => s,
1105                            _ => continue,
1106                        };
1107                        let child1_state = match rule.children.get(child1_idx) {
1108                            Some(&s) if !s.is_stuck() => s,
1109                            _ => continue,
1110                        };
1111                        let mw0 = minwidth[child0_state.index()];
1112                        let mw1 = minwidth[child1_state.index()];
1113                        let wl0 = tmpl.words_left_of_child(child0_idx);
1114                        let wr0 = tmpl.words_right_of_child(child0_idx);
1115                        let wl1 = tmpl.words_left_of_child(child1_idx);
1116                        let wr1 = tmpl.words_right_of_child(child1_idx);
1117
1118                        // For child 0: for each w1 of child 1
1119                        if mw1 < INF {
1120                            let max_w1 = remaining_for_children.saturating_sub(if mw0 < INF {
1121                                mw0
1122                            } else {
1123                                0
1124                            });
1125                            let child1_bi = &bi[child1_state.index()];
1126                            for (w1, &bi1) in
1127                                child1_bi.iter().enumerate().take(max_w1 + 1).skip(mw1)
1128                            {
1129                                let w0 = remaining_for_children - w1;
1130                                if mw0 < INF && w0 < mw0 {
1131                                    continue;
1132                                }
1133                                if bi1 == scorer.zero() {
1134                                    continue;
1135                                }
1136                                // child 1 is to the right of child 0 in frontier
1137                                // l_c0 = l_a + wl0 (words left of child0 in template)
1138                                // r_c0 = r_a + wr0 + w1 (words strictly right of child0 = wr0 from template + w1 from sibling)
1139                                let l_c0 = l_a + wl0;
1140                                let r_c0 = r_a + wr0 + w1;
1141                                if l_c0 + r_c0 > n {
1142                                    continue;
1143                                }
1144                                let candidate = scorer.times(
1145                                    scorer.times(parent_sx, scorer.rule_score(rule.weight)),
1146                                    bi1,
1147                                );
1148                                if scorer.better(
1149                                    candidate,
1150                                    sx[child0_state.index()][l_c0 * stride + r_c0],
1151                                ) {
1152                                    sx[child0_state.index()][l_c0 * stride + r_c0] = candidate;
1153                                }
1154                            }
1155                        }
1156
1157                        // For child 1: for each w0 of child 0
1158                        if mw0 < INF {
1159                            let max_w0 = remaining_for_children.saturating_sub(if mw1 < INF {
1160                                mw1
1161                            } else {
1162                                0
1163                            });
1164                            let child0_bi = &bi[child0_state.index()];
1165                            for (w0, &bi0) in
1166                                child0_bi.iter().enumerate().take(max_w0 + 1).skip(mw0)
1167                            {
1168                                let w1 = remaining_for_children - w0;
1169                                if mw1 < INF && w1 < mw1 {
1170                                    continue;
1171                                }
1172                                if bi0 == scorer.zero() {
1173                                    continue;
1174                                }
1175                                // l_c1 = l_a + wl1 + w0 (words left of child1 = wl1 from template + w0 from sibling)
1176                                // r_c1 = r_a + wr1
1177                                let l_c1 = l_a + wl1 + w0;
1178                                let r_c1 = r_a + wr1;
1179                                if l_c1 + r_c1 > n {
1180                                    continue;
1181                                }
1182                                let candidate = scorer.times(
1183                                    scorer.times(parent_sx, scorer.rule_score(rule.weight)),
1184                                    bi0,
1185                                );
1186                                if scorer.better(
1187                                    candidate,
1188                                    sx[child1_state.index()][l_c1 * stride + r_c1],
1189                                ) {
1190                                    sx[child1_state.index()][l_c1 * stride + r_c1] = candidate;
1191                                }
1192                            }
1193                        }
1194                    } else {
1195                        // Higher arity: general case
1196                        // For each child position p, enumerate sibling widths
1197                        for (p_pos, &p_child_idx) in children_order.iter().enumerate() {
1198                            let child_p_state = match rule.children.get(p_child_idx) {
1199                                Some(&s) if !s.is_stuck() => s,
1200                                _ => continue,
1201                            };
1202
1203                            let siblings: Vec<(usize, StateId)> = children_order
1204                                .iter()
1205                                .enumerate()
1206                                .filter(|&(q_pos, _)| q_pos != p_pos)
1207                                .map(|(_, &q_child_idx)| {
1208                                    let q_state = rule
1209                                        .children
1210                                        .get(q_child_idx)
1211                                        .copied()
1212                                        .unwrap_or(StateId::STUCK);
1213                                    (q_child_idx, q_state)
1214                                })
1215                                .collect();
1216
1217                            if siblings.iter().any(|(_, s)| s.is_stuck()) {
1218                                continue;
1219                            }
1220
1221                            let sib_states: Vec<StateId> =
1222                                siblings.iter().map(|(_, s)| *s).collect();
1223                            let sib_mins: Vec<usize> =
1224                                sib_states.iter().map(|s| minwidth[s.index()]).collect();
1225                            if sib_mins.contains(&INF) {
1226                                continue;
1227                            }
1228                            let sib_min_sum: usize = sib_mins.iter().sum();
1229
1230                            let mwp = minwidth[child_p_state.index()];
1231                            let max_sibling_total = if mwp < INF {
1232                                remaining_for_children.saturating_sub(mwp)
1233                            } else {
1234                                remaining_for_children
1235                            };
1236
1237                            if sib_min_sum > max_sibling_total {
1238                                continue;
1239                            }
1240
1241                            let wl_p = tmpl.words_left_of_child(p_child_idx);
1242                            let wr_p = tmpl.words_right_of_child(p_child_idx);
1243                            let left_sibs = tmpl.children_left_of(p_child_idx);
1244                            let right_sibs = tmpl.children_right_of(p_child_idx);
1245
1246                            // Enumerate sibling width assignments
1247                            enumerate_sibling_splits(
1248                                &sib_states,
1249                                &sib_mins,
1250                                sib_min_sum,
1251                                max_sibling_total,
1252                                &bi,
1253                                scorer,
1254                                &mut |sib_widths: &[usize], sib_bi_prod: f64| {
1255                                    // Map sibling widths back to left/right of p
1256                                    let mut wl_from_sibs = 0usize;
1257                                    let mut wr_from_sibs = 0usize;
1258                                    for (qi, &(q_child_idx, _)) in siblings.iter().enumerate() {
1259                                        if left_sibs.contains(&q_child_idx) {
1260                                            wl_from_sibs += sib_widths[qi];
1261                                        } else if right_sibs.contains(&q_child_idx) {
1262                                            wr_from_sibs += sib_widths[qi];
1263                                        }
1264                                    }
1265                                    let l_cp = l_a + wl_p + wl_from_sibs;
1266                                    let r_cp = r_a + wr_p + wr_from_sibs;
1267                                    if l_cp + r_cp > n {
1268                                        return;
1269                                    }
1270                                    let candidate = scorer.times(
1271                                        scorer.times(parent_sx, scorer.rule_score(rule.weight)),
1272                                        sib_bi_prod,
1273                                    );
1274                                    if scorer.better(
1275                                        candidate,
1276                                        sx[child_p_state.index()][l_cp * stride + r_cp],
1277                                    ) {
1278                                        sx[child_p_state.index()][l_cp * stride + r_cp] = candidate;
1279                                    }
1280                                },
1281                            );
1282                        }
1283                    }
1284                }
1285            }
1286        }
1287
1288        let sx_boxed: Vec<Box<[f64]>> = sx.into_iter().map(|v| v.into_boxed_slice()).collect();
1289
1290        SxHeuristic {
1291            sx: sx_boxed,
1292            n,
1293            stride,
1294            zero: scorer.zero(),
1295        }
1296    }
1297
1298    /// Serialize to bytes. Format: magic(8) + n(8) + stride(8) + num_states(8) +
1299    /// for each state: row_len(8) + f64*row_len.  All integers are little-endian.
1300    pub fn to_bytes(&self) -> Vec<u8> {
1301        let mut buf = Vec::new();
1302        buf.extend_from_slice(b"SXCACH01");
1303        buf.extend_from_slice(&(self.n as u64).to_le_bytes());
1304        buf.extend_from_slice(&(self.stride as u64).to_le_bytes());
1305        buf.extend_from_slice(&self.zero.to_le_bytes());
1306        buf.extend_from_slice(&(self.sx.len() as u64).to_le_bytes());
1307        for row in &self.sx {
1308            buf.extend_from_slice(&(row.len() as u64).to_le_bytes());
1309            for &v in row.iter() {
1310                buf.extend_from_slice(&v.to_le_bytes());
1311            }
1312        }
1313        buf
1314    }
1315
1316    /// Deserialize from bytes produced by [`Self::to_bytes`]. Returns `None` on any format error.
1317    pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
1318        let mut pos = 0;
1319        let read_u64 = |bytes: &[u8], pos: &mut usize| -> Option<u64> {
1320            let b = bytes.get(*pos..*pos + 8)?;
1321            *pos += 8;
1322            Some(u64::from_le_bytes(b.try_into().ok()?))
1323        };
1324        if bytes.get(..8) != Some(b"SXCACH01") {
1325            return None;
1326        }
1327        pos += 8;
1328        let n = read_u64(bytes, &mut pos)? as usize;
1329        let stride = read_u64(bytes, &mut pos)? as usize;
1330        let b = bytes.get(pos..pos + 8)?;
1331        pos += 8;
1332        let zero = f64::from_le_bytes(b.try_into().ok()?);
1333        let num_states = read_u64(bytes, &mut pos)? as usize;
1334        let mut sx = Vec::with_capacity(num_states);
1335        for _ in 0..num_states {
1336            let row_len = read_u64(bytes, &mut pos)? as usize;
1337            let mut row = Vec::with_capacity(row_len);
1338            for _ in 0..row_len {
1339                let b = bytes.get(pos..pos + 8)?;
1340                pos += 8;
1341                row.push(f64::from_le_bytes(b.try_into().ok()?));
1342            }
1343            sx.push(row.into_boxed_slice());
1344        }
1345        Some(SxHeuristic {
1346            sx,
1347            n,
1348            stride,
1349            zero,
1350        })
1351    }
1352}
1353
1354// ---------------------------------------------------------------------------
1355// UniversalSxHeuristic + SentenceSxHeuristic
1356// ---------------------------------------------------------------------------
1357
1358/// A precomputed SX heuristic table that is admissible for all sentence lengths ≤ `n_max`.
1359///
1360/// `SX(X, l, r)` depends only on the number of words to the left (`l`) and right (`r`)
1361/// of the span, not on the total sentence length.  A table built for `n_max` contains every
1362/// `(l, r)` pair that can appear for any `n ≤ n_max`, so it can be reused across sentences
1363/// without recomputation.  Per-sentence use requires knowing `n` to recover `r = n - span.end`
1364/// from a [`Span`]; supply it via [`UniversalSxHeuristic::for_sentence`].
1365pub struct UniversalSxHeuristic {
1366    inner: SxHeuristic,
1367}
1368
1369impl UniversalSxHeuristic {
1370    /// Build the universal SX heuristic for all sentence lengths up to `n_max`.
1371    pub fn new(grammar: &Explicit, hom: &Homomorphism, concat: Symbol, n_max: usize) -> Self {
1372        Self {
1373            inner: SxHeuristic::new(grammar, hom, concat, n_max),
1374        }
1375    }
1376
1377    /// Build the universal SX heuristic using `scorer`.
1378    pub fn new_with<S: WeightScorer>(
1379        grammar: &Explicit,
1380        hom: &Homomorphism,
1381        concat: Symbol,
1382        n_max: usize,
1383        scorer: &S,
1384    ) -> Self {
1385        Self {
1386            inner: SxHeuristic::new_with(grammar, hom, concat, n_max, scorer),
1387        }
1388    }
1389
1390    /// Maximum sentence length this table covers.
1391    pub fn n_max(&self) -> usize {
1392        self.inner.n
1393    }
1394
1395    /// Zero-cost per-sentence adapter that supplies `n` to `outside_estimate`.
1396    pub fn for_sentence(&self, n: usize) -> SentenceSxHeuristic<'_> {
1397        SentenceSxHeuristic { table: self, n }
1398    }
1399
1400    /// Serialize to bytes using the SX heuristic cache format.
1401    pub fn to_bytes(&self) -> Vec<u8> {
1402        self.inner.to_bytes()
1403    }
1404
1405    /// Deserialize from bytes produced by [`Self::to_bytes`]. Returns `None` on any format error.
1406    pub fn from_bytes(bytes: &[u8]) -> Option<Self> {
1407        SxHeuristic::from_bytes(bytes).map(|inner| Self { inner })
1408    }
1409}
1410
1411/// Zero-cost per-sentence view into a [`UniversalSxHeuristic`] with a fixed sentence length `n`.
1412#[derive(Clone, Copy)]
1413pub struct SentenceSxHeuristic<'a> {
1414    table: &'a UniversalSxHeuristic,
1415    n: usize,
1416}
1417
1418impl IntersectionHeuristic<StringDecompositionAutomaton> for SentenceSxHeuristic<'_> {
1419    fn outside_estimate(&self, left: StateId, span: &Span) -> f64 {
1420        let l = span.start;
1421        let r = self.n.saturating_sub(span.end);
1422        self.table.inner.lookup_lr(left, l, r)
1423    }
1424}
1425
1426impl IntersectionHeuristic<InvHom<'_, StringDecompositionAutomaton>> for SentenceSxHeuristic<'_> {
1427    fn outside_estimate(&self, left: StateId, span: &Span) -> f64 {
1428        <Self as IntersectionHeuristic<StringDecompositionAutomaton>>::outside_estimate(
1429            self, left, span,
1430        )
1431    }
1432}
1433
1434/// Recursively enumerate all width splits for multiple children, accumulating the product of BI values.
1435#[allow(clippy::too_many_arguments)]
1436fn enumerate_splits(
1437    child_states: &[StateId],
1438    mins: &[usize],
1439    remaining: usize,
1440    pos: usize,
1441    acc: f64,
1442    rule_weight: f64,
1443    bi: &[Vec<f64>],
1444    scorer: &impl WeightScorer,
1445    out: &mut impl FnMut(f64),
1446) {
1447    if pos == child_states.len() {
1448        if remaining == 0 {
1449            out(scorer.times(scorer.rule_score(rule_weight), acc));
1450        }
1451        return;
1452    }
1453    let min_w = mins[pos];
1454    let max_w = {
1455        // The remaining children after pos need at least sum(mins[pos+1..]) words
1456        let rest_min: usize = mins[pos + 1..].iter().sum();
1457        remaining.saturating_sub(rest_min)
1458    };
1459    let state = child_states[pos];
1460    for w in min_w..=max_w {
1461        let child_bi = bi[state.index()][w];
1462        if child_bi == scorer.zero() {
1463            continue;
1464        }
1465        enumerate_splits(
1466            child_states,
1467            mins,
1468            remaining - w,
1469            pos + 1,
1470            scorer.times(acc, child_bi),
1471            rule_weight,
1472            bi,
1473            scorer,
1474            out,
1475        );
1476    }
1477}
1478
1479/// Enumerate all sibling width assignments for the outside computation.
1480fn enumerate_sibling_splits(
1481    sib_states: &[StateId],
1482    sib_mins: &[usize],
1483    sib_min_sum: usize,
1484    max_total: usize,
1485    bi: &[Vec<f64>],
1486    scorer: &impl WeightScorer,
1487    out: &mut impl FnMut(&[usize], f64),
1488) {
1489    let k = sib_states.len();
1490    let mut widths = vec![0usize; k];
1491    let total_range = sib_min_sum..=max_total;
1492    for total in total_range {
1493        // Enumerate splits of `total` among k siblings with minimums sib_mins[i]
1494        enumerate_sibling_splits_inner(
1495            sib_states,
1496            sib_mins,
1497            total,
1498            0,
1499            &mut widths,
1500            scorer.one(),
1501            bi,
1502            scorer,
1503            out,
1504        );
1505    }
1506}
1507
1508#[allow(clippy::too_many_arguments)]
1509fn enumerate_sibling_splits_inner(
1510    sib_states: &[StateId],
1511    sib_mins: &[usize],
1512    remaining: usize,
1513    pos: usize,
1514    widths: &mut Vec<usize>,
1515    acc: f64,
1516    bi: &[Vec<f64>],
1517    scorer: &impl WeightScorer,
1518    out: &mut impl FnMut(&[usize], f64),
1519) {
1520    if pos == sib_states.len() {
1521        if remaining == 0 {
1522            out(widths, acc);
1523        }
1524        return;
1525    }
1526    let min_w = sib_mins[pos];
1527    let rest_min: usize = sib_mins[pos + 1..].iter().sum();
1528    let max_w = remaining.saturating_sub(rest_min);
1529    let state = sib_states[pos];
1530    for w in min_w..=max_w {
1531        let child_bi = bi[state.index()][w];
1532        if child_bi == scorer.zero() {
1533            continue;
1534        }
1535        widths[pos] = w;
1536        enumerate_sibling_splits_inner(
1537            sib_states,
1538            sib_mins,
1539            remaining - w,
1540            pos + 1,
1541            widths,
1542            scorer.times(acc, child_bi),
1543            bi,
1544            scorer,
1545            out,
1546        );
1547    }
1548}
1549
1550#[cfg(test)]
1551mod tests {
1552    use super::*;
1553
1554    #[test]
1555    fn displays_spans_with_dash_bounds() {
1556        assert_eq!(Span::new(2, 5).to_string(), "[2-5]");
1557    }
1558
1559    #[test]
1560    fn span_product_sibling_finder_returns_adjacent_products_in_both_directions() {
1561        let left_product = StateId(10);
1562        let right_product = StateId(11);
1563        let left_state = StateId(0);
1564        let right_state = StateId(1);
1565        let left_span = Span::new(0, 1);
1566        let right_span = Span::new(1, 3);
1567
1568        let mut finder = SpanProductSiblingFinder::default();
1569        assert!(finder.activate(right_product, right_state, StateId(21), right_span, 1));
1570
1571        let mut products = Vec::new();
1572        finder.sibling_products_into(left_span, 0, right_state, &mut products);
1573        assert_eq!(
1574            products,
1575            vec![SpanProductSibling {
1576                product: right_product,
1577                right_state: StateId(21)
1578            }]
1579        );
1580
1581        let mut finder = SpanProductSiblingFinder::default();
1582        assert!(finder.activate(left_product, left_state, StateId(20), left_span, 0));
1583
1584        products.clear();
1585        finder.sibling_products_into(right_span, 1, left_state, &mut products);
1586        assert_eq!(
1587            products,
1588            vec![SpanProductSibling {
1589                product: left_product,
1590                right_state: StateId(20)
1591            }]
1592        );
1593    }
1594
1595    #[test]
1596    fn span_product_sibling_finder_filters_by_left_state_and_activation() {
1597        let product = StateId(10);
1598        let wanted_left = StateId(0);
1599        let other_left = StateId(1);
1600        let right_state = StateId(20);
1601
1602        let mut finder = SpanProductSiblingFinder::default();
1603        let mut products = Vec::new();
1604        finder.sibling_products_into(Span::new(0, 1), 0, wanted_left, &mut products);
1605        assert!(products.is_empty());
1606
1607        assert!(finder.activate(product, other_left, right_state, Span::new(1, 3), 1));
1608        finder.sibling_products_into(Span::new(0, 1), 0, wanted_left, &mut products);
1609        assert!(products.is_empty());
1610
1611        finder.sibling_products_into(Span::new(0, 1), 0, other_left, &mut products);
1612        assert_eq!(
1613            products,
1614            vec![SpanProductSibling {
1615                product,
1616                right_state
1617            }]
1618        );
1619    }
1620
1621    #[test]
1622    fn span_product_sibling_finder_activation_is_idempotent_per_position() {
1623        let product = StateId(10);
1624        let left = StateId(0);
1625        let right = StateId(20);
1626        let span = Span::new(0, 1);
1627
1628        let mut finder = SpanProductSiblingFinder::default();
1629        assert!(finder.activate(product, left, right, span, 0));
1630        assert!(!finder.activate(product, left, right, span, 0));
1631        assert!(finder.activate(product, left, right, span, 1));
1632        assert!(!finder.activate(product, left, right, span, 1));
1633    }
1634
1635    #[test]
1636    fn span_product_sibling_finder_separates_products_with_same_right_span() {
1637        let left_a = StateId(0);
1638        let left_b = StateId(1);
1639        let product_a = StateId(10);
1640        let product_b = StateId(11);
1641        let span = Span::new(1, 2);
1642
1643        let mut finder = SpanProductSiblingFinder::default();
1644        assert!(finder.activate(product_a, left_a, StateId(20), span, 1));
1645        assert!(finder.activate(product_b, left_b, StateId(21), span, 1));
1646
1647        let mut products = Vec::new();
1648        finder.sibling_products_into(Span::new(0, 1), 0, left_a, &mut products);
1649        assert_eq!(
1650            products,
1651            vec![SpanProductSibling {
1652                product: product_a,
1653                right_state: StateId(20)
1654            }]
1655        );
1656
1657        finder.sibling_products_into(Span::new(0, 1), 0, left_b, &mut products);
1658        assert_eq!(
1659            products,
1660            vec![SpanProductSibling {
1661                product: product_b,
1662                right_state: StateId(21)
1663            }]
1664        );
1665    }
1666
1667    #[test]
1668    fn span_product_sibling_finder_allocates_only_populated_slots() {
1669        let mut finder = SpanProductSiblingFinder::default();
1670        let sparse_left = StateId(1_000_000);
1671
1672        assert!(finder.activate(
1673            StateId(0),
1674            sparse_left,
1675            StateId(0),
1676            Span::new(10_000, 20_000),
1677            0,
1678        ));
1679        assert_eq!(finder.left_slots_by_end.len(), 1);
1680        assert_eq!(finder.left_lists.len(), 1);
1681        assert!(finder.right_slots_by_start.is_empty());
1682    }
1683
1684    #[test]
1685    fn lexical_lookup_handles_repeated_words() {
1686        let mut alg = StringAlgebra::new();
1687        let a = alg.intern_word("a");
1688        let b = alg.intern_word("b");
1689        let decomp = alg.decompose(vec![a, b, a]);
1690
1691        let mut spans = Vec::new();
1692        decomp.step(a, &[], &mut |q| spans.push(q));
1693        assert_eq!(spans, vec![Span::new(0, 1), Span::new(2, 3)]);
1694    }
1695
1696    #[test]
1697    fn string_decomposition_step_det_handles_concat() {
1698        let mut alg = StringAlgebra::new();
1699        let a = alg.intern_word("a");
1700        let b = alg.intern_word("b");
1701        let decomp = alg.decompose(vec![a, b]);
1702        let concat = alg.concat_symbol();
1703
1704        assert_eq!(
1705            decomp.step_det(concat, &[Span::new(0, 1), Span::new(1, 2)]),
1706            Some(Span::new(0, 2))
1707        );
1708        assert_eq!(
1709            decomp.step_det(concat, &[Span::new(1, 2), Span::new(0, 1)]),
1710            None
1711        );
1712    }
1713
1714    #[test]
1715    fn string_decomposition_step_det_rejects_repeated_word_nullaries() {
1716        let mut alg = StringAlgebra::new();
1717        let a = alg.intern_word("a");
1718        let decomp = alg.decompose(vec![a, a]);
1719
1720        assert_eq!(decomp.step_det(a, &[]), None);
1721    }
1722
1723    #[test]
1724    fn concat_requires_adjacency() {
1725        let mut alg = StringAlgebra::new();
1726        let a = alg.intern_word("a");
1727        let decomp = alg.decompose(vec![a, a, a]);
1728        let concat = alg.concat_symbol();
1729
1730        let mut ok = Vec::new();
1731        decomp.step(concat, &[Span::new(0, 1), Span::new(1, 3)], &mut |q| {
1732            ok.push(q)
1733        });
1734        assert_eq!(ok, vec![Span::new(0, 3)]);
1735
1736        let mut bad = Vec::new();
1737        decomp.step(concat, &[Span::new(0, 1), Span::new(2, 3)], &mut |q| {
1738            bad.push(q)
1739        });
1740        assert!(bad.is_empty());
1741    }
1742
1743    #[test]
1744    fn universe_is_callback_enumerated() {
1745        let mut alg = StringAlgebra::new();
1746        let a = alg.intern_word("a");
1747        let decomp = alg.decompose(vec![a, a, a, a]);
1748
1749        let mut count = 0;
1750        decomp.all_states(&mut |_| count += 1);
1751        assert_eq!(count, 10);
1752    }
1753
1754    #[test]
1755    fn condensed_rule_count_matches_cky_formula() {
1756        let mut alg = StringAlgebra::new();
1757        let a = alg.intern_word("a");
1758        let decomp = alg.decompose(vec![a, a, a, a]);
1759
1760        let mut count = 0;
1761        decomp.condensed_rules(&mut |_, _, _| count += 1);
1762        assert_eq!(count, decomp.rule_count());
1763    }
1764
1765    #[test]
1766    fn indexed_condensed_rules_by_child_enumerates_adjacent_spans() {
1767        let mut alg = StringAlgebra::new();
1768        let a = alg.intern_word("a");
1769        let decomp = alg.decompose(vec![a, a, a]);
1770
1771        let mut rules = Vec::new();
1772        decomp.condensed_rules_by_child(0, &Span::new(0, 1), &mut |children, symbols, result| {
1773            rules.push((children.to_vec(), symbols.clone(), result));
1774        });
1775
1776        assert_eq!(rules.len(), 2);
1777        assert_eq!(rules[0].0, vec![Span::new(0, 1), Span::new(1, 2)]);
1778        assert_eq!(rules[0].2, Span::new(0, 2));
1779        assert_eq!(rules[1].0, vec![Span::new(0, 1), Span::new(1, 3)]);
1780        assert_eq!(rules[1].2, Span::new(0, 3));
1781        assert!(
1782            rules
1783                .iter()
1784                .all(|(_, symbols, _)| { symbols.contains(decomp.concat_symbol()) })
1785        );
1786    }
1787
1788    // -----------------------------------------------------------------------
1789    // SxHeuristic tests
1790    // -----------------------------------------------------------------------
1791
1792    /// Build a tiny grammar and homomorphism for tests.
1793    ///
1794    /// Grammar (binarized):
1795    ///   S -> A B   w=0.9   (homomorphism: concat(x0, x1))
1796    ///   A -> "a"   w=0.8   (homomorphism: a_word)
1797    ///   B -> "b"   w=0.7   (homomorphism: b_word)
1798    ///
1799    /// States: s_A=0, s_B=1, s_S=2 (accepting)
1800    ///
1801    /// For sentence ["a", "b"] (n=2):
1802    ///   BI(s_A, 1) = 0.8 (lexical)
1803    ///   BI(s_B, 1) = 0.7 (lexical)
1804    ///   BI(s_S, 2) = 0.9 * BI(s_A, 1) * BI(s_B, 1) = 0.9 * 0.8 * 0.7 = 0.504
1805    ///   SX(s_S, 0, 0) = 1.0 (accepting)
1806    ///   SX(s_A, 0, 1) = SX(s_S, 0, 0) * 0.9 * BI(s_B, 1) = 1.0 * 0.9 * 0.7 = 0.63
1807    ///   SX(s_B, 1, 0) = SX(s_S, 0, 0) * 0.9 * BI(s_A, 1) = 1.0 * 0.9 * 0.8 = 0.72
1808    fn build_tiny_grammar_and_hom() -> (
1809        crate::Explicit,
1810        crate::Homomorphism,
1811        Symbol,
1812        crate::StateId,
1813        crate::StateId,
1814        crate::StateId,
1815    ) {
1816        use crate::ExplicitBuilder;
1817
1818        let mut hom_arena = packed_term_arena::tree::TreeArena::new();
1819
1820        // Build word symbols and concat
1821        let mut sig = crate::Signature::new();
1822        let concat = sig.intern("*".to_owned(), 2).unwrap();
1823        let sym_a = sig.intern("a".to_owned(), 0).unwrap();
1824        let sym_b = sig.intern("b".to_owned(), 0).unwrap();
1825        // Grammar symbols (S, A, B) - we'll reuse the same symbol ids for simplicity
1826        // The grammar uses Symbol values; the homomorphism maps grammar symbols to string terms.
1827        // Let's define grammar symbols as:
1828        //   g_AB = Symbol(10), g_A = Symbol(11), g_B = Symbol(12)
1829        let g_ab = Symbol(10); // S -> A B
1830        let g_a = Symbol(11); // A -> "a"
1831        let g_b = Symbol(12); // B -> "b"
1832
1833        // Homomorphism terms:
1834        // g_ab(x0, x1) -> concat(x0, x1)
1835        let v0 = hom_arena.add_node(HomLabel::Var(0), vec![]);
1836        let v1 = hom_arena.add_node(HomLabel::Var(1), vec![]);
1837        let concat_term = hom_arena.add_node(HomLabel::Symbol(concat), vec![v0, v1]);
1838        // g_a() -> a_word
1839        let a_word_term = hom_arena.add_node(HomLabel::Symbol(sym_a), vec![]);
1840        // g_b() -> b_word
1841        let b_word_term = hom_arena.add_node(HomLabel::Symbol(sym_b), vec![]);
1842
1843        let mut hom = Homomorphism::with_arena(hom_arena);
1844        hom.add(g_ab, 2, concat_term).unwrap();
1845        hom.add(g_a, 0, a_word_term).unwrap();
1846        hom.add(g_b, 0, b_word_term).unwrap();
1847
1848        // Grammar automaton
1849        let mut b = ExplicitBuilder::new();
1850        let s_a = b.new_state(); // index 0
1851        let s_b = b.new_state(); // index 1
1852        let s_s = b.new_state(); // index 2, accepting
1853
1854        b.add_weighted_rule(g_a, vec![], s_a, 0.8);
1855        b.add_weighted_rule(g_b, vec![], s_b, 0.7);
1856        b.add_weighted_rule(g_ab, vec![s_a, s_b], s_s, 0.9);
1857        b.add_accepting(s_s);
1858
1859        (b.build(), hom, concat, s_a, s_b, s_s)
1860    }
1861
1862    #[test]
1863    fn sx_heuristic_bi_and_sx_values_match_hand_computation() {
1864        let (grammar, hom, concat, s_a, s_b, s_s) = build_tiny_grammar_and_hom();
1865        let universal = UniversalSxHeuristic::new(&grammar, &hom, concat, 2);
1866        let h = universal.for_sentence(2);
1867
1868        // outside_estimate(s_A, span=[0,1]): l=0, r=2-1=1 -> SX(s_A, 0, 1)
1869        let est_a = <SentenceSxHeuristic<'_> as IntersectionHeuristic<
1870            StringDecompositionAutomaton,
1871        >>::outside_estimate(&h, s_a, &Span::new(0, 1));
1872        let expected_a = 0.9 * 0.7; // SX(s_S,0,0)*w_rule*BI(s_B,1)
1873        assert!(
1874            (est_a - expected_a).abs() < 1e-9,
1875            "SX(s_A, 0, 1) = {est_a}, expected {expected_a}"
1876        );
1877
1878        // outside_estimate(s_B, span=[1,2]): l=1, r=2-2=0 -> SX(s_B, 1, 0)
1879        let est_b = <SentenceSxHeuristic<'_> as IntersectionHeuristic<
1880            StringDecompositionAutomaton,
1881        >>::outside_estimate(&h, s_b, &Span::new(1, 2));
1882        let expected_b = 0.9 * 0.8; // SX(s_S,0,0)*w_rule*BI(s_A,1)
1883        assert!(
1884            (est_b - expected_b).abs() < 1e-9,
1885            "SX(s_B, 1, 0) = {est_b}, expected {expected_b}"
1886        );
1887
1888        // outside_estimate(s_S, span=[0,2]): l=0, r=0 -> SX(s_S, 0, 0) = 1.0
1889        let est_s = <SentenceSxHeuristic<'_> as IntersectionHeuristic<
1890            StringDecompositionAutomaton,
1891        >>::outside_estimate(&h, s_s, &Span::new(0, 2));
1892        assert!(
1893            (est_s - 1.0).abs() < 1e-9,
1894            "SX(s_S, 0, 0) = {est_s}, expected 1.0"
1895        );
1896    }
1897
1898    #[test]
1899    fn sx_heuristic_outside_returns_zero_for_impossible_spans() {
1900        let (grammar, hom, concat, s_a, _s_b, _s_s) = build_tiny_grammar_and_hom();
1901        let universal = UniversalSxHeuristic::new(&grammar, &hom, concat, 2);
1902        let h = universal.for_sentence(2);
1903
1904        // s_A cannot appear in a span of width 0 (minwidth=1), so contexts where
1905        // n-l-r < 1 should give 0.
1906        // span=[0,0]: l=0, r=2, l+r=2=n -> parent width 0, can't fit s_A
1907        let est = <SentenceSxHeuristic<'_> as IntersectionHeuristic<
1908            StringDecompositionAutomaton,
1909        >>::outside_estimate(&h, s_a, &Span::new(0, 0));
1910        assert_eq!(est, 0.0, "SX(s_A, 0, 2) should be 0 (no room)");
1911    }
1912
1913    #[test]
1914    fn universal_sx_covers_shorter_sentences() {
1915        // A table built for n_max=3 must give the same estimates as one built for n=2
1916        // when used via for_sentence(2).
1917        let (grammar, hom, concat, s_a, s_b, _s_s) = build_tiny_grammar_and_hom();
1918        let universal = UniversalSxHeuristic::new(&grammar, &hom, concat, 3);
1919        let h2 = universal.for_sentence(2);
1920        let universal_direct = UniversalSxHeuristic::new(&grammar, &hom, concat, 2);
1921        let h2_direct = universal_direct.for_sentence(2);
1922
1923        for (state, span) in [(s_a, Span::new(0, 1)), (s_b, Span::new(1, 2))] {
1924            let via_universal = <SentenceSxHeuristic<'_> as IntersectionHeuristic<
1925                StringDecompositionAutomaton,
1926            >>::outside_estimate(&h2, state, &span);
1927            let direct = <SentenceSxHeuristic<'_> as IntersectionHeuristic<
1928                StringDecompositionAutomaton,
1929            >>::outside_estimate(&h2_direct, state, &span);
1930            assert!(
1931                (via_universal - direct).abs() < 1e-12,
1932                "universal(n_max=3).for_sentence(2) != direct(n=2) for state {:?} span {:?}: {} vs {}",
1933                state,
1934                span,
1935                via_universal,
1936                direct
1937            );
1938        }
1939    }
1940}