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
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
//! A whole-tree ranker over aggregates of the per-node quantities: linear, or
//! a boosted pairwise model over the same inputs.
//!
//! The portfolio selects on the ranker shipped in this crate unless
//! `VITRI_SCORE_AGG` says otherwise: `cost` selects on [`super::vtree_cost`]
//! alone, and a path names another ranker file.
//!
//! The quantities are the 38 columns [`super::tables`] computes at each
//! internal node. Each column is reduced over ALL internal nodes of the tree by
//! one of five aggregates, standardized, and summed with the eleven addends of
//! the structural cost ([`super::unified_cost_terms`]). Lower is better, and
//! the portfolio takes the argmin within a component.
//!
//! The model is data, not code: a JSON file exported by the fit that produced
//! the weights, the shipped one read from [`DEFAULT_MODEL`]. A file naming a
//! column, an aggregate or a cost term this crate has no definition for is
//! refused at load.
//!
//! Two kinds of file. `agg-linear` scores each candidate on its own: the
//! intercept, plus each cost addend at its weight, plus each standardized
//! aggregate at its weight. `agg-pair-boost` scores candidates against each
//! other: a gradient-boosted ensemble reads the DIFFERENCE of two candidates'
//! raw input vectors and predicts the probability that the first is the larger
//! compile, and a candidate's score is its mean probability of being larger
//! than each sibling. Both kinds: lower is better, argmin within a component.

use std::collections::{BTreeMap, HashMap};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock};

use crate::cnf::CnfFormula;
use crate::error::VitriError;
use crate::vtree::Vtree;

use super::tables::{FEATURE_NAMES, Feature, Tables};
use super::{COST_TERM_NAMES, VtreeScores};

// ---------------------------------------------------------------------------
// The aggregates
// ---------------------------------------------------------------------------

/// How one column is reduced over the internal nodes of a tree.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Aggregate {
    Max,
    Mean,
    /// The 90th percentile, interpolated linearly between the two neighbouring
    /// order statistics.
    P90,
    /// The 99th, the same way.
    P99,
    /// `log2 Σ 2^v` over the strictly positive entries, computed max-shifted.
    Lse,
}

/// Every aggregate under the name the model file uses for it.
const AGGREGATE_NAMES: [(&str, Aggregate); 5] = [
    ("max", Aggregate::Max),
    ("mean", Aggregate::Mean),
    ("p90", Aggregate::P90),
    ("p99", Aggregate::P99),
    ("lse", Aggregate::Lse),
];

impl Aggregate {
    fn from_name(name: &str) -> Option<Aggregate> {
        AGGREGATE_NAMES
            .iter()
            .find(|(known, _)| *known == name)
            .map(|&(_, agg)| agg)
    }

    /// Reduce the values a column took over the internal nodes.
    ///
    /// `values` holds one entry per node the column has a value at, which is
    /// every internal node except that the four cut columns have none at the
    /// root — the cut pass produces no row there, and neither does the table
    /// this is checked against. An aggregate over no entries is 0, for `lse`
    /// and for the other four alike. The reference implementation instead
    /// carries such a column through as NaN and drops the tree from the fit, so
    /// a 0 here is a tree that fit never saw; [`agg_score`] says so on stderr
    /// when it happens.
    ///
    /// `values` is sorted in place by the two percentiles.
    fn of(self, values: &mut [f64]) -> f64 {
        if values.is_empty() {
            return 0.0;
        }
        match self {
            Aggregate::Max => values.iter().copied().fold(f64::NEG_INFINITY, f64::max),
            Aggregate::Mean => values.iter().sum::<f64>() / values.len() as f64,
            Aggregate::P90 => percentile(values, 90.0),
            Aggregate::P99 => percentile(values, 99.0),
            Aggregate::Lse => lse2(values),
        }
    }
}

