vitri 0.2.0

CNF preprocessing and vtree construction (variable trees) for circuit compilation and model counting: preprocesses a DIMACS CNF, records the arithmetic to lift a model count back to the original, and builds a good vtree for it — for any d-DNNF/SDD/TDD compiler, or any model counter that takes a vtree.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
//! The portfolio catalog: what can be built, and under what
//! conditions. [`driver`](super::driver) runs what is described here.

use crate::candidates::CandidateRankMetric;
use crate::cnf::CnfFormula;
use crate::decompose::goatd::candidate_param;
use crate::decompose::{
    BagMetadata, ConversionRequest, FcBudget, GraphKind, Reading, TdConversion, WallCapMode,
    convert_td,
};
use crate::diagnostics::diag;
use crate::score::StructureProfile;
use crate::score::agg::{AggModel, AggScore, agg_score};
use crate::score::{VtreeScores, vtree_max_clause_load};
use crate::vtree::Vtree;
use std::sync::Arc;

// ---------------------------------------------------------------------------
// Portfolio: the catalog entries + goatd
// ---------------------------------------------------------------------------

/// Above this var count, skip the bisection-family entries (hypergraph-bisect,
/// guided-bisect) and release the held flowcutter-incidence TD early.
pub(super) const PORTFOLIO_HEAVY_MAX_VARS: u32 = 500_000;

/// One built-and-scored candidate, retained past its scoring, for two
/// independent reasons:
/// 1. Projected (`peak_mode`) selection — the whole catalog must be collected
///    before the blended band selection ([`select_peak_band`]) can pick a winner.
/// 2. A caller that asked for an exported candidate set
///    ([`Inputs::candidate_capacity`]). This adds no selection semantics:
///    plain-MC selection never reads `cands`.
pub(super) struct ScoredCandidate {
    /// This candidate's score under the run's ranking metric
    /// ([`Inputs::rank_metric`]). The blended projected selection minimizes it.
    pub(super) sel_metric: f64,
    /// Every metric this candidate scored, carried whole rather than as the two
    /// selection needs, so the retained candidate set can report the same five numbers
    /// the selector saw without recomputing any of them.
    pub(super) stats: VtreeScores,
    /// The ranker's score for this candidate — or, for the boosted kind, its
    /// inputs until the component's candidates are all known; `None` when the
    /// build selects on the cost alone.
    pub(super) agg: Option<AggScore>,
    pub(super) name: &'static str,
    /// The parameter this candidate was built at, carried beside the name so
    /// the retained set can publish a spec rather than a bare family.
    pub(super) param: Option<&'static str>,
    pub(super) vtree: Arc<Vtree>,
    /// TD bag metadata for this candidate's vtree (`None` for non-TD families).
    /// Carried per candidate so only the selected one's metadata escapes.
    pub(super) meta: Option<Arc<BagMetadata>>,
}

/// The candidate a selection has adopted and its reported scores.
///
/// One value rather than a handful of fields, because none of them means
/// anything without the rest: the bag metadata describes THIS tree and no
/// other, and the name and parameter are what would rebuild it. Adoption
/// replaces them all at once, which is what makes "kept in lockstep" a property
/// of the code rather than a warning in a comment.
pub(super) struct Incumbent {
    /// Every score of `vtree`; absent until a candidate is adopted.
    pub(super) scores: Option<VtreeScores>,
    /// Clause-load standard deviation of `vtree`.
    pub(super) stddev: f64,
    /// Combined structural cost of `vtree`.
    pub(super) cost: f64,
    /// The adopted tree; `None` until something is adopted, which is also what
    /// the scores being at their maxima means.
    pub(super) vtree: Option<Arc<Vtree>>,
    /// TD bag metadata of `vtree`. `None` whenever the incumbent is not
    /// TD-derived.
    pub(super) meta: Option<Arc<BagMetadata>>,
    /// The family that built `vtree`.
    pub(super) name: &'static str,
    /// The parameter behind `name`, so the published winner can be spelled as
    /// the spec that rebuilds it.
    pub(super) param: Option<&'static str>,
}

impl Default for Incumbent {
    /// Nothing adopted: both scores at the maximum, so the first candidate
    /// scored beats it.
    fn default() -> Self {
        Incumbent {
            scores: None,
            stddev: f64::MAX,
            cost: f64::MAX,
            vtree: None,
            meta: None,
            name: "none",
            param: None,
        }
    }
}

