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}