/// The `q`th percentile with linear interpolation, which is what
/// `numpy.percentile` computes by default.
///
/// The position is `q/100` of the way through the order statistics; when it
/// falls between two of them the answer is interpolated between the pair. The
/// two-sided form is numpy's own: it interpolates from whichever end is nearer,
/// so neither endpoint is recovered through a cancelling subtraction.
fn percentile(values: &mut [f64], q: f64) -> f64 {
    values.sort_by(f64::total_cmp);
    let last = values.len() - 1;
    let pos = q / 100.0 * last as f64;
    let below = pos.floor();
    let index = below as usize;
    if index >= last {
        return values[last];
    }
    let (a, b) = (values[index], values[index + 1]);
    let t = pos - below;
    let span = b - a;
    if t <= 0.5 {
        a + span * t
    } else {
        b - span * (1.0 - t)
    }
}

/// `log2 Σ 2^v` over the strictly positive entries, max-shifted; 0 when none is
/// positive. The convention the offline tables were built with.
fn lse2(values: &[f64]) -> f64 {
    let peak = values.iter().copied().filter(|&v| v > 0.0).reduce(f64::max);
    let Some(peak) = peak else {
        return 0.0;
    };
    peak + values
        .iter()
        .copied()
        .filter(|&v| v > 0.0)
        .map(|v| 2f64.powf(v - peak))
        .sum::<f64>()
        .log2()
}

// ---------------------------------------------------------------------------
// The model file
// ---------------------------------------------------------------------------

#[derive(serde::Deserialize)]
struct RawModel {
    kind: String,
    #[serde(default)]
    intercept: f64,
    /// Ordered, so a file with more than one bad term is refused for the same
    /// one on every run.
    #[serde(default)]
    terms: BTreeMap<String, f64>,
    #[serde(default)]
    features: Vec<RawFeature>,
    // The boosted kind's fields.
    #[serde(default)]
    baseline: f64,
    #[serde(default)]
    inputs: Vec<RawInput>,
    #[serde(default)]
    trees: Vec<RawTree>,
}

/// One entry of the boosted kind's input vector: a cost addend by name, or a
/// column reduced by an aggregate, raw.
#[derive(serde::Deserialize)]
struct RawInput {
    term: Option<String>,
    column: Option<String>,
    agg: Option<String>,
}

#[derive(serde::Deserialize)]
struct RawTree {
    nodes: Vec<RawNode>,
}

/// A leaf carries `value`; a split carries the other four. The file's
/// `missing_left` is read and ignored: every input here is finite.
#[derive(serde::Deserialize)]
struct RawNode {
    value: Option<f64>,
    feature: Option<usize>,
    threshold: Option<f64>,
    left: Option<usize>,
    right: Option<usize>,
}

#[derive(serde::Deserialize)]
struct RawFeature {
    column: String,
    agg: String,
    mean: f64,
    sd: f64,
    weight: f64,
}

/// The two `kind`s this crate evaluates. A file carrying any other is refused
/// rather than read as one of these.
const LINEAR_KIND: &str = "agg-linear";
const BOOST_KIND: &str = "agg-pair-boost";

/// One node of one boosted tree, indices into the tree's own node table.
#[derive(Clone, Debug)]
enum Node {
    Leaf(f64),
    Split {
        /// Index into [`AggModel::inputs`].
        input: usize,
        /// `<=` goes left.
        threshold: f64,
        left: usize,
        right: usize,
    },
}

/// One entry of the boosted kind's input vector.
#[derive(Clone, Copy, Debug)]
enum Input {
    /// A cost addend, by position in [`COST_TERM_NAMES`].
    Term(usize),
    /// An entry of [`AggModel::aggregates`], raw (its mean and sd unused).
    Aggregate(usize),
}

/// How a loaded model turns a candidate's numbers into a score.
enum Scorer {
    Linear,
    PairBoost {
        baseline: f64,
        trees: Vec<Vec<Node>>,
    },
}

/// What the ranker computed for one candidate: the linear kind's score, or the
/// boosted kind's raw input vector, which only becomes a score once the
/// component's candidates are all known ([`round_robin`]).
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum AggScore {
    Scalar(f64),
    Inputs(Vec<f64>),
}

impl AggScore {
    /// The score, once there is one.
    pub(crate) fn scalar(&self) -> Option<f64> {
        match self {
            AggScore::Scalar(s) => Some(*s),
            AggScore::Inputs(_) => None,
        }
    }
}

/// One standardized aggregate of one column, with the weight it enters at.
struct AggTerm {
    /// Which of [`AggModel::columns`] this reduces.
    column: usize,
    agg: Aggregate,
    mean: f64,
    sd: f64,
    weight: f64,
}