impl Incumbent {
    /// Take over from whatever was adopted before. `pick` is the number the
    /// selection compared, which is `stats.cost` in plain mode.
    pub(super) fn adopt(
        &mut self,
        stats: &VtreeScores,
        vtree: Arc<Vtree>,
        meta: Option<Arc<BagMetadata>>,
        name: &'static str,
        param: Option<&'static str>,
    ) {
        *self = Incumbent {
            scores: Some(*stats),
            stddev: stats.clause_load_stddev,
            cost: stats.cost,
            vtree: Some(vtree),
            meta,
            name,
            param,
        };
    }
}

/// One machine-parseable per-candidate trace row (plain-MC tracing only),
/// emitted after selection so `built`/`adopted` reflect the true chain pick.
pub(super) struct TraceRow {
    pub(super) family: &'static str,
    pub(super) param: String,
    pub(super) stddev: f64,
    pub(super) mcl: u32,
    pub(super) peak_context_width_all: u32,
    pub(super) cost: f64,
    pub(super) built: bool,
}

impl TraceRow {
    /// The row a scored vtree reports as. `built` separates the candidates the
    /// chain realized from the ones a score-everything trace generated for
    /// comparison only.
    pub(super) fn from_scores(
        family: &'static str,
        param: String,
        scores: &VtreeScores,
        built: bool,
    ) -> Self {
        Self {
            family,
            param,
            stddev: scores.clause_load_stddev,
            mcl: scores.max_clause_load,
            peak_context_width_all: scores.peak_context_width_all,
            cost: scores.cost,
            built,
        }
    }
}

/// One entry of the portfolio catalog as data. The one driver loop runs
/// `gate → build → fold` over the ordered catalog, so extending the portfolio
/// is one more `CatalogEntry`, never a new inline block.
pub(super) struct CatalogEntry {
    /// What a run publishes as the candidate that won, and the `--vtree` base
    /// that builds this construction alone.
    pub(super) name: &'static str,
    /// The parameter that base needs to reproduce THIS build, `None` when the
    /// bare base already does — so `name` and `param` together spell the spec
    /// this run publishes, and the plain-MC trace prints the same parameter in
    /// its own column. An entry whose build returns several trees names the
    /// first with this; the rest carry [`candidate_param`] of their index.
    /// `every_catalog_candidate_names_a_spec_that_rebuilds_it` holds the pair
    /// to the grammar.
    pub(super) param: Option<&'static str>,
    /// Gates both the bag metadata the fold keeps and the verbose per-entry
    /// trace line: only entries built through the one conversion come back with
    /// metadata describing the tree they returned.
    pub(super) td_based: bool,
    pub(super) gate: Gate,
    /// The most trees this entry's build can offer at once. A build may offer
    /// fewer — the goatd entries offer as many as [`GoatdKnobs::candidates`]
    /// asks for — but never more, so this is how many names the entry
    /// contributes to [`PortfolioKnobs::candidate_names`]. An entry above 1
    /// carries no `param` of its own, since a tree past the first is named by
    /// [`candidate_param`] of its index instead.
    ///
    /// [`GoatdKnobs::candidates`]: crate::decompose::GoatdKnobs::candidates
    /// [`PortfolioKnobs::candidate_names`]: super::PortfolioKnobs::candidate_names
    pub(super) offers: u32,
    /// The trees this entry offers, best first by its own reckoning; empty when
    /// it produced none. Most entries offer one.
    pub(super) build: fn(&Inputs, &mut RunState) -> Vec<TdConversion>,
}

impl CatalogEntry {
    /// Every `--vtree` spec this entry can publish as a winner, in the order it
    /// offers them: its own, then one per tree past the first.
    pub(super) fn published_specs(&self) -> impl Iterator<Item = String> + '_ {
        (0..self.offers as usize)
            .map(|index| candidate_spec(self.name, candidate_param(index).or(self.param)))
    }
}

/// Milliseconds of construction work done since `start`, measured on the
/// construction clock ([`crate::decompose::meter::now`]).
///
/// The one spelling of that read. Under a deterministic construction budget the
/// clock advances with the work charged rather than with the wall, so a bound
/// expressed through this function is a bound on what the build DOES; without
/// one it is `start.elapsed()` and the bound is the wall it always was. Every
/// portfolio bound that decides how hard the search tries — the projected
/// large-component cap, the behind-schedule latch — is measured with it, so all
/// of them change currency together and none can be left reading the other
/// clock.
pub(super) fn work_ms_since(start: std::time::Instant) -> u64 {
    crate::decompose::meter::now()
        .saturating_duration_since(start)
        .as_millis() as u64
}

