Skip to main content

rusty_alto/
heuristic.rs

1//! Admissible heuristics for the A* intersection materializer.
2//!
3//! An [`IntersectionHeuristic`] provides an optimistic upper bound on the
4//! outside weight of a product state `(left, right)`. When the bound is tight
5//! (equal to the true outside weight) A* reduces to exact Knuth/Viterbi order.
6//!
7//! Two concrete heuristics are provided:
8//!
9//! * [`ZeroHeuristic`] — the uninformed bound `1.0`.  Always admissible
10//!   because all weights are in `(0, 1]`.  With this heuristic A* equals pure
11//!   Knuth and is exact.
12//!
13//! * [`OutsideHeuristic`] — precomputes grammar-only outside weights `OUT(X)`
14//!   from a fixed-point iteration over the left-hand (grammar) automaton. This
15//!   is algebra-agnostic and sentence-independent, so it can be computed once
16//!   per grammar and reused across inputs.  Because the decomposition automaton
17//!   is unweighted, the true product-outside of `(X, ·)` is at most the
18//!   grammar `OUT(X)`, so the bound is admissible.  Both fixpoints are exact
19//!   (Dijkstra/Knuth-style), so the heuristic is also consistent.
20
21use crate::{BottomUpTa, Explicit, ProbabilityScorer, StateId, TopDownTa, WeightScorer};
22use fixedbitset::FixedBitSet;
23use std::collections::BinaryHeap;
24
25// ---------------------------------------------------------------------------
26// f64 max-ordering newtype
27// ---------------------------------------------------------------------------
28
29/// Wrapper that gives `f64` a total ordering suitable for a max-heap.
30#[derive(Clone, Copy, PartialEq)]
31struct OrdF64(f64);
32
33impl Eq for OrdF64 {}
34
35impl Ord for OrdF64 {
36    fn cmp(&self, o: &Self) -> std::cmp::Ordering {
37        self.0.total_cmp(&o.0)
38    }
39}
40
41impl PartialOrd for OrdF64 {
42    fn partial_cmp(&self, o: &Self) -> Option<std::cmp::Ordering> {
43        Some(self.cmp(o))
44    }
45}
46
47// ---------------------------------------------------------------------------
48// Trait
49// ---------------------------------------------------------------------------
50
51/// Admissible upper bound on the outside weight of a product state.
52///
53/// For every product state `(left, right)` the method must return a value that
54/// is **at least** as large as the true outside weight.  Tighter bounds yield
55/// fewer A* expansions.  A bound of `1.0` is always safe (all weights ≤ 1).
56pub trait IntersectionHeuristic<R: BottomUpTa> {
57    /// Return an optimistic (admissible) upper bound in `(0, 1]` on the best
58    /// outside weight of any completion around product state `(left, right)`.
59    fn outside_estimate(&self, left: StateId, right: &R::State) -> f64;
60
61    /// Sound hard filter consulted at candidate-construction time: return
62    /// `false` iff `(left, right)` provably has **zero** outside weight (it can
63    /// appear in no valid completion), so the A* loop may skip building the edge
64    /// entirely rather than merely deprioritizing it.
65    ///
66    /// This must be sound — only return `false` when the true outside weight is
67    /// genuinely zero — but it need not be complete (returning `true` is always
68    /// safe). The default admits everything, so heuristics that are pure
69    /// admissible bounds (SX, Outside, Zero) impose no filtering.
70    #[inline]
71    fn admits(&self, _left: StateId, _right: &R::State) -> bool {
72        true
73    }
74
75    /// Return the outside estimate when the product is admitted, and `None`
76    /// when it is provably unable to occur in an accepting derivation.
77    ///
78    /// Heuristics whose filtering and estimate share work should override this
79    /// method. The A* hot path uses it to avoid evaluating the heuristic twice.
80    #[inline]
81    fn estimate_if_admitted(&self, left: StateId, right: &R::State) -> Option<f64> {
82        self.admits(left, right)
83            .then(|| self.outside_estimate(left, right))
84    }
85
86    /// Whether A* should memoize the admission decision by product pair.
87    ///
88    /// The default is `false`: simple table lookups such as zero, outside, and
89    /// SX are cheaper than maintaining a cache. Heuristics whose sound filter
90    /// performs non-trivial repeated work should opt in.
91    #[inline]
92    fn memoize_admission(&self) -> bool {
93        false
94    }
95
96    /// Return the outside estimate when admission for this pair is already
97    /// known to succeed.
98    ///
99    /// Filtering heuristics that opt into admission memoization should override
100    /// this when their ordinary outside estimate repeats the filter work.
101    #[inline]
102    fn estimate_after_admission(&self, left: StateId, right: &R::State) -> f64 {
103        self.outside_estimate(left, right)
104    }
105}
106
107// ---------------------------------------------------------------------------
108// MinHeuristic (combinator)
109// ---------------------------------------------------------------------------
110
111/// Combines two admissible heuristics by taking, per product state, the
112/// tighter (smaller) of the two estimates.
113///
114/// The minimum of two admissible upper bounds is itself an admissible upper
115/// bound (and at least as tight), so A* stays exact. The numeric `min` is
116/// correct in both probability space (estimates in `(0, 1]`, smaller = tighter)
117/// and log-prob space (estimates `≤ 0`, smaller = tighter), since the scorer
118/// representation is monotone in the true weight.
119pub struct MinHeuristic<A, B> {
120    a: A,
121    b: B,
122}
123
124impl<A, B> MinHeuristic<A, B> {
125    /// Combine heuristics `a` and `b`; `outside_estimate` returns `min(a, b)`.
126    pub fn new(a: A, b: B) -> Self {
127        Self { a, b }
128    }
129}
130
131impl<R, A, B> IntersectionHeuristic<R> for MinHeuristic<A, B>
132where
133    R: BottomUpTa,
134    A: IntersectionHeuristic<R>,
135    B: IntersectionHeuristic<R>,
136{
137    #[inline]
138    fn outside_estimate(&self, left: StateId, right: &R::State) -> f64 {
139        self.a
140            .outside_estimate(left, right)
141            .min(self.b.outside_estimate(left, right))
142    }
143
144    /// An item is admitted only if **both** sub-heuristics admit it (either
145    /// proving zero outside weight suffices to drop the edge).
146    #[inline]
147    fn admits(&self, left: StateId, right: &R::State) -> bool {
148        self.a.admits(left, right) && self.b.admits(left, right)
149    }
150
151    #[inline]
152    fn estimate_if_admitted(&self, left: StateId, right: &R::State) -> Option<f64> {
153        let a = self.a.estimate_if_admitted(left, right)?;
154        let b = self.b.estimate_if_admitted(left, right)?;
155        Some(a.min(b))
156    }
157
158    #[inline]
159    fn memoize_admission(&self) -> bool {
160        self.a.memoize_admission() || self.b.memoize_admission()
161    }
162
163    #[inline]
164    fn estimate_after_admission(&self, left: StateId, right: &R::State) -> f64 {
165        self.a
166            .estimate_after_admission(left, right)
167            .min(self.b.estimate_after_admission(left, right))
168    }
169}
170
171// ---------------------------------------------------------------------------
172// ZeroHeuristic (baseline)
173// ---------------------------------------------------------------------------
174
175/// Uninformed heuristic that always returns `1.0`.
176///
177/// This is admissible because all weights live in `(0, 1]`. With this
178/// heuristic A* degenerates to pure Knuth order and is exact.
179pub struct ZeroHeuristic;
180
181impl<R: BottomUpTa> IntersectionHeuristic<R> for ZeroHeuristic {
182    #[inline]
183    fn outside_estimate(&self, _left: StateId, _right: &R::State) -> f64 {
184        1.0
185    }
186}
187
188/// Uninformed heuristic in an arbitrary scorer representation.
189pub struct ScoredZeroHeuristic {
190    one: f64,
191}
192
193impl ScoredZeroHeuristic {
194    /// Construct the constant-one heuristic in `scorer`'s representation.
195    pub fn new<S: WeightScorer>(scorer: &S) -> Self {
196        Self { one: scorer.one() }
197    }
198}
199
200impl<R: BottomUpTa> IntersectionHeuristic<R> for ScoredZeroHeuristic {
201    #[inline]
202    fn outside_estimate(&self, _left: StateId, _right: &R::State) -> f64 {
203        self.one
204    }
205}
206
207// ---------------------------------------------------------------------------
208// OutsideHeuristic
209// ---------------------------------------------------------------------------
210
211/// Grammar-only, algebra-agnostic outside-weight heuristic.
212///
213/// Precomputes `OUT(X)` for every grammar state `X` via two max-product
214/// fixed-point passes over the grammar automaton, then uses that as an upper
215/// bound for the corresponding product-outside weight.
216///
217/// # Admissibility
218///
219/// The decomposition automaton is unweighted, so all weight is carried by the
220/// grammar.  Therefore the true outside weight of any product state `(X, ·)` is
221/// at most `OUT(X)`.
222///
223/// # Consistency
224///
225/// Both fixpoints are Dijkstra/Knuth-style: each state is finalized at most
226/// once with its optimal value.  The resulting heuristic is therefore
227/// consistent (monotone), which guarantees that A* never re-expands a node.
228pub struct OutsideHeuristic {
229    out: Vec<f64>,
230    zero: f64,
231}
232
233impl OutsideHeuristic {
234    /// Compute grammar outside weights from `grammar`.
235    ///
236    /// Runs two max-product fixpoints:
237    ///
238    /// 1. **IN(X)** (Knuth bottom-up): seed nullary rules; finalize each state
239    ///    exactly once; propagate through rules whose other children are
240    ///    already finalized.
241    ///
242    /// 2. **OUT(X)** (top-down, needs IN): seed accepting states with `1.0`;
243    ///    finalize each state exactly once; for each rule whose result is the
244    ///    current state, push a new outside estimate for every child.
245    pub fn from_grammar(grammar: &Explicit) -> Self {
246        Self::from_grammar_with(grammar, &ProbabilityScorer)
247    }
248
249    /// Compute grammar outside scores from `grammar` with `scorer`.
250    pub fn from_grammar_with<S: WeightScorer>(grammar: &Explicit, scorer: &S) -> Self {
251        let n = grammar.num_states() as usize;
252
253        // ------------------------------------------------------------------
254        // Pass 1: IN(X) — max-product inside weights
255        // ------------------------------------------------------------------
256
257        let mut inside = vec![scorer.zero(); n];
258        let mut fin_in = FixedBitSet::with_capacity(n);
259
260        // Index: for each state, which rules have it as a child?
261        // We store (rule_index) lists per child state.
262        let rules: Vec<_> = grammar.rules().collect();
263        let mut by_child: Vec<Vec<usize>> = vec![Vec::new(); n];
264        for (idx, rule) in rules.iter().enumerate() {
265            // Deduplicate child appearances so we don't double-count.
266            let mut seen_in_rule = FixedBitSet::with_capacity(n);
267            for &child in rule.children {
268                if !seen_in_rule.contains(child.index()) {
269                    seen_in_rule.set(child.index(), true);
270                    by_child[child.index()].push(idx);
271                }
272            }
273        }
274
275        // Per-rule pending count (number of un-finalized distinct children)
276        // and running partial product (weight × prod of finalized children).
277        let mut pending: Vec<usize> = rules
278            .iter()
279            .map(|r| {
280                let mut seen = FixedBitSet::with_capacity(n);
281                r.children
282                    .iter()
283                    .filter(|&&c| {
284                        if seen.contains(c.index()) {
285                            false
286                        } else {
287                            seen.set(c.index(), true);
288                            true
289                        }
290                    })
291                    .count()
292            })
293            .collect();
294        let mut partial: Vec<f64> = rules.iter().map(|r| scorer.rule_score(r.weight)).collect();
295
296        // Heap entries: (OrdF64(weight), state_index)
297        let mut heap: BinaryHeap<(OrdF64, u32)> = BinaryHeap::new();
298
299        // Seed nullary rules.
300        for (idx, rule) in rules.iter().enumerate() {
301            if rule.children.is_empty() {
302                let ri = rule.result.index();
303                let rule_score = scorer.rule_score(rule.weight);
304                if scorer.better(rule_score, inside[ri]) {
305                    inside[ri] = rule_score;
306                    heap.push((OrdF64(rule_score), ri as u32));
307                }
308                // A nullary rule contributes its weight as the full partial product.
309                // Mark it as having zero pending children (it's immediately fireable).
310                // We still want it in the heap; the result was already pushed above.
311                // partial[idx] is already rule.weight; pending[idx] is 0.
312                let _ = idx; // suppress unused warning
313            }
314        }
315
316        while let Some((OrdF64(w), si)) = heap.pop() {
317            let si = si as usize;
318            if fin_in.contains(si) {
319                continue;
320            }
321            // Stale-entry check: ignore if a better value was already pushed.
322            if w != inside[si] {
323                continue;
324            }
325            fin_in.set(si, true);
326
327            for &rule_idx in &by_child[si] {
328                let rule = &rules[rule_idx];
329                // Update partial product for this rule.
330                // Each unique child appears once in by_child[si], so we
331                // multiply in IN[state] once.
332                partial[rule_idx] = scorer.times(partial[rule_idx], inside[si]);
333                pending[rule_idx] -= 1;
334
335                if pending[rule_idx] == 0 {
336                    // All children finalized: try to update result's inside weight.
337                    let ri = rule.result.index();
338                    let cand = partial[rule_idx];
339                    if scorer.better(cand, inside[ri]) {
340                        inside[ri] = cand;
341                        heap.push((OrdF64(cand), ri as u32));
342                    }
343                }
344            }
345        }
346
347        // ------------------------------------------------------------------
348        // Pass 2: OUT(X) — max-product outside weights
349        // ------------------------------------------------------------------
350
351        let mut outside = vec![scorer.zero(); n];
352        let mut fin_out = FixedBitSet::with_capacity(n);
353        let mut out_heap: BinaryHeap<(OrdF64, u32)> = BinaryHeap::new();
354
355        // Seed: accepting states get OUT = 1.0.
356        grammar.initial_states(&mut |state| {
357            if !state.is_stuck() && state.index() < n {
358                let si = state.index();
359                let one = scorer.one();
360                if scorer.better(one, outside[si]) {
361                    outside[si] = one;
362                    out_heap.push((OrdF64(one), si as u32));
363                }
364            }
365        });
366
367        while let Some((OrdF64(w), si)) = out_heap.pop() {
368            let si = si as usize;
369            if fin_out.contains(si) {
370                continue;
371            }
372            // Stale-entry check.
373            if w != outside[si] {
374                continue;
375            }
376            fin_out.set(si, true);
377
378            let state = StateId(si as u32);
379            // For each rule whose result is `state`, push new outside estimates
380            // for each child.
381            for rule in grammar.rules_topdown(state) {
382                if rule.children.is_empty() {
383                    continue;
384                }
385                let nc = rule.children.len();
386                // Compute prefix and suffix products of IN values.
387                let mut prefix = vec![scorer.one(); nc + 1];
388                for i in 0..nc {
389                    prefix[i + 1] = scorer.times(prefix[i], inside[rule.children[i].index()]);
390                }
391                let mut suffix = vec![scorer.one(); nc + 1];
392                for i in (0..nc).rev() {
393                    suffix[i] = scorer.times(suffix[i + 1], inside[rule.children[i].index()]);
394                }
395
396                for p in 0..nc {
397                    let child_p = rule.children[p];
398                    if child_p.is_stuck() {
399                        continue;
400                    }
401                    let ci = child_p.index();
402                    if fin_out.contains(ci) {
403                        continue;
404                    }
405                    // OUT[child_p] >= OUT[state] * rule.weight * prod_{q != p} IN[child_q]
406                    let sibling_product = scorer.times(prefix[p], suffix[p + 1]);
407                    let new_out = scorer.times(
408                        scorer.times(w, scorer.rule_score(rule.weight)),
409                        sibling_product,
410                    );
411                    if scorer.better(new_out, outside[ci]) {
412                        outside[ci] = new_out;
413                        out_heap.push((OrdF64(new_out), ci as u32));
414                    }
415                }
416            }
417        }
418
419        OutsideHeuristic {
420            out: outside,
421            zero: scorer.zero(),
422        }
423    }
424}
425
426impl<R: BottomUpTa> IntersectionHeuristic<R> for OutsideHeuristic {
427    #[inline]
428    fn outside_estimate(&self, left: StateId, _right: &R::State) -> f64 {
429        self.out.get(left.index()).copied().unwrap_or(self.zero)
430    }
431}
432
433// ---------------------------------------------------------------------------
434// Tests
435// ---------------------------------------------------------------------------
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use crate::{Explicit, ExplicitBuilder, Symbol};
441
442    /// Build a small grammar and verify IN and OUT weights.
443    ///
444    /// Grammar:
445    ///   a() -> s0   w=0.5
446    ///   a() -> s2   w=0.3   (s2 is non-productive: never reaches s4)
447    ///   f(s0) -> s1 w=0.8
448    ///   g(s1) -> s4 w=0.9
449    ///
450    /// States: s0, s1, s2, s3, s4
451    /// Accepting: s4
452    /// s3 has no rules (unreachable from both bottom-up and top-down).
453    ///
454    /// Expected IN:
455    ///   IN[s0] = 0.5
456    ///   IN[s1] = 0.5 * 0.8 = 0.40
457    ///   IN[s2] = 0.3
458    ///   IN[s3] = 0.0  (no rule fires)
459    ///   IN[s4] = 0.4 * 0.9 = 0.36
460    ///
461    /// Expected OUT:
462    ///   OUT[s4] = 1.0  (accepting seed)
463    ///   OUT[s1] = OUT[s4] * 0.9 = 0.9
464    ///   OUT[s0] = OUT[s1] * 0.8 = 0.72
465    ///   OUT[s2] = 0.0  (no path from s2 reaches s4)
466    ///   OUT[s3] = 0.0  (unreachable from top)
467    fn build_grammar() -> (Explicit, [StateId; 5]) {
468        let mut b = ExplicitBuilder::new();
469        let s0 = b.new_state(); // index 0
470        let s1 = b.new_state(); // index 1
471        let s2 = b.new_state(); // index 2
472        let s3 = b.new_state(); // index 3  — isolated
473        let s4 = b.new_state(); // index 4  — accepting
474
475        let a = Symbol(0);
476        let f = Symbol(1);
477        let g = Symbol(2);
478
479        b.add_weighted_rule(a, vec![], s0, 0.5); // a() -> s0, w=0.5
480        b.add_weighted_rule(a, vec![], s2, 0.3); // a() -> s2, w=0.3
481        b.add_weighted_rule(f, vec![s0], s1, 0.8); // f(s0) -> s1, w=0.8
482        b.add_weighted_rule(g, vec![s1], s4, 0.9); // g(s1) -> s4, w=0.9
483
484        b.add_accepting(s4);
485
486        let grammar = b.build();
487        (grammar, [s0, s1, s2, s3, s4])
488    }
489
490    #[test]
491    fn inside_weights_are_correct() {
492        let (grammar, [s0, s1, s2, s3, s4]) = build_grammar();
493        let h = OutsideHeuristic::from_grammar(&grammar);
494
495        // We inspect the inside vector through the OutsideHeuristic struct
496        // by using a grammar with only one accepting state and checking
497        // outside values (indirect).  For a direct check we re-run
498        // from_grammar but also expose a small helper here.
499
500        // Actually compute inside separately using the same logic.
501        let inside = compute_inside(&grammar);
502
503        assert!(
504            (inside[s0.index()] - 0.5).abs() < 1e-10,
505            "IN[s0] = {}",
506            inside[s0.index()]
507        );
508        assert!(
509            (inside[s1.index()] - 0.4).abs() < 1e-10,
510            "IN[s1] = {}",
511            inside[s1.index()]
512        );
513        assert!(
514            (inside[s2.index()] - 0.3).abs() < 1e-10,
515            "IN[s2] = {}",
516            inside[s2.index()]
517        );
518        assert!(
519            inside[s3.index()] == 0.0,
520            "IN[s3] should be 0, got {}",
521            inside[s3.index()]
522        );
523        assert!(
524            (inside[s4.index()] - 0.36).abs() < 1e-10,
525            "IN[s4] = {}",
526            inside[s4.index()]
527        );
528
529        // Suppress unused variable warning for h.
530        let _ = h;
531    }
532
533    #[test]
534    fn outside_weights_are_correct() {
535        let (grammar, [s0, s1, s2, s3, s4]) = build_grammar();
536        let h = OutsideHeuristic::from_grammar(&grammar);
537
538        assert!(
539            (h.out[s4.index()] - 1.0).abs() < 1e-10,
540            "OUT[s4] = {}",
541            h.out[s4.index()]
542        );
543        assert!(
544            (h.out[s1.index()] - 0.9).abs() < 1e-10,
545            "OUT[s1] = {}",
546            h.out[s1.index()]
547        );
548        assert!(
549            (h.out[s0.index()] - 0.72).abs() < 1e-10,
550            "OUT[s0] = {}",
551            h.out[s0.index()]
552        );
553        assert!(
554            h.out[s2.index()] == 0.0,
555            "OUT[s2] should be 0, got {}",
556            h.out[s2.index()]
557        );
558        assert!(
559            h.out[s3.index()] == 0.0,
560            "OUT[s3] should be 0, got {}",
561            h.out[s3.index()]
562        );
563    }
564
565    #[test]
566    fn outside_estimate_returns_out_value() {
567        let (grammar, [s0, _s1, _s2, _s3, s4]) = build_grammar();
568        let h = OutsideHeuristic::from_grammar(&grammar);
569
570        // Test via the trait method (using Explicit as R since it implements BottomUpTa).
571        let dummy_right = StateId(0);
572        let est_s0: f64 = <OutsideHeuristic as IntersectionHeuristic<Explicit>>::outside_estimate(
573            &h,
574            s0,
575            &dummy_right,
576        );
577        let est_s4: f64 = <OutsideHeuristic as IntersectionHeuristic<Explicit>>::outside_estimate(
578            &h,
579            s4,
580            &dummy_right,
581        );
582
583        assert!((est_s0 - 0.72).abs() < 1e-10, "estimate for s0 = {est_s0}");
584        assert!((est_s4 - 1.0).abs() < 1e-10, "estimate for s4 = {est_s4}");
585    }
586
587    #[test]
588    fn zero_heuristic_always_returns_one() {
589        let h = ZeroHeuristic;
590        let dummy: StateId = StateId(0);
591        let est: f64 =
592            <ZeroHeuristic as IntersectionHeuristic<Explicit>>::outside_estimate(&h, dummy, &dummy);
593        assert_eq!(est, 1.0);
594    }
595
596    #[test]
597    fn outside_estimate_out_of_range_returns_zero() {
598        let (grammar, _states) = build_grammar();
599        let h = OutsideHeuristic::from_grammar(&grammar);
600        // StateId with index well beyond num_states should return 0.
601        let far = StateId(9999);
602        let dummy_right = StateId(0);
603        let est: f64 = <OutsideHeuristic as IntersectionHeuristic<Explicit>>::outside_estimate(
604            &h,
605            far,
606            &dummy_right,
607        );
608        assert_eq!(est, 0.0);
609    }
610
611    // ------------------------------------------------------------------
612    // Helper: re-implement only the IN pass so we can inspect it directly
613    // without exposing the field.
614    // ------------------------------------------------------------------
615    fn compute_inside(grammar: &Explicit) -> Vec<f64> {
616        let n = grammar.num_states() as usize;
617        let mut inside = vec![0.0f64; n];
618        let mut fin_in = FixedBitSet::with_capacity(n);
619
620        let rules: Vec<_> = grammar.rules().collect();
621        let mut by_child: Vec<Vec<usize>> = vec![Vec::new(); n];
622        for (idx, rule) in rules.iter().enumerate() {
623            let mut seen = FixedBitSet::with_capacity(n);
624            for &child in rule.children {
625                if !seen.contains(child.index()) {
626                    seen.set(child.index(), true);
627                    by_child[child.index()].push(idx);
628                }
629            }
630        }
631
632        let mut pending: Vec<usize> = rules
633            .iter()
634            .map(|r| {
635                let mut seen = FixedBitSet::with_capacity(n);
636                r.children
637                    .iter()
638                    .filter(|&&c| {
639                        if seen.contains(c.index()) {
640                            false
641                        } else {
642                            seen.set(c.index(), true);
643                            true
644                        }
645                    })
646                    .count()
647            })
648            .collect();
649        let mut partial: Vec<f64> = rules.iter().map(|r| r.weight).collect();
650
651        let mut heap: BinaryHeap<(OrdF64, u32)> = BinaryHeap::new();
652        for rule in rules.iter() {
653            if rule.children.is_empty() {
654                let ri = rule.result.index();
655                if rule.weight > inside[ri] {
656                    inside[ri] = rule.weight;
657                    heap.push((OrdF64(rule.weight), ri as u32));
658                }
659            }
660        }
661
662        while let Some((OrdF64(w), si)) = heap.pop() {
663            let si = si as usize;
664            if fin_in.contains(si) {
665                continue;
666            }
667            if w < inside[si] - 1e-15 * inside[si].max(1e-15) {
668                continue;
669            }
670            fin_in.set(si, true);
671
672            for &rule_idx in &by_child[si] {
673                partial[rule_idx] *= inside[si];
674                pending[rule_idx] -= 1;
675                if pending[rule_idx] == 0 {
676                    let ri = rules[rule_idx].result.index();
677                    let cand = partial[rule_idx];
678                    if cand > inside[ri] {
679                        inside[ri] = cand;
680                        heap.push((OrdF64(cand), ri as u32));
681                    }
682                }
683            }
684        }
685
686        inside
687    }
688}