impl AggTerm {
    /// The name the model file spells this reduction as.
    fn agg_name(&self) -> &'static str {
        AGGREGATE_NAMES
            .iter()
            .find(|(_, known)| *known == self.agg)
            .map(|&(name, _)| name)
            .expect("every aggregate is in the name table")
    }
}

/// A fitted whole-tree ranker, ready to score a candidate.
pub(crate) struct AggModel {
    intercept: f64,
    /// One multiplier per addend of the structural cost, in
    /// [`COST_TERM_NAMES`] order. A term the file does not name enters at 0.
    terms: [f64; 11],
    /// The distinct columns the aggregates below read, gathered once per tree.
    columns: Vec<Feature>,
    aggregates: Vec<AggTerm>,
    /// The boosted kind's input vector, in file order; empty for the linear
    /// kind.
    inputs: Vec<Input>,
    scorer: Scorer,
}

impl AggModel {
    /// Read a ranker from the JSON a fitting run exported.
    ///
    /// # Errors
    ///
    /// A sentence naming the file and the field that is wrong: bad JSON, a
    /// `kind` this crate does not evaluate, a cost term or a column or an
    /// aggregate it has no definition for, a standard deviation that is not
    /// above zero, a weight that is not finite.
    pub(crate) fn from_json(source: &Path, text: &str) -> Result<AggModel, String> {
        let raw: RawModel = serde_json::from_str(text)
            .map_err(|e| format!("{}: not an aggregate ranker: {e}", source.display()))?;
        let bad = |what: String| format!("{}: {what}", source.display());
        let boosted = match raw.kind.as_str() {
            LINEAR_KIND => false,
            BOOST_KIND => true,
            other => {
                return Err(bad(format!(
                    "kind {other:?} is not one this crate evaluates; it reads {LINEAR_KIND:?} \
                     and {BOOST_KIND:?}",
                )));
            }
        };
        if boosted && (!raw.features.is_empty() || !raw.terms.is_empty()) {
            return Err(bad(format!(
                "a {BOOST_KIND} file lists its inputs under \"inputs\"; \"features\" and \
                 \"terms\" belong to {LINEAR_KIND}",
            )));
        }
        if !raw.intercept.is_finite() {
            return Err(bad(format!("intercept {} is not finite", raw.intercept)));
        }
        let mut terms = [0f64; 11];
        for (name, weight) in &raw.terms {
            let Some(at) = COST_TERM_NAMES
                .iter()
                .position(|known| *known == name.as_str())
            else {
                return Err(bad(format!(
                    "terms names {name:?}, which is not one of the cost's addends: {}",
                    COST_TERM_NAMES.join(", "),
                )));
            };
            if !weight.is_finite() {
                return Err(bad(format!("terms {name:?} weight {weight} is not finite")));
            }
            terms[at] = *weight;
        }
        // The boosted kind's column inputs enter the same aggregate table, raw:
        // mean 0, sd 1, weight unused.
        let listed: Vec<(String, RawFeature)> = if boosted {
            let mut out = Vec::new();
            for (at, input) in raw.inputs.iter().enumerate() {
                if let (Some(column), Some(agg)) = (&input.column, &input.agg) {
                    if input.term.is_some() {
                        return Err(bad(format!(
                            "inputs[{at}] names both a term and a column; one or the other"
                        )));
                    }
                    out.push((
                        format!("inputs[{at}]"),
                        RawFeature {
                            column: column.clone(),
                            agg: agg.clone(),
                            mean: 0.0,
                            sd: 1.0,
                            weight: 0.0,
                        },
                    ));
                }
            }
            out
        } else {
            raw.features
                .into_iter()
                .enumerate()
                .map(|(at, f)| (format!("features[{at}]"), f))
                .collect()
        };
        let mut columns: Vec<Feature> = Vec::new();
        let mut aggregates = Vec::with_capacity(listed.len());
        for (where_, feature) in &listed {
            let named = |what: &str| {
                bad(format!(
                    "{where_} ({:?} {:?}): {what}",
                    feature.column, feature.agg,
                ))
            };
            let column = Feature::from_name(&feature.column)
                .ok_or_else(|| named("column is not a quantity this crate computes"))?;
            let agg = Aggregate::from_name(&feature.agg).ok_or_else(|| {
                named(&format!(
                    "agg is not known; this crate reduces by {}",
                    AGGREGATE_NAMES
                        .iter()
                        .map(|(name, _)| *name)
                        .collect::<Vec<_>>()
                        .join(", "),
                ))
            })?;
            if !(feature.sd.is_finite() && feature.sd > 0.0) {
                return Err(named(&format!(
                    "sd is {}; a standard deviation has to be above zero",
                    feature.sd
                )));
            }
            if !feature.mean.is_finite() {
                return Err(named(&format!("mean {} is not finite", feature.mean)));
            }
            if !feature.weight.is_finite() {
                return Err(named(&format!("weight {} is not finite", feature.weight)));
            }
            // One gather per distinct column, however many aggregates read it.
            let column_at = match columns.iter().position(|&c| c == column) {
                Some(at) => at,
                None => {
                    columns.push(column);
                    columns.len() - 1
                }
            };
            aggregates.push(AggTerm {
                column: column_at,
                agg,
                mean: feature.mean,
                sd: feature.sd,
                weight: feature.weight,
            });
        }
        let (inputs, scorer) = if boosted {
            (
                Self::inputs_from(&raw.inputs, &bad)?,
                Scorer::PairBoost {
                    baseline: Self::baseline_from(raw.baseline, &bad)?,
                    trees: Self::trees_from(&raw.trees, raw.inputs.len(), &bad)?,
                },
            )
        } else {
            (Vec::new(), Scorer::Linear)
        };
        Ok(AggModel {
            intercept: raw.intercept,
            terms,
            columns,
            aggregates,
            inputs,
            scorer,
        })
    }