/// This build has less room than the last portfolio build in its caller-owned
/// history actually took.
///
/// `was` is a measurement, not a forecast, and `None` before the first build in
/// the history finishes. A build with more room than the measurement is not
/// gated, so nothing changes on a run whose builds fit the room left. Without a
/// deadline `remaining_ms` is `None` and the gate cannot fire at all.
pub(super) fn outspent(remaining_ms: Option<i64>, was: Option<u64>) -> bool {
    remaining_ms
        .zip(was)
        .is_some_and(|(left, was)| left > 0 && (left as u64) <= was)
}

/// The `--vtree` spec that rebuilds one candidate: its name, plus the
/// parameter the name needs to mean THIS build. The one place a published
/// candidate identity is assembled — `winning_spec` and `built_by` are read
/// back as specs, so a name that dropped the parameter it was built at would
/// send its reader to a different tree.
pub(super) fn candidate_spec(name: &str, param: Option<&str>) -> String {
    crate::spec::spec_string(name, param)
}

/// What an entry's gate is allowed to consult, and therefore when the
/// driver must have the derived signals computed.
pub(super) enum Gate {
    Always,
    FromInputs(fn(&Inputs) -> bool),
    FromDerived(fn(&Inputs, &Derived) -> bool),
}

/// What one portfolio build was asked for.
pub(super) struct Inputs<'a> {
    pub(super) formula: &'a CnfFormula,
    /// Optional profile of the source formula. Only its clause-width
    /// dispersion participates in the structure gate; the formula above owns
    /// the occurrence signal.
    pub(super) source_profile: Option<StructureProfile>,
    pub(super) seed: u64,
    pub(super) peak_mode: bool,
    /// show-set mask (var-indexed) for projection-aware peak selection. `None`
    /// = all-var peak (or plain MC).
    pub(super) show_mask: Option<&'a crate::cnf::ShowMask>,
    pub(super) trace: bool,
    pub(super) flowcutter_cap_ms: Option<i64>,
    /// When this build started, read on the construction clock
    /// ([`crate::decompose::meter::now`]) — the reading the one bound measured
    /// from the build's start rather than from its deadline compares against
    /// ([`Inputs::cap_tripped`]). Without a deterministic budget it is the real
    /// start, and that bound is the wall it has always been.
    ///
    /// The wall the driver REPORTS when the build finishes is measured from a
    /// real reading of the same moment, kept in the driver: a report of elapsed
    /// time has to stay one whatever budget the build ran under.
    pub(super) t_build: std::time::Instant,
    /// Absolute wall-clock deadline for this whole portfolio build. `None` = no
    /// deadline, so every entry runs to completion.
    pub(super) deadline: Option<std::time::Instant>,
    /// How many candidates the caller asked to have retained for export
    /// ([`crate::candidates`]). `0`/`1` = do not retain: `fold` then keeps
    /// nothing beyond the running best and the selection tail publishes no
    /// candidate set. Never consulted by any adoption rule — this decides what
    /// is kept, never what wins.
    pub(super) candidate_capacity: usize,
    /// Projected selection's tie band.
    pub(super) peak_tolerance: f64,
    /// What the goatd entry is configured with.
    pub(super) goatd: crate::decompose::goatd::GoatdKnobs,
    /// What this run ranks candidates by — both the deferred selection among
    /// them and the order an exported set is published in.
    pub(super) rank_metric: CandidateRankMetric,
    /// This build's construction-effort multiplier
    /// ([`crate::budget::vtree_effort_scale`]), computed once from the budget
    /// hint on the selection context.
    pub(super) effort_scale: f64,
    /// Which dimensions of the conversion the run named. Every candidate that
    /// converts a decomposition inherits it, so one run reads every candidate's
    /// decomposition under the same rule.
    pub(super) reading: Reading,
    /// Whether each candidate's conversion reports every reading it scored.
    pub(super) conversion_trace: bool,
    /// The caller's candidate preference, already checked against the catalog
    /// by the driver. Read at the end of the build, never by a gate: the
    /// preference decides what is selected, not what is built.
    pub(super) prefer: Option<&'a super::CandidatePreference>,
    /// The ranker this build selects on, or `None` when it selects on the
    /// structural cost alone: `VITRI_SCORE_AGG=cost`, a caller that turned
    /// [`super::PortfolioKnobs::ranker`] off, or projected selection. Set,
    /// every candidate is scored by it as well as by the cost and the driver
    /// takes its argmin once the catalog is in; unset, no aggregate is
    /// computed at all.
    pub(super) score_agg: Option<&'a AggModel>,
}

