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};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum Aggregate {
Max,
Mean,
P90,
P99,
Lse,
}
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)
}
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),
}
}
}
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)
}
}
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()
}
#[derive(serde::Deserialize)]
struct RawModel {
kind: String,
#[serde(default)]
intercept: f64,
#[serde(default)]
terms: BTreeMap<String, f64>,
#[serde(default)]
features: Vec<RawFeature>,
#[serde(default)]
baseline: f64,
#[serde(default)]
inputs: Vec<RawInput>,
#[serde(default)]
trees: Vec<RawTree>,
}
#[derive(serde::Deserialize)]
struct RawInput {
term: Option<String>,
column: Option<String>,
agg: Option<String>,
}
#[derive(serde::Deserialize)]
struct RawTree {
nodes: Vec<RawNode>,
}
#[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,
}
const LINEAR_KIND: &str = "agg-linear";
const BOOST_KIND: &str = "agg-pair-boost";
#[derive(Clone, Debug)]
enum Node {
Leaf(f64),
Split {
input: usize,
threshold: f64,
left: usize,
right: usize,
},
}
#[derive(Clone, Copy, Debug)]
enum Input {
Term(usize),
Aggregate(usize),
}
enum Scorer {
Linear,
PairBoost {
baseline: f64,
trees: Vec<Vec<Node>>,
},
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) enum AggScore {
Scalar(f64),
Inputs(Vec<f64>),
}
impl AggScore {
pub(crate) fn scalar(&self) -> Option<f64> {
match self {
AggScore::Scalar(s) => Some(*s),
AggScore::Inputs(_) => None,
}
}
}
struct AggTerm {
column: usize,
agg: Aggregate,
mean: f64,
sd: f64,
weight: f64,
}
impl AggTerm {
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")
}
}
pub(crate) struct AggModel {
intercept: f64,
terms: [f64; 11],
columns: Vec<Feature>,
aggregates: Vec<AggTerm>,
inputs: Vec<Input>,
scorer: Scorer,
}
impl AggModel {
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;
}
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)));
}
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,
})
}
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")))
}
}
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)
}
pub(crate) fn is_pairwise(&self) -> bool {
matches!(self.scorer, Scorer::PairBoost { .. })
}
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
}
fn reads_split(&self) -> bool {
self.columns.iter().any(|c| c.is_from_split())
}
fn reads_cut(&self) -> bool {
self.columns.iter().any(|c| c.is_from_cut())
}
}
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")
}
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
}
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)))
}
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
}
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() {
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(),
);
});
}
let value = entry.agg.of(column);
values.push(if value.is_finite() { value } else { 0.0 });
}
Ok((stats, terms, values))
}
pub(crate) const AGG_VAR: &str = "VITRI_SCORE_AGG";
pub(crate) const COST_ONLY: &str = "cost";
const AGG_EXPECTED: &str = "`cost`, or the path of an exported whole-tree aggregate ranker in JSON";
const DEFAULT_MODEL: &str = include_str!("agg/pair_boost.json");
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}"),
)),
}
}
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"),
)
}))
}
pub(crate) const MARGIN_VAR: &str = "VITRI_SCORE_AGG_MARGIN";
pub(crate) const DEFAULT_MARGIN: f64 = 10.0;
pub(crate) const NO_MARGIN: &str = "none";
const MARGIN_EXPECTED: &str =
"a cost margin in the cost's own units, zero or more, or `none` for every candidate";
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)
}
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))
}
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)
}
thread_local! {
static COMPONENT: std::cell::Cell<Option<usize>> =
const { std::cell::Cell::new(None) };
}
pub(crate) fn set_component(index: Option<usize>) {
COMPONENT.with(|slot| slot.set(index));
}
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;