    /// The boosted kind's input vector: each entry a cost addend by name or an
    /// aggregate, in file order, indexed the way the trees index them.
    fn inputs_from(raw: &[RawInput], bad: &dyn Fn(String) -> String) -> Result<Vec<Input>, String> {
        if raw.is_empty() {
            return Err(bad(
                "inputs is empty; the trees have nothing to read".to_string()
            ));
        }
        let mut inputs = Vec::with_capacity(raw.len());
        let mut next_aggregate = 0;
        for (at, input) in raw.iter().enumerate() {
            match (&input.term, &input.column, &input.agg) {
                (Some(term), None, None) => {
                    let Some(position) = COST_TERM_NAMES.iter().position(|known| known == term)
                    else {
                        return Err(bad(format!(
                            "inputs[{at}] names term {term:?}, which is not one of the cost's \
                             addends: {}",
                            COST_TERM_NAMES.join(", "),
                        )));
                    };
                    inputs.push(Input::Term(position));
                }
                (None, Some(_), Some(_)) => {
                    inputs.push(Input::Aggregate(next_aggregate));
                    next_aggregate += 1;
                }
                _ => {
                    return Err(bad(format!(
                        "inputs[{at}] has to be a term, or a column with an agg"
                    )));
                }
            }
        }
        Ok(inputs)
    }

    fn baseline_from(baseline: f64, bad: &dyn Fn(String) -> String) -> Result<f64, String> {
        if baseline.is_finite() {
            Ok(baseline)
        } else {
            Err(bad(format!("baseline {baseline} is not finite")))
        }
    }