impl<'a> Inputs<'a> {
    /// What one catalog entry hands the conversion of its decomposition.
    pub(super) fn conversion(&self, spec: &'static str) -> ConversionRequest<'static> {
        ConversionRequest {
            spec: Some(spec),
            reading: self.reading,
            effort_scale: self.effort_scale,
            deadline: self.deadline,
            real_deadline: None,
            trace: self.conversion_trace,
        }
    }

    /// Whether the tree `entry` offered at `index` is the candidate this build
    /// was asked to prefer. The spec that tree publishes matches, and so does
    /// the bare family name — which names the first tree of the first entry of
    /// that family, since the catalog order decides.
    pub(super) fn prefers(&self, entry: &CatalogEntry, index: usize) -> bool {
        self.prefer.is_some_and(|p| {
            (index == 0 && p.name() == entry.name)
                || p.name() == candidate_spec(entry.name, candidate_param(index).or(entry.param))
        })
    }
}

/// What the build has produced so far: the running selection accumulators,
/// the retained side tables, and the effort/budget dials the driver re-aims
/// per entry.
pub(super) struct RunState {
    /// FlowCutter step budget for the TD entries.
    pub(super) reduced_steps: i64,
    /// FlowCutter restart breadth, alongside `reduced_steps`.
    pub(super) iters: i32,
    /// This entry's fair share of the remaining budget, in ms; `None` = no
    /// limit, which is the `deadline == None` case. Recomputed by the driver
    /// loop at each entry's start, so a
    /// builder that finishes early rolls its unspent time forward to the rest.
    ///
    /// This is the SCHEDULE, not the bound: how much of the budget this entry is
    /// planned to use, and what the anytime goatd builder takes as its budget.
    /// What an entry may not outlive is `cand_wall_ms`.
    pub(super) cand_cap_ms: Option<i64>,
    /// Hard wall bound, in ms, for the entry being built: it may not outlive the
    /// construction budget it was admitted under. `None` = no deadline.
    ///
    /// Set by the driver loop at each entry's start from the whole time left,
    /// not from the fair share, so an entry that behaves is bounded only by a
    /// wall it never reaches. The deadline is otherwise consulted only between
    /// entries, which cannot stop the one that has already begun — and that is
    /// the entry which overruns the ceiling.
    ///
    /// The one exception is the attempt the driver allows when the deadline is
    /// already spent and nothing has been built: there the share and the wall
    /// are both a fixed short number, because what is left is zero or less.
    pub(super) cand_wall_ms: Option<i64>,
    /// Latched once some entry has overrun its own fair share, and set outright
    /// for the one attempt a spent deadline allows. Until it latches every entry
    /// is bounded only by the whole remaining budget; after it latches the
    /// remaining FlowCutter builds are additionally tightened to the fair share,
    /// and take the tight search with it (see `fc_time_cap_ms` and
    /// `fc_cap_mode`).
    pub(super) behind_schedule: bool,
    pub(super) flowcutter_incidence_td_cache: Option<crate::decompose::TreeDecomposition>,
    /// The candidate plain-MC greedy selection has adopted so far.
    pub(super) best: Incumbent,
    /// Machine-parseable per-candidate trace rows. Populated only when
    /// tracing; fully inert otherwise.
    pub(super) trace_rows: Vec<TraceRow>,
    // Projected (peak_mode) collects every generated candidate and picks via
    // blended selection, rather than greedy argmin.
    pub(super) cands: Vec<ScoredCandidate>,
    /// Whether the hypergraph-bisect family was scored by the chain itself, so the
    /// score-everything trace simulation does not re-score the one imbalance
    /// point production already covered. Only ever set while tracing.
    pub(super) hypergraph_bisect_040_built: bool,
    /// The preferred candidate, kept as it is scored so the selection tail can
    /// adopt it whatever the scores said. `None` on every build that asked for
    /// no preference, and on one whose preferred candidate did not build —
    /// which are the two cases the tail has to tell apart.
    pub(super) preferred: Option<ScoredCandidate>,
}

/// The structure signals a subset of entries gate on, computed once from
/// the inputs and from the selection as it stood when first needed.
///
/// Not part of [`Inputs`] because the generation gate reads the incumbent
/// vtree, and not part of [`RunState`] because nothing ever revises it: this is
/// a snapshot taken at a defined point in the catalog.
pub(super) struct Derived {
    /// Whether the formula is coloring-like (near-uniform variable occurrence
    /// and near-uniform clause width). Always `false` above
    /// `PORTFOLIO_HEAVY_MAX_VARS`.
    pub(super) coloring_like: bool,
    /// The MCL-floor generation gate hypergraph-bisect keeps in plain mode.
    pub(super) hypergraph_bisect_gen_gate: bool,
}

impl Derived {
    /// Compute the structure gates read. Called at the first
    /// [`Gate::FromDerived`], so the incumbent already reflects the earlier
    /// entries; the result is reused for every later gate, the adoption
    /// test and the trace.
    pub(super) fn compute(inp: &Inputs, run: &RunState) -> Derived {
        let formula = inp.formula;
        let num_vars = inp.num_vars();
        // Gated on `PORTFOLIO_HEAVY_MAX_VARS` so the O(formula) scan isn't
        // paid above it.
        let coloring_like = if num_vars <= PORTFOLIO_HEAVY_MAX_VARS {
            let profile = StructureProfile::measure(formula);
            let coloring_like = coloring_like_for_selection(profile, inp.source_profile);
            if inp.trace {
                diag!(
                    "[coloring] occ_cv={:.4} width_cv={:.4} source_width_cv={} \
                     coloring_like={} num_vars={num_vars}",
                    profile.var_occurrence_cv,
                    profile.clause_width_cv,
                    inp.source_profile
                        .map(|p| format!("{:.4}", p.clause_width_cv))
                        .unwrap_or_else(|| "none".to_owned()),
                    coloring_like as u8,
                );
            }
            coloring_like
        } else {
            false
        };
        let best_mcl = run
            .best
            .vtree
            .as_ref()
            .map(|v| vtree_max_clause_load(v, formula));
        Derived {
            coloring_like,
            hypergraph_bisect_gen_gate: best_mcl.is_none_or(|mcl| mcl > formula.num_vars / 5),
        }
    }
}

/// Resolve the portfolio's structure gate from the formula it is building and
/// an optional profile of that formula's source.
///
/// The reduced/built formula remains authoritative for occurrence dispersion.
/// A source profile supplies only an additional clause-width signal. With no
/// source profile, the measured verdict is returned unchanged.
pub(super) fn coloring_like_for_selection(
    built: StructureProfile,
    source: Option<StructureProfile>,
) -> bool {
    built.coloring_like
        || source.is_some_and(|source| {
            crate::cnf::stats::coloring_like_predicate(
                built.var_occurrence_cv,
                source.clause_width_cv,
            )
        })
}

impl Inputs<'_> {
    /// How many variables the formula this build was handed has.
    pub(super) fn num_vars(&self) -> u32 {
        self.formula.num_vars
    }

