tididi_cnf/compile/
mod.rs

1//! The compiler: a CNF formula and a vtree in, a Tree Decision Diagram out.
2//!
3//! [`compile_cnf`] groups the clauses by their scope, the lowest vtree node
4//! whose subtree covers every variable of the clause, and works bottom-up: at
5//! each vtree node it conjoins the children's diagrams, then the clauses scoped
6//! there, a batch at a time, minimizing as it goes. A formula whose clauses
7//! fall into independent components, groups that share no variable, is
8//! compiled one component at a time; [`ComponentPlan`] says whether the
9//! compiler finds them, takes them from the vtree construction, or compiles
10//! the formula whole. [`Output`] says what the compile produces: the Boolean
11//! diagram, or a counting diagram that marginalizes each level once its last
12//! clause is in, weighted or not, with variables forgotten for a projected
13//! count, or the Boolean diagram of such a projection.
14//!
15//! A finished compile returns [`Compiled`]: the diagram, the largest size it
16//! reached, and how many projected variables it left free. It never returns
17//! a partial diagram: a compile that cannot finish says why with [`GiveUp`].
18//!
19//! The run [`count`](crate::count) counts with this same compile, one
20//! component at a time where the formula splits, and retries a count that ran
21//! out of memory on smaller pieces of the formula.
22
23mod batch;
24mod cofactor;
25mod component;
26mod marginalize;
27mod minimize;
28pub(crate) mod sat_prune;
29mod schedule;
30mod scope;
31mod stall;
32mod step;
33
34use std::cell::Cell;
35use std::collections::HashSet;
36use std::sync::Arc;
37use std::time::Instant;
38
39use num_bigint::BigUint;
40use tididi::{Engine, OperationError};
41use tididi::diagram::{Arithmetic, RationalWeights, Tdd, WeightStore, WeightValue};
42use tididi::vtree::{VarId, Vtree};
43use vitri::cnf::CnfFormula;
44
45use crate::Error;
46use crate::budget::{deadline_passed, deadline_scope};
47use crate::problem::WeightedCount;
48use crate::vtree::{Component, ComponentVtree};
49use component::{CompileJob, PassOutput, compile_split, detected_split};
50pub(crate) use component::{check_partition, check_vtree};
51use minimize::minimize_full;
52use step::compile_component;
53
54pub(crate) use stall::{StallRule, StallWatch};
55
56/// Which independent components a compile splits the formula into.
57#[derive(Clone, Copy, Default)]
58pub enum ComponentPlan<'a> {
59    /// Find them; with two or more, compile each on a balanced vtree of its
60    /// own and leave `vtree` unused.
61    #[default]
62    Detect,
63    /// Compile the formula whole, against the supplied root vtree.
64    Whole,
65    /// Compile the components the caller already built trees for, in this order.
66    Split(&'a [ComponentVtree]),
67}
68
69impl<'a> ComponentPlan<'a> {
70    /// Interpret a construction's component trees: a component list is an
71    /// exact split plan, and absence is an explicit whole-formula plan.
72    pub fn from_components(components: Option<&'a [ComponentVtree]>) -> Self {
73        match components {
74            Some(components) => Self::Split(components),
75            None => Self::Whole,
76        }
77    }
78}
79
80/// What a run sets for one compile, beyond what the compile is asked to
81/// produce: the run's SAT pruning, whether the compile is a Shannon
82/// cofactor's, and the per-step wall its internal steps run under.
83#[derive(Clone, Copy, Default)]
84pub(crate) struct CompileRun<'a> {
85    /// The run's SAT pruning, which a compile that runs to its end adds its
86    /// tallies to; `None` prunes nothing.
87    pub(crate) sat_prune: Option<&'a sat_prune::SatPrune>,
88    /// A recovery cofactor's compile, which skips the content-twin scan: the
89    /// cascade recompiles cofactors under memory pressure, and scanning a
90    /// diagram about to be split or dropped adds no useful work.
91    pub(crate) cofactor: bool,
92    /// The per-step wall the compile's internal steps run under, and where it
93    /// reports a step that stopped on it ([`StallWatch`]); `None` for no
94    /// per-step wall. Only the model count's primary compile has one.
95    pub(crate) stall_watch: Option<&'a StallWatch>,
96}
97
98/// Why a compile has no diagram to return.
99#[derive(Clone, PartialEq, Eq, Debug)]
100pub enum GiveUp {
101    /// The compile stopped short at its deadline. A caller with a fallback
102    /// runs it; nothing partial is ever returned, so a count taken from a
103    /// compile that did not give up is exact. The library's
104    /// `OperationError::Stopped` is the same stop, one level down.
105    Stopped,
106    /// The compile stopped short because an apply was refused under the batch
107    /// limits: `limit` is `OverBudget` (memory) or `OutputCap` (size), and
108    /// `refused_bytes` the request the allocator refused, when it was one. A
109    /// caller treats it as [`GiveUp::Stopped`].
110    Refused {
111        /// The limit the apply reached.
112        limit: OperationError,
113        /// The size of the refused allocation, if the allocator refused one.
114        refused_bytes: Option<u64>,
115    },
116    /// An allocation was refused while reducing the finished diagram. The
117    /// binary reports it with the diagnostic [`crate::Error`] carries, which is
118    /// what a benchmark harness reads to classify the run as out of memory.
119    OutOfMemory,
120    /// The caller's input does not fit together: the vtree does not place
121    /// exactly the formula's variables, a [`ComponentPlan::Split`] does not
122    /// partition the formula, or the output's variables or weights do not
123    /// cover the requested formula. The text says which.
124    Invalid(String),
125}
126
127impl GiveUp {
128    /// The one reading of an `OperationError` a compile makes as it gives up:
129    /// a refused apply names its limit and, when the allocator refused it,
130    /// the size, which separates a request no machine could satisfy from a
131    /// machine that is full. `Stopped` is the compile's own wall, or an inner
132    /// budget's expiry riding the same unwind.
133    ///
134    /// # Panics
135    ///
136    /// On any other error: invalid operands are a compiler bug, not a stop.
137    pub(crate) fn from_operation(eng: &Engine, e: OperationError) -> Self {
138        match e {
139            OperationError::OverBudget | OperationError::OutputCap => {
140                GiveUp::Refused { limit: e, refused_bytes: eng.limits().meters().refused_reserve_bytes }
141            }
142            OperationError::Stopped => GiveUp::Stopped,
143            other => panic!("compiler produced invalid operands: {other}"),
144        }
145    }
146
147    /// Say on stderr why the compile stopped, for a refused apply: what a
148    /// benchmark log is read for to tell a memory stop from a deadline. The
149    /// other give-ups print nothing; their callers say what they do next.
150    pub(crate) fn report(&self) {
151        if let GiveUp::Refused { .. } = self {
152            eprintln!("[compile] stopped: {self}");
153        }
154    }
155}
156
157/// A compile whose inputs the crate built, as its caller reads it: a give-up
158/// is reported and becomes `None`, for the caller's fallback.
159///
160/// # Panics
161///
162/// On [`GiveUp::Invalid`]: the crate built inputs that do not fit together.
163pub(crate) fn settle<T>(result: Result<T, GiveUp>) -> Option<T> {
164    match result {
165        Ok(value) => Some(value),
166        Err(GiveUp::Invalid(why)) => panic!("the crate built a compile input that does not fit: {why}"),
167        Err(give_up) => {
168            give_up.report();
169            None
170        }
171    }
172}
173
174impl std::fmt::Display for GiveUp {
175    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
176        match self {
177            GiveUp::Stopped => f.write_str("the compile stopped at its deadline"),
178            GiveUp::Refused { limit, refused_bytes: Some(b) } => write!(
179                f,
180                "the allocator refused {b} bytes ({:.1} GiB) during apply (cap={limit:?})",
181                *b as f64 / (1u64 << 30) as f64,
182            ),
183            GiveUp::Refused { limit, refused_bytes: None } => {
184                write!(f, "apply reached a limit with no recorded allocator refusal (cap={limit:?})")
185            }
186            GiveUp::OutOfMemory => f.write_str("an allocation was refused while reducing the compiled diagram"),
187            GiveUp::Invalid(why) => f.write_str(why),
188        }
189    }
190}
191
192impl std::error::Error for GiveUp {}
193
194/// A give-up as the run reports it: a stop or a refusal ends the run without
195/// a count, and inputs that do not fit fail the compile stage.
196impl From<GiveUp> for Error {
197    fn from(give_up: GiveUp) -> Self {
198        match give_up {
199            GiveUp::Stopped | GiveUp::Refused { .. } => Error::compile_gave_up(),
200            GiveUp::OutOfMemory => Error::out_of_memory(),
201            GiveUp::Invalid(why) => Error::Stage(format!("compile failed: {why}")),
202        }
203    }
204}
205
206/// The heap around one [`release_failed_compile`], and how its purge went:
207/// `Err` carries the mallctl failure.
208pub(crate) struct Released {
209    pub(crate) before: crate::mem::HeapStats,
210    pub(crate) after: crate::mem::HeapStats,
211    pub(crate) purged: Result<(), libc::c_int>,
212}
213
214impl Released {
215    /// The address space the allocator held, `mapped + retained`, before and
216    /// after the purge, in whole MiB: the pair every purge report prints.
217    pub(crate) fn held_mib(&self) -> (u64, u64) {
218        let held = |heap: &crate::mem::HeapStats| (heap.mapped + heap.retained) / crate::MIB;
219        (held(&self.before), held(&self.after))
220    }
221}
222
223/// Give back what a failed compile left behind, before the next compile
224/// starts on the same engine.
225///
226/// The failed compile's diagrams are already dropped. What outlives them is
227/// the engine's retained apply scratch (`Engine::clear_scratch`: the sparse
228/// workspace and level pools the failure left pinned at full capacity), freed
229/// first so the purge can advise its pages away, and the allocator's dirty
230/// pages, which the purge ([`crate::mem::purge_all_arenas`]) returns. The
231/// decay setting is left to the pressure check in `crate::mem`, so the
232/// compiles that follow keep their freed pages for reuse until the footprint
233/// nears the address-space cap. Every compile that follows a failed one on
234/// the same engine runs this first: the model count's fallbacks, its stall
235/// recompile and Shannon split, and the plain compile's retry on a second
236/// vtree.
237pub(crate) fn release_failed_compile(eng: &Engine) -> Released {
238    let before = crate::mem::HeapStats::read();
239    eng.clear_scratch();
240    let purged = crate::mem::purge_all_arenas();
241    Released { before, after: crate::mem::HeapStats::read(), purged }
242}
243
244/// What a compile produces.
245#[derive(Clone, Default)]
246pub enum Output<'a> {
247    /// The formula's Boolean diagram.
248    #[default]
249    Function,
250    /// A counting diagram: each level is marginalized as soon as the last
251    /// clause mentioning its variables lands, so the diagram's `model_count` is
252    /// the formula's and memory is freed as the compile climbs.
253    Count,
254    /// [`Output::Count`] with literal weights: the marginalization carries
255    /// values in `arithmetic` instead of integers, and the result holds the
256    /// store [`tididi::Engine::weighted_value`] reads the weighted count from.
257    WeightedCount {
258        /// The weight of each literal, covering every formula variable.
259        weights: RationalWeights,
260        /// The domain the values are computed in.
261        arithmetic: Arithmetic,
262    },
263    /// [`Output::Count`] of the formula with these variables ∃-forgotten during
264    /// the compile, each at its completion node, so the diagram never
265    /// materializes the projected cone. A forgotten variable is left free and
266    /// counts ×2, so a caller divides the count by `2^`[`Compiled::forgotten`].
267    /// Every leaf level stays Boolean, so a later forget can read its label.
268    ProjectedCount(&'a [VarId]),
269    /// The Boolean diagram of the formula with these variables ∃-forgotten
270    /// during the compile, as [`Output::ProjectedCount`] forgets them, each
271    /// left free. No level is marginalized, so a weighted fold can read the
272    /// whole result.
273    ProjectedFunction(&'a [VarId]),
274}
275
276/// What a whole-formula compile is being asked for.
277///
278/// [`CompileOptions::default`] is the plain Boolean compile with component detection —
279/// `compile_cnf(eng, &formula, &vtree, CompileOptions::default())`.
280#[derive(Default)]
281pub struct CompileOptions<'a> {
282    /// Which components to compile separately, and against which vtrees.
283    pub plan: ComponentPlan<'a>,
284    /// What the compile produces. Every variable it lists must belong to the
285    /// formula (`1..=formula.num_vars()`).
286    pub output: Output<'a>,
287    /// The wall-clock the compile gives up at. When set it is also installed
288    /// as the apply-level cut for this compile, so one giant un-yielding
289    /// conjoin can be stopped mid-apply rather than only at a step boundary.
290    /// `None` leaves any enclosing apply deadline in force.
291    pub deadline: Option<Instant>,
292}
293
294/// What a finished compile returns.
295pub struct Compiled {
296    /// The compiled diagram, on the vtree described at [`compile_cnf`].
297    pub diagram: Tdd,
298    /// How many of the variables a projected [`Output`] forgets are free in
299    /// `diagram`, each doubling its count: those ∃-forgotten, and those in no
300    /// clause, which a component split grafts in free or a clause-free formula
301    /// leaves unconstrained. Zero for a compile that forgets nothing.
302    pub forgotten: usize,
303    /// The largest diagram, in pairs, any step of the compile reached; the
304    /// largest over the components of a split compile, which run one after
305    /// another.
306    pub peak_pairs: usize,
307}
308
309impl Compiled {
310    /// The count `diagram` holds onto the variables a projected [`Output`]
311    /// keeps: its model count with the doubling of each
312    /// [`forgotten`](Self::forgotten) variable divided out. The plain model
313    /// count for a compile that forgets nothing.
314    pub(crate) fn projected_count(&self, eng: &Engine) -> Result<BigUint, OperationError> {
315        Ok(eng.model_count(&self.diagram)? >> self.forgotten)
316    }
317}
318
319/// Compile a CNF formula into a canonical TDD, guided by the vtree.
320///
321/// By default ([`ComponentPlan::Detect`]) a formula whose clauses fall into
322/// independent components, groups that share no variable, is compiled one
323/// component at a time, each on a balanced vtree of its own, and the
324/// components' diagrams are grafted into one; a formula with one component
325/// is compiled on `vtree`. [`CompileOptions::plan`] can instead ask for the
326/// whole formula on `vtree`, or for the caller's own component vtrees.
327///
328/// The result's vtree, the one its diagram carries, is the vtree to build any
329/// further operand on: [`and`](tididi::and) and the other operations refuse
330/// operands on different vtrees. It is `vtree` itself only when the formula was
331/// compiled whole and no rotation fired. A split compile's result is on a vtree
332/// grafted from the components' vtrees, and a counting compile may rotate its
333/// vtree to bring marginal levels together, onto a copy; `vtree` itself is
334/// never changed.
335///
336/// An [`Err`] is a give-up, never a partial diagram, so any count taken from an
337/// `Ok` is exact. A caller that runs this under a deadline it set — an attempt
338/// self-limiting at the wall, a reactive single shot with a fallback — treats
339/// [`GiveUp::Stopped`] as its own loss.
340///
341/// An empty clause makes the result the false diagram, on `vtree`.
342///
343/// # Errors
344///
345/// - [`GiveUp::Stopped`]: [`CompileOptions::deadline`] passed.
346/// - [`GiveUp::Refused`]: an apply was refused under the limits of the batch
347///   `eng` belongs to.
348/// - [`GiveUp::OutOfMemory`]: an allocation was refused while reducing the
349///   finished diagram.
350/// - [`GiveUp::Invalid`]: `vtree`, or a component's vtree, does not place
351///   exactly its formula's variables, or a [`ComponentPlan::Split`] does not
352///   partition the formula; a projected variable is outside the formula, or
353///   a literal weight is missing.
354pub fn compile_cnf(
355    eng: &Engine,
356    formula: &CnfFormula,
357    vtree: &Arc<Vtree>,
358    opts: CompileOptions<'_>,
359) -> Result<Compiled, GiveUp> {
360    let sat_prune = sat_prune::SatPrune::default();
361    compile_with(eng, formula, vtree, opts, CompileRun { sat_prune: Some(&sat_prune), ..CompileRun::default() })
362}
363
364/// [`compile_cnf`] under what the run sets for it (`run`).
365pub(crate) fn compile_with(
366    eng: &Engine,
367    formula: &CnfFormula,
368    vtree: &Arc<Vtree>,
369    opts: CompileOptions<'_>,
370    run: CompileRun<'_>,
371) -> Result<Compiled, GiveUp> {
372    let CompileOptions { plan, output, deadline } = opts;
373    // The apply-level deadline, whose Drop restores the previous value on every
374    // return and panic path.
375    let _apply_deadline = deadline_scope(eng, deadline);
376
377    component::check_vtree(formula.num_vars(), vtree)?;
378    if let ComponentPlan::Split(components) = plan {
379        component::check_partition(formula, components)?;
380    }
381
382    let weights;
383    let forget: HashSet<VarId>;
384    let output = match output {
385        Output::Function => PassOutput::Function,
386        Output::Count => PassOutput::Count(None),
387        Output::WeightedCount { weights: algebra, arithmetic } => {
388            // Literal tables cover a contiguous range starting at one. Ask
389            // the library to validate its last required entry before either
390            // a constant shortcut or a component can read the table.
391            if formula.num_vars() > 0 {
392                algebra.restated(&[VarId(formula.num_vars())])
393                    .map_err(|error| GiveUp::Invalid(error.to_string()))?;
394            }
395            weights = WeightStore::new(algebra, arithmetic);
396            PassOutput::Count(Some(&weights))
397        }
398        Output::ProjectedCount(vars) => {
399            forget = formula_vars(formula, vars)?;
400            PassOutput::ProjectedCount(&forget)
401        }
402        Output::ProjectedFunction(vars) => {
403            forget = formula_vars(formula, vars)?;
404            PassOutput::ProjectedFunction(&forget)
405        }
406    };
407    let prune_spent_us = Cell::new(0);
408    let job = CompileJob { deadline, output, run, prune_spent_us: &prune_spent_us };
409    eng.limits().poll_host_work(0).map_err(|error| GiveUp::from_operation(eng, error))?;
410
411    if formula.clauses().is_empty() {
412        let diagram = output.with_store(eng.one(vtree));
413        // Every variable is free, ×2 in the raw count, a projected one included.
414        let forgotten = output.forget().map_or(0, |forget| {
415            (1..=vtree.num_vars()).filter(|&v| forget.contains(&VarId(v))).count()
416        });
417        return Ok(Compiled { diagram, forgotten, peak_pairs: 0 });
418    }
419    // The empty clause is the constant false, which the whole-formula pass
420    // answers at once: there is nothing to split into components.
421    let plan = if formula.clauses().iter().any(|c| c.literals.is_empty()) { ComponentPlan::Whole } else { plan };
422
423    // Two compilation paths:
424    //  1. A split — the caller's per-component vtrees, or detected components
425    //     on balanced ones: compile each component on its own vtree (a
426    //     marginal-cluster rotation stays confined to that vtree Arc), then
427    //     *graft* the per-component TDDs into a single TDD on a freshly built
428    //     grafted vtree.
429    //  2. The whole formula (asked for, or a detected single component):
430    //     compile on the caller's supplied root and minimize.
431    match plan {
432        ComponentPlan::Split(component_vtrees) => {
433            return compile_split(eng, formula, component_vtrees, job);
434        }
435        ComponentPlan::Detect => {
436            if let Some(detected) = formula.detect_components() {
437                return compile_split(eng, formula, &detected_split(eng, formula, detected), job);
438            }
439        }
440        ComponentPlan::Whole => {}
441    }
442
443    // Whole-formula compile.
444    let mut result = compile_component(eng, formula, vtree, job)?;
445    minimize_full(eng, &mut result.diagram, job.run.cofactor).map_err(|_| GiveUp::OutOfMemory)?;
446    Ok(result)
447}
448
449/// The projected variables as a set, refusing ids outside the formula.
450fn formula_vars(formula: &CnfFormula, vars: &[VarId]) -> Result<HashSet<VarId>, GiveUp> {
451    vars.iter().map(|&var| {
452        if var.0 == 0 || var.0 > formula.num_vars() {
453            Err(GiveUp::Invalid(format!(
454                "projected variable {} is outside the formula's 1..={} variables", var.0, formula.num_vars(),
455            )))
456        } else {
457            Ok(var)
458        }
459    }).collect()
460}
461
462/// The weighted count of `formula` under `algebra`, computed in
463/// `arithmetic`: the [`Output::WeightedCount`] compile, read at its root. It
464/// marginalizes as it climbs, so it frees memory as the model count does.
465///
466/// The verdict is the diagram's: a weighted compile stores no unsatisfiable
467/// node, so the formula is unsatisfiable exactly when the result is the false
468/// constant, whatever its weights.
469///
470/// # Returns
471///
472/// `None` when the compile gives up, at `deadline` or refused under the batch
473/// limits (a refusal is reported on stderr), when the readout is refused, or
474/// when the result has no weighted value.
475pub(crate) fn weighted_count(
476    eng: &Engine,
477    formula: &CnfFormula,
478    vtree: &Arc<Vtree>,
479    components: ComponentPlan<'_>,
480    algebra: RationalWeights,
481    arithmetic: Arithmetic,
482    deadline: Option<Instant>,
483) -> Option<WeightedCount<WeightValue>> {
484    let tdd = settle(compile_cnf(
485        eng,
486        formula,
487        vtree,
488        CompileOptions {
489            plan: components,
490            output: Output::WeightedCount { weights: algebra, arithmetic },
491            deadline,
492        },
493    ))?
494    .diagram;
495    if tdd.is_zero() {
496        return Some(WeightedCount::Unsatisfiable);
497    }
498    eng.weighted_value(&tdd).ok().flatten().map(WeightedCount::Satisfiable)
499}
500
501/// The model count of `formula`, one of `components` at a time: each
502/// component's clauses are renumbered, `build_sub_vtree` builds its vtree,
503/// and its count multiplies into the product before its diagram is dropped,
504/// so the memory the count needs is the largest component's, not the sum.
505/// Each variable in no component doubles the product.
506///
507/// `forget` makes it the count [`Output::ProjectedCount`] gives, onto the
508/// other variables: each component's count is divided by its own forgotten
509/// variables before it enters the product, so the result needs no correction.
510///
511/// `deadline` is installed as the apply-level cut, as [`compile_cnf`] does. It
512/// does not bound `build_sub_vtree`, which runs no library operation. `run`
513/// is what the run sets for each component's compile.
514///
515/// # Returns
516///
517/// The count and the largest diagram size any component's compile reached, in
518/// pairs; or `None` when `build_sub_vtree` built no vtree for a component, or
519/// a component's compile gave up, at `deadline` or refused under the batch
520/// limits (a refusal is reported on stderr).
521pub(crate) fn model_count_streaming<F>(
522    eng: &Engine,
523    formula: &CnfFormula,
524    components: &[Component],
525    mut build_sub_vtree: F,
526    forget: Option<&[VarId]>,
527    deadline: Option<Instant>,
528    run: CompileRun<'_>,
529) -> Option<(BigUint, usize)>
530where
531    F: FnMut(&CnfFormula) -> Option<Arc<Vtree>>,
532{
533    let forget = settle(forget.map(|vars| formula_vars(formula, vars)).transpose())?;
534
535    if formula.clauses().is_empty() {
536        return Some((
537            BigUint::from(1u32) << component::streaming_free_var_count(formula, &[], forget.as_ref()),
538            0,
539        ));
540    }
541
542    let scratch = &mut component::RemapScratch::default();
543    // Restored when the count returns, on every path.
544    let _apply_deadline_guard = deadline_scope(eng, deadline);
545
546    let prune_spent_us = Cell::new(0);
547    let mut total = BigUint::from(1u32);
548    let mut peak = 0usize;
549    for comp in components {
550        if deadline_passed(deadline) {
551            return None;
552        }
553        component::remap_component_clauses_into(
554            scratch,
555            formula,
556            &comp.clause_indices,
557            &comp.local_to_global,
558        );
559        let sub_formula = scratch.sub();
560        let sub_vtree = build_sub_vtree(sub_formula)?;
561        settle(component::check_vtree(sub_formula.num_vars(), &sub_vtree))?;
562        // The projected variables, in this component's local numbering.
563        let comp_forget =
564            forget.as_ref().map(|forget| component::local_vars_in(&comp.local_to_global, forget));
565        let (sub_count, sub_peak) = {
566            let output = comp_forget.as_ref().map_or(PassOutput::Count(None), PassOutput::ProjectedCount);
567            let job = CompileJob { deadline, output, run, prune_spent_us: &prune_spent_us };
568            let compiled = settle(compile_component(eng, sub_formula, &sub_vtree, job))?;
569            (compiled.projected_count(eng).ok()?, compiled.peak_pairs)
570        };
571        peak = peak.max(sub_peak);
572        // A zero component zeroes the product: skip the rest of the loop.
573        if sub_count == BigUint::ZERO {
574            return Some((sub_count, peak));
575        }
576        total *= sub_count;
577    }
578    total <<= component::streaming_free_var_count(formula, components, forget.as_ref());
579    Some((total, peak))
580}
581
582#[cfg(test)]
583mod tests;