    /// The node tables, each index checked against its own tree and each split
    /// against the input vector, so evaluation never indexes out of range.
    fn trees_from(
        raw: &[RawTree],
        n_inputs: usize,
        bad: &dyn Fn(String) -> String,
    ) -> Result<Vec<Vec<Node>>, String> {
        if raw.is_empty() {
            return Err(bad(
                "trees is empty; a boosted ranker has at least one".to_string()
            ));
        }
        let mut trees = Vec::with_capacity(raw.len());
        for (t, tree) in raw.iter().enumerate() {
            let n = tree.nodes.len();
            if n == 0 {
                return Err(bad(format!("trees[{t}] has no nodes")));
            }
            let mut nodes = Vec::with_capacity(n);
            for (i, node) in tree.nodes.iter().enumerate() {
                let named = |what: String| bad(format!("trees[{t}].nodes[{i}]: {what}"));
                let parsed = match (
                    node.value,
                    node.feature,
                    node.threshold,
                    node.left,
                    node.right,
                ) {
                    (Some(value), None, None, None, None) => {
                        if !value.is_finite() {
                            return Err(named(format!("value {value} is not finite")));
                        }
                        Node::Leaf(value)
                    }
                    (None, Some(input), Some(threshold), Some(left), Some(right)) => {
                        if input >= n_inputs {
                            return Err(named(format!(
                                "feature {input} is out of range; the file lists {n_inputs} inputs"
                            )));
                        }
                        if !threshold.is_finite() {
                            return Err(named(format!("threshold {threshold} is not finite")));
                        }
                        if left >= n || right >= n {
                            return Err(named(format!(
                                "children {left} and {right} have to index the tree's {n} nodes"
                            )));
                        }
                        if left <= i || right <= i {
                            return Err(named(
                                "children have to come after their parent".to_string(),
                            ));
                        }
                        Node::Split {
                            input,
                            threshold,
                            left,
                            right,
                        }
                    }
                    _ => {
                        return Err(named(
                            "a node is a leaf with a value, or a split with feature, \
                             threshold, left and right"
                                .to_string(),
                        ));
                    }
                };
                nodes.push(parsed);
            }
            trees.push(nodes);
        }
        Ok(trees)
    }

    /// Whether this model scores candidates against each other, so a
    /// component's candidates have to be gathered before any of them has a
    /// score ([`round_robin`]).
    pub(crate) fn is_pairwise(&self) -> bool {
        matches!(self.scorer, Scorer::PairBoost { .. })
    }

    /// The ensemble's raw output on one difference vector: the baseline plus
    /// one leaf per tree. Children come after their parent, so the walk ends.
    fn raw_pair(&self, diff: &[f64]) -> f64 {
        let Scorer::PairBoost { baseline, trees } = &self.scorer else {
            unreachable!("raw_pair is the boosted kind's");
        };
        let mut sum = *baseline;
        for tree in trees {
            let mut at = 0;
            loop {
                match &tree[at] {
                    Node::Leaf(value) => {
                        sum += value;
                        break;
                    }
                    Node::Split {
                        input,
                        threshold,
                        left,
                        right,
                    } => {
                        at = if diff[*input] <= *threshold {
                            *left
                        } else {
                            *right
                        };
                    }
                }
            }
        }
        sum
    }

    /// Whether any column comes from the split pass, which decides whether the
    /// per-node tables pay for it.
    fn reads_split(&self) -> bool {
        self.columns.iter().any(|c| c.is_from_split())
    }

    /// The same for the cut pass, which is the more expensive of the two.
    fn reads_cut(&self) -> bool {
        self.columns.iter().any(|c| c.is_from_cut())
    }
}

/// The name the model file spells `feature` as, for a message about it.
fn feature_name(feature: Feature) -> &'static str {
    FEATURE_NAMES
        .iter()
        .find(|(_, known)| *known == feature)
        .map(|&(name, _)| name)
        .expect("every feature is in the name table")
}

// ---------------------------------------------------------------------------
// Scoring one candidate
// ---------------------------------------------------------------------------

/// What each column took over the internal nodes of the tree, in `columns`
/// order, one pass for all of them.
///
/// A column with no value at a node contributes no entry there, which is the
/// four cut columns at the root and nothing else — the cut pass writes no row
/// for it. Everywhere else every internal node contributes.
fn gather(vtree: &Vtree, tables: &Tables, columns: &[Feature]) -> Vec<Vec<f64>> {
    let mut gathered: Vec<Vec<f64>> = vec![Vec::new(); columns.len()];
    for (node, left, right) in vtree.internal_bottomup() {
        for (column, values) in columns.iter().zip(&mut gathered) {
            if column.is_from_cut() && !tables.has_cut_row(node) {
                continue;
            }
            values.push(tables.value(*column, node, left, right));
        }
    }
    gathered
}