    /// Whether the projected large-component cap has been spent. A DECISION —
    /// it decides whether the goatd entry is attempted at all — so it is
    /// measured in construction work rather than in elapsed time.
    fn cap_tripped(&self) -> bool {
        self.flowcutter_cap_ms
            .is_some_and(|cap| (work_ms_since(self.t_build) as i64) > cap)
    }

    /// Milliseconds left before the construction deadline. `None` = no deadline.
    pub(super) fn remaining_ms(&self) -> Option<i64> {
        self.deadline
            .map(|d| i64::try_from(crate::budget::remaining(d).as_millis()).unwrap_or(i64::MAX))
    }

    /// True once the construction deadline has passed (always false without one).
    pub(super) fn out_of_time(&self) -> bool {
        self.remaining_ms().is_some_and(|r| r <= 0)
    }

    /// Fair share, in ms, for the next entry when `n_remaining` entries
    /// (including it) are still to be attempted: `remaining / n_remaining`,
    /// floored at 1 ms, so a build already past its deadline still gets a cap
    /// rather than a zero one. Computed at each entry's start, so time a
    /// cheap builder leaves unspent rolls forward to the rest. `None` when
    /// there is no deadline.
    pub(super) fn fair_share_ms(&self, n_remaining: usize) -> Option<i64> {
        self.remaining_ms()
            .map(|r| (r / n_remaining.max(1) as i64).max(1))
    }
}

impl RunState {
    pub(super) fn new(reduced_steps: i64, iters: i32) -> RunState {
        RunState {
            reduced_steps,
            iters,
            cand_cap_ms: None,
            cand_wall_ms: None,
            behind_schedule: false,
            flowcutter_incidence_td_cache: None,
            best: Incumbent::default(),
            trace_rows: Vec::new(),
            cands: Vec::new(),
            hypergraph_bisect_040_built: false,
            preferred: None,
        }
    }

