Skip to main content

rusty_alto/
obligatory_leaf.rs

1//! Obligatory-leaf suffix filter — the **F** heuristic (Klein & Manning 2003,
2//! adapted to the automaton-intersection setting; see `docs/f-heuristic-design.md`).
3//!
4//! An active constituent `(X, span)` is hopeless if the grammar *forces* `X`'s
5//! completions to emit terminals strictly left/right of `span` that the actual
6//! input cannot supply there. We precompute, per grammar state `X`, the
7//! multisets of leaves every completion must emit on each side (`req_left`,
8//! `req_right`); per input we count the available terminal supply on each side
9//! of a span; and we prune (`outside_estimate = scorer.zero()`) when an
10//! obligation exceeds the supply. This is admissible — we zero only when an
11//! obligatory leaf is genuinely unavailable (true outside weight = 0) — and is
12//! meant to be combined with the SX heuristic via [`crate::MinHeuristic`].
13//!
14//! This module is self-contained: it walks the homomorphism frontier directly
15//! (capturing each leaf `Symbol`) rather than extending the SX builder's
16//! `YieldToken`, so the pure SX path is untouched.
17
18use crate::combinators::InvHom;
19use crate::homomorphism::{HomLabel, Homomorphism};
20use crate::score::WeightScorer;
21use crate::{
22    Explicit, FxHashMap, IntersectionHeuristic, Span, StateId, StringDecompositionAutomaton,
23    Symbol, TopDownTa,
24};
25use packed_term_arena::tree::{Tree, TreeArena};
26use std::collections::BTreeMap;
27
28/// Build-time sparse leaf multiset: leaf-symbol id -> count.
29type Bag = BTreeMap<u32, u32>;
30/// Frozen obligation: sorted `(leaf-symbol id, count)` pairs (small; median 1).
31type ReqList = Box<[(u32, u32)]>;
32
33#[derive(Clone, Debug)]
34enum Tok {
35    Word(u32),
36    Child(usize),
37}
38
39struct FlatRule {
40    result: usize,
41    tokens: Vec<Tok>,
42}
43
44fn frontier(arena: &TreeArena<HomLabel>, node: Tree, children: &[StateId], out: &mut Vec<Tok>) {
45    match *arena.get_label(node) {
46        HomLabel::Var(i) => {
47            if let Some(c) = children.get(i) {
48                out.push(Tok::Child(c.index()));
49            }
50        }
51        HomLabel::Symbol(s) => {
52            let kids = arena.get_children(node);
53            if kids.is_empty() {
54                out.push(Tok::Word(s.0));
55            } else {
56                for &k in kids {
57                    frontier(arena, k, children, out);
58                }
59            }
60        }
61    }
62}
63
64fn bag_add(acc: &mut Bag, other: &Bag) {
65    for (&k, &v) in other {
66        *acc.entry(k).or_insert(0) += v;
67    }
68}
69
70/// Per-key min; drops keys whose min is 0 (= absent from one side).
71fn bag_min(a: &Bag, b: &Bag) -> Bag {
72    let mut out = Bag::new();
73    for (&k, &av) in a {
74        if let Some(&bv) = b.get(&k) {
75            let m = av.min(bv);
76            if m > 0 {
77                out.insert(k, m);
78            }
79        }
80    }
81    out
82}
83
84fn meet_update(slot: &mut Option<Bag>, cand: Bag, changed: &mut bool) {
85    let new = match slot.as_ref() {
86        None => cand,
87        Some(cur) => bag_min(cur, &cand),
88    };
89    if slot.as_ref() != Some(&new) {
90        *slot = Some(new);
91        *changed = true;
92    }
93}
94
95fn freeze(slot: &Option<Bag>) -> Option<ReqList> {
96    slot.as_ref()
97        .map(|b| b.iter().map(|(&k, &v)| (k, v)).collect())
98}
99
100/// Grammar-only obligatory-leaf tables (`req_left`, `req_right`), cached per
101/// grammar and reused across inputs. Build with [`ObligatoryLeafTables::from_grammar`].
102pub struct ObligatoryLeafTables {
103    /// `req_left[X]`: leaves every completion of `X` must emit left of its span.
104    /// `None` = non-productive / root-unreachable (never an A* item; treated as
105    /// no obligation).
106    req_left: Vec<Option<ReqList>>,
107    req_right: Vec<Option<ReqList>>,
108}
109
110impl ObligatoryLeafTables {
111    /// Compute the obligatory-leaf tables from a grammar and its string
112    /// homomorphism. Grammar-only and cheap (same fixpoint shape as `minwidth`).
113    pub fn from_grammar(grammar: &Explicit, hom: &Homomorphism) -> Self {
114        let num_states = grammar.num_states() as usize;
115        let arena = hom.arena();
116
117        // --- Phase A: flatten rules (mirrors how SX filters stuck children). ---
118        let mut flat_rules: Vec<FlatRule> = Vec::new();
119        for rule in grammar.rules() {
120            let Some(term) = hom.get(rule.symbol) else {
121                continue;
122            };
123            if rule
124                .children
125                .iter()
126                .any(|c| c.is_stuck() || c.index() >= num_states)
127                || rule.result.is_stuck()
128                || rule.result.index() >= num_states
129            {
130                continue;
131            }
132            let mut tokens = Vec::new();
133            frontier(arena, term, rule.children, &mut tokens);
134            flat_rules.push(FlatRule {
135                result: rule.result.index(),
136                tokens,
137            });
138        }
139        let mut accepting = Vec::new();
140        grammar.initial_states(&mut |s: StateId| {
141            if !s.is_stuck() && s.index() < num_states {
142                accepting.push(s.index());
143            }
144        });
145
146        // --- Phase B: mic (obligatory INSIDE leaves), MEET over a state's rules. ---
147        let mut mic: Vec<Option<Bag>> = vec![None; num_states];
148        loop {
149            let mut changed = false;
150            for r in &flat_rules {
151                let mut acc = Bag::new();
152                let mut feasible = true;
153                for tok in &r.tokens {
154                    match tok {
155                        Tok::Word(s) => {
156                            *acc.entry(*s).or_insert(0) += 1;
157                        }
158                        Tok::Child(c) => match &mic[*c] {
159                            Some(m) => bag_add(&mut acc, m),
160                            None => {
161                                feasible = false;
162                                break;
163                            }
164                        },
165                    }
166                }
167                if feasible {
168                    meet_update(&mut mic[r.result], acc, &mut changed);
169                }
170            }
171            if !changed {
172                break;
173            }
174        }
175
176        // Only rules whose every child is productive can occur in a finite parse.
177        let productive: Vec<&FlatRule> = flat_rules
178            .iter()
179            .filter(|r| {
180                r.tokens.iter().all(|t| match t {
181                    Tok::Child(c) => mic[*c].is_some(),
182                    _ => true,
183                })
184            })
185            .collect();
186
187        // --- Phase C: req_left / req_right (obligatory OUTSIDE leaves). ---
188        let mut req_left: Vec<Option<Bag>> = vec![None; num_states];
189        let mut req_right: Vec<Option<Bag>> = vec![None; num_states];
190        for &a in &accepting {
191            req_left[a] = Some(Bag::new());
192            req_right[a] = Some(Bag::new());
193        }
194        loop {
195            let mut changed = false;
196            for r in &productive {
197                for (pos, tok) in r.tokens.iter().enumerate() {
198                    let x = match tok {
199                        Tok::Child(c) => *c,
200                        _ => continue,
201                    };
202                    let mut left = Bag::new();
203                    let mut right = Bag::new();
204                    for (q, t2) in r.tokens.iter().enumerate() {
205                        if q == pos {
206                            continue;
207                        }
208                        let side = if q < pos { &mut left } else { &mut right };
209                        match t2 {
210                            Tok::Word(s) => {
211                                *side.entry(*s).or_insert(0) += 1;
212                            }
213                            Tok::Child(c) => bag_add(side, mic[*c].as_ref().unwrap()),
214                        }
215                    }
216                    if let Some(pr) = req_right[r.result].clone() {
217                        let mut cand = pr;
218                        bag_add(&mut cand, &right);
219                        meet_update(&mut req_right[x], cand, &mut changed);
220                    }
221                    if let Some(pl) = req_left[r.result].clone() {
222                        let mut cand = pl;
223                        bag_add(&mut cand, &left);
224                        meet_update(&mut req_left[x], cand, &mut changed);
225                    }
226                }
227            }
228            if !changed {
229                break;
230            }
231        }
232
233        ObligatoryLeafTables {
234            req_left: req_left.iter().map(freeze).collect(),
235            req_right: req_right.iter().map(freeze).collect(),
236        }
237    }
238
239    /// Build a per-sentence F heuristic. `scorer` fixes the `pass` / `prune`
240    /// values (`one()` / `zero()`) so the estimate lives in the same score
241    /// space as the SX heuristic it is `min`-combined with.
242    pub fn for_sentence<'a, S: WeightScorer>(
243        &'a self,
244        sentence: &[Symbol],
245        scorer: &S,
246    ) -> ObligatoryLeafHeuristic<'a> {
247        // Sorted positions per terminal: count in a prefix/suffix via partition_point.
248        let mut positions: FxHashMap<u32, Vec<u32>> = FxHashMap::default();
249        for (i, sym) in sentence.iter().enumerate() {
250            positions.entry(sym.0).or_default().push(i as u32);
251        }
252        ObligatoryLeafHeuristic {
253            tables: self,
254            positions,
255            pass: scorer.one(),
256            prune: scorer.zero(),
257        }
258    }
259}
260
261/// Per-input F heuristic: an [`ObligatoryLeafTables`] bound to one sentence's
262/// terminal supply. Cheap to build (one pass over the sentence).
263pub struct ObligatoryLeafHeuristic<'a> {
264    tables: &'a ObligatoryLeafTables,
265    positions: FxHashMap<u32, Vec<u32>>,
266    pass: f64,
267    prune: f64,
268}
269
270impl ObligatoryLeafHeuristic<'_> {
271    /// Occurrences of terminal `t` strictly left of position `start`.
272    #[inline]
273    fn supply_left(&self, t: u32, start: usize) -> usize {
274        self.positions
275            .get(&t)
276            .map_or(0, |p| p.partition_point(|&q| (q as usize) < start))
277    }
278
279    /// Occurrences of terminal `t` at or right of position `end`.
280    #[inline]
281    fn supply_right(&self, t: u32, end: usize) -> usize {
282        self.positions
283            .get(&t)
284            .map_or(0, |p| p.len() - p.partition_point(|&q| (q as usize) < end))
285    }
286
287    /// `true` iff the grammar forces `left`'s completion to emit an obligatory
288    /// leaf that the input cannot supply on the required side of `span` — i.e.
289    /// the item has zero outside weight and is hopeless. This is the sound test
290    /// shared by [`Self::estimate`] (priority) and `admits` (construction-time
291    /// filter).
292    #[inline]
293    fn prunes(&self, left: StateId, span: &Span) -> bool {
294        let idx = left.index();
295        if let Some(Some(req)) = self.tables.req_left.get(idx).map(|o| o.as_deref()) {
296            for &(t, need) in req {
297                if self.supply_left(t, span.start) < need as usize {
298                    return true;
299                }
300            }
301        }
302        if let Some(Some(req)) = self.tables.req_right.get(idx).map(|o| o.as_deref()) {
303            for &(t, need) in req {
304                if self.supply_right(t, span.end) < need as usize {
305                    return true;
306                }
307            }
308        }
309        false
310    }
311
312    #[inline]
313    fn estimate(&self, left: StateId, span: &Span) -> f64 {
314        if self.prunes(left, span) {
315            self.prune
316        } else {
317            self.pass
318        }
319    }
320}
321
322impl IntersectionHeuristic<StringDecompositionAutomaton> for ObligatoryLeafHeuristic<'_> {
323    #[inline]
324    fn outside_estimate(&self, left: StateId, span: &Span) -> f64 {
325        self.estimate(left, span)
326    }
327
328    #[inline]
329    fn admits(&self, left: StateId, span: &Span) -> bool {
330        !self.prunes(left, span)
331    }
332
333    #[inline]
334    fn estimate_if_admitted(&self, left: StateId, span: &Span) -> Option<f64> {
335        (!self.prunes(left, span)).then_some(self.pass)
336    }
337
338    #[inline]
339    fn memoize_admission(&self) -> bool {
340        true
341    }
342
343    #[inline]
344    fn estimate_after_admission(&self, _left: StateId, _span: &Span) -> f64 {
345        self.pass
346    }
347}
348
349impl IntersectionHeuristic<InvHom<'_, StringDecompositionAutomaton>>
350    for ObligatoryLeafHeuristic<'_>
351{
352    #[inline]
353    fn outside_estimate(&self, left: StateId, span: &Span) -> f64 {
354        // InvHom<StringDecompositionAutomaton>::State = Span.
355        self.estimate(left, span)
356    }
357
358    #[inline]
359    fn admits(&self, left: StateId, span: &Span) -> bool {
360        !self.prunes(left, span)
361    }
362
363    #[inline]
364    fn estimate_if_admitted(&self, left: StateId, span: &Span) -> Option<f64> {
365        (!self.prunes(left, span)).then_some(self.pass)
366    }
367
368    #[inline]
369    fn memoize_admission(&self) -> bool {
370        true
371    }
372
373    #[inline]
374    fn estimate_after_admission(&self, _left: StateId, _span: &Span) -> f64 {
375        self.pass
376    }
377}
378
379#[cfg(test)]
380mod tests {
381    use super::*;
382    use crate::{ExplicitBuilder, LogProbabilityScorer, ProbabilityScorer};
383
384    /// Build the Step-1 worked-example grammar + homomorphism through the real
385    /// public APIs, then derive the obligatory-leaf tables via `from_grammar`.
386    ///
387    ///   states S=0, A=1, B=2 ; grammar symbols rS=0, rA=1, rB=2, rA2=3 ;
388    ///   word leaves x=10, y=11, z=12 ; concat=99.
389    ///   S -> *(A, B) ;  A -> "x" ;  B -> "y" ;  A -> "z"
390    fn worked_example() -> (ObligatoryLeafTables, StateId, StateId, StateId) {
391        let mut b = ExplicitBuilder::new();
392        let s = b.new_state();
393        let a = b.new_state();
394        let bb = b.new_state();
395        b.add_rule(Symbol(0), vec![a, bb], s);
396        b.add_rule(Symbol(1), vec![], a);
397        b.add_rule(Symbol(2), vec![], bb);
398        b.add_rule(Symbol(3), vec![], a);
399        b.add_accepting(s);
400        let grammar = b.build();
401
402        let mut hom = Homomorphism::new();
403        let v0 = hom.add_var(0);
404        let v1 = hom.add_var(1);
405        let root = hom.add_symbol(Symbol(99), vec![v0, v1]);
406        hom.add(Symbol(0), 2, root).unwrap();
407        for (gsym, word) in [(1u32, 10u32), (2, 11), (3, 12)] {
408            let leaf = hom.add_symbol(Symbol(word), vec![]);
409            hom.add(Symbol(gsym), 0, leaf).unwrap();
410        }
411
412        let tables = ObligatoryLeafTables::from_grammar(&grammar, &hom);
413        (tables, s, a, bb)
414    }
415
416    #[test]
417    fn from_grammar_matches_worked_example() {
418        let (tables, s, a, bb) = worked_example();
419        // A always has a `y` (11) to its right; nothing forced to its left.
420        assert_eq!(
421            tables.req_right[a.index()].as_deref(),
422            Some(&[(11u32, 1u32)][..])
423        );
424        assert_eq!(tables.req_left[a.index()].as_deref(), Some(&[][..]));
425        // A forces nothing, so B's sides are empty; root has empty obligations.
426        assert_eq!(tables.req_left[bb.index()].as_deref(), Some(&[][..]));
427        assert_eq!(tables.req_right[bb.index()].as_deref(), Some(&[][..]));
428        assert_eq!(tables.req_left[s.index()].as_deref(), Some(&[][..]));
429        assert_eq!(tables.req_right[s.index()].as_deref(), Some(&[][..]));
430    }
431
432    #[test]
433    fn prunes_when_required_leaf_missing_on_side() {
434        let (tables, _s, a, _b) = worked_example();
435        // sentence "x y" = [10, 11], log space (pass = one() = 0.0).
436        let h = tables.for_sentence(&[Symbol(10), Symbol(11)], &LogProbabilityScorer);
437
438        // A over [0,1): a `y` lies to its right -> pass.
439        assert_eq!(
440            IntersectionHeuristic::<StringDecompositionAutomaton>::outside_estimate(
441                &h,
442                a,
443                &Span::new(0, 1)
444            ),
445            0.0
446        );
447        // A over [0,2): nothing to its right -> the required `y` is gone -> prune.
448        assert_eq!(
449            IntersectionHeuristic::<StringDecompositionAutomaton>::outside_estimate(
450                &h,
451                a,
452                &Span::new(0, 2)
453            ),
454            f64::NEG_INFINITY
455        );
456    }
457
458    #[test]
459    fn admits_is_complement_of_prune() {
460        let (tables, _s, a, _b) = worked_example();
461        let h = tables.for_sentence(&[Symbol(10), Symbol(11)], &LogProbabilityScorer);
462        // `admits` must agree with the priority sentinel: admit iff not pruned.
463        for span in [Span::new(0, 1), Span::new(0, 2), Span::new(1, 2)] {
464            let est = IntersectionHeuristic::<StringDecompositionAutomaton>::outside_estimate(
465                &h, a, &span,
466            );
467            let admitted =
468                IntersectionHeuristic::<StringDecompositionAutomaton>::admits(&h, a, &span);
469            assert_eq!(admitted, est != f64::NEG_INFINITY, "span {span:?}");
470        }
471        // A over [0,1): `y` lies right -> admitted; over [0,2): gone -> rejected.
472        assert!(
473            IntersectionHeuristic::<StringDecompositionAutomaton>::admits(&h, a, &Span::new(0, 1))
474        );
475        assert!(
476            !IntersectionHeuristic::<StringDecompositionAutomaton>::admits(&h, a, &Span::new(0, 2))
477        );
478    }
479
480    #[test]
481    fn pass_prune_track_the_scorer() {
482        let (tables, _s, a, _b) = worked_example();
483        // Probability space: pass = one() = 1.0, prune = zero() = 0.0.
484        let h = tables.for_sentence(&[Symbol(10), Symbol(11)], &ProbabilityScorer);
485        assert_eq!(
486            IntersectionHeuristic::<StringDecompositionAutomaton>::outside_estimate(
487                &h,
488                a,
489                &Span::new(0, 1)
490            ),
491            1.0
492        );
493        assert_eq!(
494            IntersectionHeuristic::<StringDecompositionAutomaton>::outside_estimate(
495                &h,
496                a,
497                &Span::new(0, 2)
498            ),
499            0.0
500        );
501    }
502}