/// Structural statistics and what `model` computes for `vtree` against
/// `formula`, sharing their clause and context tables. The linear kind: the
/// intercept, plus each addend of the structural cost at its weight, plus each
/// standardized aggregate at its weight, lower is better. The boosted kind: the
/// raw input vector, which [`round_robin`] turns into a score once the
/// component's candidates are all known.
///
/// # Errors
///
/// [`VitriError::Mismatch`] if `formula` names a variable `vtree` has no leaf
/// for.
pub(crate) fn agg_score(
    vtree: &Vtree,
    formula: &CnfFormula,
    model: &AggModel,
    show_mask: Option<&crate::cnf::ShowMask>,
) -> Result<(VtreeScores, AggScore), VitriError> {
    let (stats, terms, values) = agg_numbers(vtree, formula, model, show_mask)?;
    if model.is_pairwise() {
        let inputs = model
            .inputs
            .iter()
            .map(|input| match *input {
                Input::Term(at) => terms[at],
                Input::Aggregate(at) => values[at],
            })
            .collect();
        return Ok((stats, AggScore::Inputs(inputs)));
    }
    let mut score = model.intercept;
    for (weight, term) in model.terms.iter().zip(&terms) {
        score += weight * term;
    }
    for (entry, value) in model.aggregates.iter().zip(&values) {
        score += entry.weight * ((value - entry.mean) / entry.sd);
    }
    Ok((stats, AggScore::Scalar(score)))
}

/// The boosted kind's scores for one component: each candidate's mean predicted
/// probability of being the larger compile against each sibling, from the
/// input vectors [`agg_score`] produced. A lone candidate scores 0.
/// `families` gives equal total weight to each represented opponent family;
/// `None` gives equal weight to each opponent.
///
/// The ensemble is evaluated on every ordered pair, `n(n-1)` walks of a few
/// hundred shallow trees, which is nothing beside building one candidate.
pub(crate) fn round_robin(
    model: &AggModel,
    inputs: &[&[f64]],
    families: Option<&[&str]>,
) -> Vec<f64> {
    let n = inputs.len();
    if n < 2 {
        return vec![0.0; n];
    }
    let mut family_counts = HashMap::new();
    if let Some(families) = families {
        assert_eq!(families.len(), n);
        for family in families {
            *family_counts.entry(*family).or_insert(0usize) += 1;
        }
    }
    let mut scores = vec![0.0; n];
    let mut diff = vec![0.0; model.inputs.len()];
    for i in 0..n {
        let mut weight_sum = 0.0;
        for j in 0..n {
            if i == j {
                continue;
            }
            for (d, (a, b)) in diff.iter_mut().zip(inputs[i].iter().zip(inputs[j])) {
                *d = a - b;
            }
            let raw = model.raw_pair(&diff);
            let weight = families.map_or(1.0, |families| {
                let opponents =
                    family_counts[families[j]] - usize::from(families[i] == families[j]);
                1.0 / opponents as f64
            });
            scores[i] += weight / (1.0 + (-raw).exp());
            weight_sum += weight;
        }
        scores[i] /= weight_sum;
    }
    scores
}

/// The eleven cost addends and each aggregate's value over `vtree`, in
/// [`AggModel::aggregates`] order: the numbers both kinds read.
///
/// # Errors
///
/// [`VitriError::Mismatch`] if `formula` names a variable `vtree` has no leaf
/// for.
fn agg_numbers(
    vtree: &Vtree,
    formula: &CnfFormula,
    model: &AggModel,
    show_mask: Option<&crate::cnf::ShowMask>,
) -> Result<(VtreeScores, [f64; 11], Vec<f64>), VitriError> {
    super::covered_by(vtree, formula)?;
    let tables = Tables::build(vtree, formula, model.reads_split(), model.reads_cut());
    let peak_show = show_mask.map(|mask| {
        super::context_width_from_high_lca(
            vtree,
            &super::clause_high_lca(vtree, formula),
            Some(mask),
        )
        .into_iter()
        .max()
        .unwrap_or(0)
    });
    let (stats, terms) = VtreeScores::from_tables(vtree, formula, tables.cost_tables(), peak_show);

    let mut gathered = gather(vtree, &tables, &model.columns);

    let mut values = Vec::with_capacity(model.aggregates.len());
    for entry in &model.aggregates {
        let column = &mut gathered[entry.column];
        if column.is_empty() {
            // The fit's table carries this column as NaN over such a tree and
            // drops the row; scoring it against a 0 is the one place this
            // ranker can say something the fit never learned. Said once per
            // process, so a run that hits it is readable and one that hits it
            // on every component is still readable.
            static SAID: OnceLock<()> = OnceLock::new();
            SAID.get_or_init(|| {
                crate::diagnostics::diag!(
                    "[agg-pick] no {} to take the {} of over the {} internal node(s) of this \
                     tree; scoring it as 0 (said once)",
                    feature_name(model.columns[entry.column]),
                    entry.agg_name(),
                    vtree.internal_bottomup().count(),
                );
            });
        }
        // A non-finite aggregate enters as 0 before standardising, which is
        // what the fit did with one.
        let value = entry.agg.of(column);
        values.push(if value.is_finite() { value } else { 0.0 });
    }
    Ok((stats, terms, values))
}