    /// Wall cap (ms) to hand a FlowCutter build; `None` = no cap, which is the
    /// deterministic step-budgeted search.
    ///
    /// Three sources, and the tightest wins:
    /// - `cand_wall_ms`, the time actually left in the construction budget when
    ///   this entry started — or the fixed short wall of the one attempt a spent
    ///   deadline allows, where the time left is zero or less. Under a deadline
    ///   this is always armed, the first entry included, which is what makes the
    ///   budget a ceiling rather than a suggestion.
    /// - `cand_cap_ms`, this entry's fair share, once `behind_schedule` has
    ///   latched. That is the scheduling tightening the latch has always
    ///   applied; it no longer decides whether a cap exists at all.
    /// - the caller's projected large-component cap.
    pub(super) fn fc_time_cap_ms(&self, inp: &Inputs) -> Option<i64> {
        let share = if self.behind_schedule {
            self.cand_cap_ms
        } else {
            None
        };
        [self.cand_wall_ms, share, inp.flowcutter_cap_ms]
            .into_iter()
            .flatten()
            .min()
    }

    /// Whether the cap `fc_time_cap_ms` hands FlowCutter is expected to bite.
    ///
    /// Tightness changes what the search considers, not only when it stops (see
    /// [`WallCapMode`]), so it is keyed on the two conditions that mean the
    /// build is already in the regime where finishing beats searching:
    /// - `behind_schedule` — some entry has already overrun its fair share, or
    ///   this is the one attempt a spent deadline allows;
    /// - `flowcutter_cap_ms` — the projected large-component cap, whose whole
    ///   purpose is to cut a grinding `flowcutter-primal` short.
    ///
    /// Any other wall is an outer bound the build is expected to finish inside,
    /// and gets a search identical to the unbounded one.
    pub(super) fn fc_cap_mode(&self, inp: &Inputs) -> WallCapMode {
        if self.behind_schedule || inp.flowcutter_cap_ms.is_some() {
            WallCapMode::Tight
        } else {
            WallCapMode::BoundOnly
        }
    }

    /// The budget both FlowCutter entries search under: this run's step and
    /// iteration dials, timed once `fc_time_cap_ms` says the build owes time
    /// back. Without a cap the search is the deterministic step-budgeted one.
    fn fc_budget(&self, inp: &Inputs) -> FcBudget {
        match self.fc_time_cap_ms(inp) {
            None => FcBudget::Steps {
                steps: self.reduced_steps,
                iters: self.iters,
            },
            Some(timeout_ms) => FcBudget::Timed {
                timeout_ms,
                patience_ms: 0,
                iters: self.iters,
                steps: self.reduced_steps,
                cap_mode: self.fc_cap_mode(inp),
            },
        }
    }

    /// Wall budget for a goatd build: its fair share, or `None` when there is no
    /// deadline. Unlike the FlowCutter cap this is armed unconditionally — the
    /// goatd schedule and its post-process refinement are anytime by
    /// construction (the lex-min picker keeps the best TD found so far, and both
    /// deadline checks sit between phases), so a budget that never trips leaves
    /// the output unchanged.
    fn goatd_budget_ms(&self) -> Option<u64> {
        self.cand_cap_ms.map(|cap| cap as u64)
    }