// ---------------------------------------------------------------------------
// The switch
// ---------------------------------------------------------------------------

/// The variable that chooses the ranker. Unset — the default — the portfolio
/// selects on [`DEFAULT_MODEL`]; [`COST_ONLY`] selects on [`super::vtree_cost`]
/// alone, and nothing else in this module runs; a path names another file.
pub(crate) const AGG_VAR: &str = "VITRI_SCORE_AGG";

/// The value of [`AGG_VAR`] that turns the ranker off.
pub(crate) const COST_ONLY: &str = "cost";

/// What the variable's value has to be, quoted in the message a bad one gets.
const AGG_EXPECTED: &str = "`cost`, or the path of an exported whole-tree aggregate ranker in JSON";

/// The ranker the portfolio selects on when [`AGG_VAR`] is unset: a pairwise
/// boosted model of 300 trees over the eleven cost addends and the five
/// aggregates of every column, fitted on the portfolio's own candidates over
/// the model-counting competition benchmarks, each pair labelled by which of
/// the two compiled to the larger diagram.
const DEFAULT_MODEL: &str = include_str!("agg/pair_boost.json");

/// The ranker selection runs under: the one [`AGG_VAR`] names, [`DEFAULT_MODEL`]
/// when it is unset, or `None` under [`COST_ONLY`].
///
/// Each file is read and parsed once per process; a second call for the same
/// path hands back the same ranker, and the shipped one is parsed once.
///
/// # Errors
///
/// [`VitriError::Env`] when the file the variable names cannot be read or is
/// not a ranker this crate can evaluate. A ranker that was asked for and could
/// not be loaded is never quietly dropped.
pub(crate) fn model() -> Result<Option<Arc<AggModel>>, VitriError> {
    let Some(raw) = crate::env::env_raw(AGG_VAR, AGG_EXPECTED)? else {
        return Ok(Some(default_model()));
    };
    if crate::env::is_form(&raw, COST_ONLY) {
        return Ok(None);
    }
    let path = PathBuf::from(raw.trim());
    match load_cached(&path) {
        Ok(model) => Ok(Some(model)),
        Err(reason) => Err(VitriError::env(
            AGG_VAR,
            format!("must be {AGG_EXPECTED}; {reason}"),
        )),
    }
}

/// [`DEFAULT_MODEL`], parsed once per process.
fn default_model() -> Arc<AggModel> {
    static SHIPPED: OnceLock<Arc<AggModel>> = OnceLock::new();
    Arc::clone(SHIPPED.get_or_init(|| {
        Arc::new(
            AggModel::from_json(Path::new("pair_boost.json"), DEFAULT_MODEL)
                .expect("the shipped ranker is a file this crate evaluates"),
        )
    }))
}

/// The variable that narrows the field the ranker chooses from: only the
/// candidates whose cost is within this much of the cost pick's cost are
/// eligible. Unset, the margin is [`DEFAULT_MARGIN`]; [`NO_MARGIN`] makes
/// every candidate eligible.
pub(crate) const MARGIN_VAR: &str = "VITRI_SCORE_AGG_MARGIN";

/// The margin a run under the ranker gets when [`MARGIN_VAR`] is unset. It
/// leaves the ranker its picks on nineteen components in twenty and pins the
/// rest to the cost pick, which is where the ranker has traded a solve for
/// speed.
pub(crate) const DEFAULT_MARGIN: f64 = 10.0;

/// The value of [`MARGIN_VAR`] that ranks every candidate.
pub(crate) const NO_MARGIN: &str = "none";

/// What the margin's value has to be, quoted in the message a bad one gets.
const MARGIN_EXPECTED: &str =
    "a cost margin in the cost's own units, zero or more, or `none` for every candidate";

/// How far above the cost pick's cost a candidate may sit and still be ranked:
/// [`DEFAULT_MARGIN`] when [`MARGIN_VAR`] is unset, `None` when it is
/// [`NO_MARGIN`] or there is no ranker. `ranker_on` is what [`model`] answered:
/// whether this process selects on a ranker at all.
///
/// # Errors
///
/// [`VitriError::Env`] when the margin is set under [`COST_ONLY`], where there
/// is no ranker to narrow, or to something that is not a margin.
pub(crate) fn margin_from_env(ranker_on: bool) -> Result<Option<f64>, VitriError> {
    let raw = crate::env::env_raw(MARGIN_VAR, MARGIN_EXPECTED)?;
    margin_from_value(raw.as_deref(), ranker_on)
}

/// The pure half of [`margin_from_env`].
///
/// # Errors
///
/// [`VitriError::Env`] naming both variables when `raw` is `Some` and
/// `ranker_on` is false, or naming the margin when it does not read as one.
fn margin_from_value(raw: Option<&str>, ranker_on: bool) -> Result<Option<f64>, VitriError> {
    let Some(raw) = raw else {
        return Ok(ranker_on.then_some(DEFAULT_MARGIN));
    };
    if !ranker_on {
        return Err(VitriError::env(
            MARGIN_VAR,
            format!(
                "requires a ranker: it narrows the field the ranker chooses from, and under \
                 {AGG_VAR}={COST_ONLY} the cost picks alone. Unset {MARGIN_VAR}, or set {AGG_VAR} \
                 to a ranker."
            ),
        ));
    }
    if raw.trim() == NO_MARGIN {
        return Ok(None);
    }
    let margin: f64 = crate::env::parse_value(MARGIN_VAR, Some(raw), 0.0, MARGIN_EXPECTED)?;
    if !margin.is_finite() || margin < 0.0 {
        return Err(VitriError::env(
            MARGIN_VAR,
            format!("must be {MARGIN_EXPECTED}; got {raw:?}"),
        ));
    }
    Ok(Some(margin))
}

/// Read and parse `path`, or hand back what an earlier call parsed.
fn load_cached(path: &Path) -> Result<Arc<AggModel>, String> {
    static CACHE: OnceLock<Mutex<HashMap<PathBuf, Arc<AggModel>>>> = OnceLock::new();
    let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new()));
    let mut cache = cache
        .lock()
        .unwrap_or_else(|poisoned| poisoned.into_inner());
    if let Some(model) = cache.get(path) {
        return Ok(Arc::clone(model));
    }
    let text = std::fs::read_to_string(path)
        .map_err(|e| format!("cannot read {}: {e}", path.display()))?;
    let model = Arc::new(AggModel::from_json(path, &text)?);
    cache.insert(path.to_path_buf(), Arc::clone(&model));
    Ok(model)
}

// ---------------------------------------------------------------------------
// Which component the pick line is about
// ---------------------------------------------------------------------------

thread_local! {
    /// Which independent component the build is on, as
    /// [`crate::component::build_vtree_split`] numbers them — which is the same
    /// numbering the written `components/compNNN` files carry. The library has
    /// no component identity of its own, and the pick line has to be joinable
    /// to an offline table by component, so the loop that splits the formula
    /// records the number here; the whole-formula path resets it.
    static COMPONENT: std::cell::Cell<Option<usize>> =
        const { std::cell::Cell::new(None) };
}

/// Record which component the build about to run is on; `None` for a build over
/// a whole formula that was never split.
pub(crate) fn set_component(index: Option<usize>) {
    COMPONENT.with(|slot| slot.set(index));
}

/// What the pick line calls the component it is about.
pub(crate) fn component_label() -> String {
    COMPONENT.with(|slot| match slot.get() {
        Some(index) => format!("comp{index:03}"),
        None => "whole".to_string(),
    })
}

#[cfg(test)]
mod tests;