    /// Scores a freshly built candidate and folds it into selection — the one
    /// fold for the whole catalog. `index` is the tree's place among what the
    /// entry offered: the first carries the entry's own parameter, the rest are
    /// named by [`candidate_param`]. Any of them can be the preferred
    /// candidate, since any of them can be published as the winner.
    pub(super) fn fold(
        &mut self,
        inp: &Inputs,
        entry: &CatalogEntry,
        index: usize,
        built: TdConversion,
    ) {
        let TdConversion { vtree, td } = built;
        let param = candidate_param(index).or(entry.param);
        // Only TD-based families' metadata describes the vtree just built;
        // bisection families recombine several conversions, so theirs would
        // describe a different tree.
        let meta = if entry.td_based { td.meta } else { None };
        let formula = inp.formula;
        let (stats, agg) = if let Some(model) = inp.score_agg {
            let (stats, score) = agg_score(&vtree, formula, model, inp.show_mask)
                .expect(crate::score::BUILT_FROM_THIS_FORMULA);
            (stats, Some(score))
        } else {
            (
                VtreeScores::compute(&vtree, formula, inp.show_mask)
                    .expect(crate::score::BUILT_FROM_THIS_FORMULA),
                None,
            )
        };
        let sel_metric = inp.rank_metric.value(&stats);
        if inp.trace && entry.td_based {
            diag!(
                "[portfolio] cand {:18} stddev={:8.2} peak_ctx={:5} peak_context_width_show={:>5} cost={:.2}",
                candidate_spec(entry.name, candidate_param(index)),
                stats.clause_load_stddev,
                stats.peak_context_width_all,
                stats
                    .peak_context_width_show
                    .map(|s| s.to_string())
                    .unwrap_or_else(|| "-".to_string()),
                stats.cost,
            );
        }
        // Kept whatever the mode, and independently of the retained set: plain
        // selection retains no candidate at all, so without this the preference
        // would have nothing left to adopt by the time the catalog is done.
        if self.preferred.is_none() && inp.prefers(entry, index) {
            self.preferred = Some(ScoredCandidate {
                sel_metric,
                stats,
                agg: agg.clone(),
                name: entry.name,
                param,
                vtree: Arc::clone(&vtree),
                meta: meta.clone(),
            });
        }
        // Retained when the selection waits for the whole catalog: peak_mode,
        // the ranker (which compares the cost pick against its own once every
        // candidate is in), or an exported candidate set. A build selecting on
        // the cost alone with `candidate_capacity <= 1` keeps nothing: no
        // clone, no retained vtree, nothing alive past this function.
        if inp.peak_mode || inp.candidate_capacity > 1 || inp.score_agg.is_some() {
            self.cands.push(ScoredCandidate {
                sel_metric,
                stats,
                agg,
                name: entry.name,
                param,
                vtree: Arc::clone(&vtree),
                meta: meta.clone(),
            });
        }
        if !inp.peak_mode {
            // Record every candidate for the trace (built=true) before the greedy
            // adoption, so the row exists even for candidates the chain built but
            // did not adopt.
            if inp.trace {
                self.trace_rows.push(TraceRow::from_scores(
                    entry.name,
                    param.unwrap_or("-").to_string(),
                    &stats,
                    true,
                ));
                // Matched on the pair, not the name alone, so a bare
                // family name cannot stand in for this one point.
                if entry.name == "hypergraph-bisect" && entry.param == Some("imbalance=0.40") {
                    self.hypergraph_bisect_040_built = true;
                }
            }
            if stats.cost < self.best.cost {
                self.best.adopt(&stats, vtree, meta, entry.name, param);
            }
        }
    }
}

// ---------------------------------------------------------------------------
// The catalog itself: gate + build free functions, coerced to fn pointers in
// the `CatalogEntry` table in `driver`. Adoption runs in `fold`.
// ---------------------------------------------------------------------------

/// Catalog entry 1, flowcutter-incidence — FlowCutter incidence TD.
pub(super) fn build_fc_inc(inp: &Inputs, run: &mut RunState) -> Vec<TdConversion> {
    let formula = inp.formula;
    run.flowcutter_incidence_td_cache = crate::decompose::flowcutter::flowcutter_td(
        formula,
        GraphKind::Incidence,
        run.fc_budget(inp),
    )
    .ok();
    let vtree = run
        .flowcutter_incidence_td_cache
        .as_ref()
        .map(|td| convert_td(formula, td, inp.conversion("flowcutter-incidence")));
    if inp.num_vars() > PORTFOLIO_HEAVY_MAX_VARS {
        run.flowcutter_incidence_td_cache = None;
    }
    vtree.into_iter().collect()
}

/// Catalog entry 2, flowcutter-primal — FlowCutter primal TD.
pub(super) fn build_fc_pri(inp: &Inputs, run: &mut RunState) -> Vec<TdConversion> {
    let formula = inp.formula;
    crate::decompose::flowcutter::flowcutter_td(formula, GraphKind::Primal, run.fc_budget(inp))
        .ok()
        .map(|td| convert_td(formula, &td, inp.conversion("flowcutter-primal")))
        .into_iter()
        .collect()
}

/// Gate for both goatd entries: once the cap has tripped there is no time for
/// a scheduled decomposition. Shared, so the two views are admitted on the same
/// condition; it runs once per entry, so a trace shows one line per skip.
pub(super) fn gate_goatd(inp: &Inputs) -> bool {
    if !inp.cap_tripped() {
        true
    } else {
        if inp.trace {
            diag!(
                "[portfolio] cap tripped ({}ms) \u{2192} skip goatd",
                work_ms_since(inp.t_build)
            );
        }
        false
    }
}

/// Catalog entry 3, goatd-incidence — goatd incidence-refine. Offers as many
/// of the schedule's decompositions as [`GoatdKnobs::candidates`] asks for.
pub(super) fn build_goatd(inp: &Inputs, run: &mut RunState) -> Vec<TdConversion> {
    crate::decompose::goatd::vtrees_from_goatd_refined(
        inp.formula,
        crate::decompose::GraphKind::Incidence,
        inp.seed,
        run.goatd_budget_ms(),
        inp.goatd,
        inp.trace,
        inp.conversion("goatd-incidence"),
    )
    .unwrap_or_default()
}

/// Catalog entry 4, goatd-primal — the same schedule on the primal graph.
///
/// Both views are in the catalog because they reach different trees: the
/// incidence graph separates a clause from its variables and the primal graph
/// does not, so a formula whose structure survives one projection can be
/// flattened by the other, and which tree scores better is not decidable from
/// the formula. A default build leaves this view out
/// ([`DEFAULT_SKIP`](super::DEFAULT_SKIP)): its build costs every component a
/// quarter of the construction, and on the model-counting competition
/// benchmarks the trees it wins with are as often larger as smaller than the
/// ranker's next choice.
pub(super) fn build_goatd_primal(inp: &Inputs, run: &mut RunState) -> Vec<TdConversion> {
    crate::decompose::goatd::vtrees_from_goatd_refined(
        inp.formula,
        crate::decompose::GraphKind::Primal,
        inp.seed,
        run.goatd_budget_ms(),
        inp.goatd,
        inp.trace,
        inp.conversion("goatd-primal"),
    )
    .unwrap_or_default()
}

/// Catalog entry 5, force gate.
///
/// FORCE takes no budget — it runs its embedding to completion — so the only
/// bound on it is the formula size, and it is skipped once the cap has tripped
/// for the same reason the goatd entries are.
pub(super) fn gate_force(inp: &Inputs) -> bool {
    inp.num_vars() <= PORTFOLIO_HEAVY_MAX_VARS && !inp.cap_tripped()
}

/// Catalog entry 5, force — a 2-D FORCE embedding, tree-ified by MST.
///
/// The one entry that is not a converted decomposition. It lays the variables
/// out by attraction to the clauses they share and builds the tree from that
/// layout, so it can beat the conversions on a formula no decomposition
/// separates well — which is the case the rest of the catalog has no answer
/// for.
pub(super) fn build_force(inp: &Inputs, _run: &mut RunState) -> Vec<TdConversion> {
    let cfg = crate::decompose::ForceConfig::new(crate::decompose::ForceMode::Mst);
    crate::decompose::vtree_from_force(inp.formula, cfg)
        .ok()
        .map(TdConversion::bare)
        .into_iter()
        .collect()
}

/// Catalog entry 6, hypergraph-bisect gate.
pub(super) fn gate_hypergraph_bisect(inp: &Inputs, derived: &Derived) -> bool {
    inp.num_vars() <= PORTFOLIO_HEAVY_MAX_VARS
        // Dropping the plain-mode prefilter wouldn't change what's adoptable —
        // it only adds build cost.
        && derived.coloring_like
        && (inp.peak_mode || derived.hypergraph_bisect_gen_gate)
}

/// Catalog entry 6, hypergraph-bisect@0.40.
pub(super) fn build_hypergraph_bisect(inp: &Inputs, _run: &mut RunState) -> Vec<TdConversion> {
    let dials = crate::decompose::BisectDials {
        imbalance: crate::decompose::multilevel_hg_bisect::IMBALANCE_PORTFOLIO_RELAXED,
        base_seed: 0,
        effort_scale: inp.effort_scale,
    };
    crate::decompose::multilevel_hg_bisect::vtree_from_hg_bisect(inp.formula, dials)
        .ok()
        .map(TdConversion::bare)
        .into_iter()
        .collect()
}

/// Catalog entry 7, guided-bisect gate.
pub(super) fn gate_guided_bisect(inp: &Inputs, derived: &Derived) -> bool {
    derived.coloring_like && inp.num_vars() <= PORTFOLIO_HEAVY_MAX_VARS
}

/// Catalog entry 7, guided-bisect — reuses the flowcutter-incidence TD.
pub(super) fn build_guided_bisect(inp: &Inputs, run: &mut RunState) -> Vec<TdConversion> {
    run.flowcutter_incidence_td_cache
        .as_ref()
        .and_then(|td| {
            crate::decompose::guided_bisect_from_incidence_td(
                inp.formula,
                td,
                inp.conversion("guided-bisect"),
            )
            .ok()
        })
        .into_iter()
        .collect()
}