use crate::candidates::CandidateSet;
use crate::cnf::CnfFormula;
use crate::decompose::{
BuildLimits, MAX_GOATD_CANDIDATES, SelectionCtx, SelectionObjective, TraceLevel,
};
use crate::diagnostics::diag;
use crate::error::VitriError;
use crate::score::VtreeScores;
use crate::score::agg::component_label;
use crate::spec::{SelectionRecord, VtreeArtifacts};
use std::sync::Arc;
use super::catalog::{
CatalogEntry, Derived, Gate, Incumbent, Inputs, PORTFOLIO_HEAVY_MAX_VARS, RunState,
ScoredCandidate, TraceRow, build_fc_inc, build_fc_pri, build_force, build_goatd,
build_goatd_primal, build_guided_bisect, build_hypergraph_bisect, candidate_spec, gate_force,
gate_goatd, gate_guided_bisect, gate_hypergraph_bisect, outspent, work_ms_since,
};
const LAST_ATTEMPT_MS: i64 = 1_000;
pub(super) fn limits_report(
skipped: &[&'static str],
spent: std::time::Duration,
) -> crate::decompose::BuildLimitsReport {
crate::decompose::BuildLimitsReport {
truncated_builds: u32::from(!skipped.is_empty()),
complete_builds: u32::from(skipped.is_empty()),
spent_ms: spent.as_millis() as u64,
skipped: skipped.iter().map(|n| (*n).to_string()).collect(),
}
}
fn check_candidate_name(name: &str) -> Result<(), VitriError> {
let names = super::PortfolioKnobs::candidate_names();
if catalog().iter().any(|c| c.name == name) || names.iter().any(|n| n == name) {
return Ok(());
}
Err(VitriError::config(format!(
"no portfolio candidate is named {name:?}; the catalog builds {}",
names.join(", "),
)))
}
struct MeasureBuild<'a> {
started: std::time::Instant,
history: &'a super::PortfolioBuildHistory,
}
impl<'a> MeasureBuild<'a> {
fn new(history: &'a super::PortfolioBuildHistory) -> Self {
Self {
started: std::time::Instant::now(),
history,
}
}
}
impl Drop for MeasureBuild<'_> {
fn drop(&mut self) {
self.history
.record(self.started.elapsed().as_millis() as u64);
}
}
pub(super) fn select_peak_band(cands: &[ScoredCandidate], rel_tol: f64) -> &ScoredCandidate {
let min_peak = cands.iter().map(|c| c.sel_metric).fold(f64::MAX, f64::min);
let band = min_peak * (1.0 + rel_tol);
cands
.iter()
.filter(|c| c.sel_metric <= band)
.min_by(|a, b| {
a.stats
.clause_load_stddev
.partial_cmp(&b.stats.clause_load_stddev)
.unwrap_or(std::cmp::Ordering::Equal)
})
.expect("min_peak member is always within band")
}
fn greedy_pick(
cands: &[ScoredCandidate],
of: impl Fn(&ScoredCandidate) -> f64,
) -> &ScoredCandidate {
let at = greedy_index(cands.iter().map(of))
.expect("the caller has already refused an empty catalog");
&cands[at]
}
pub(super) fn greedy_index(values: impl IntoIterator<Item = f64>) -> Option<usize> {
let mut best = None;
let mut best_value = f64::MAX;
for (i, value) in values.into_iter().enumerate() {
if best.is_none() {
best = Some(0);
}
if value < best_value {
best = Some(i);
best_value = value;
}
}
best
}
pub(super) fn select_agg(cands: &[ScoredCandidate], margin: Option<f64>) -> &ScoredCandidate {
let agg_of = |c: &ScoredCandidate| {
c.agg
.as_ref()
.and_then(crate::score::agg::AggScore::scalar)
.expect("the ranker scores every candidate or none of them, before selection")
};
let by_cost = greedy_pick(cands, |c| c.stats.cost);
let eligible = |c: &ScoredCandidate| match margin {
None => true,
Some(m) => std::ptr::eq(c, by_cost) || c.stats.cost <= by_cost.stats.cost + m,
};
let picked = greedy_pick(cands, |c| if eligible(c) { agg_of(c) } else { f64::NAN });
crate::diagnostics::diag!(
"[agg-pick] {component} picked {pspec} agg={pagg:.6} cost={pcost:.6} \
margin={margin} eligible={k}/{n} ; cost pick {cspec} agg={cagg:.6} cost={ccost:.6}",
component = component_label(),
pspec = candidate_spec(picked.name, picked.param),
pagg = agg_of(picked),
pcost = picked.stats.cost,
margin = margin.map_or_else(|| "-".to_string(), |m| format!("{m:.6}")),
k = cands.iter().filter(|c| eligible(c)).count(),
n = cands.len(),
cspec = candidate_spec(by_cost.name, by_cost.param),
cagg = agg_of(by_cost),
ccost = by_cost.stats.cost,
);
picked
}
pub(super) fn catalog() -> Vec<CatalogEntry> {
vec![
CatalogEntry {
name: "flowcutter-incidence",
param: None,
offers: 1,
td_based: true,
gate: Gate::Always,
build: build_fc_inc,
},
CatalogEntry {
name: "flowcutter-primal",
param: None,
offers: 1,
td_based: true,
gate: Gate::Always,
build: build_fc_pri,
},
CatalogEntry {
name: "goatd-incidence",
param: None,
offers: MAX_GOATD_CANDIDATES,
td_based: true,
gate: Gate::FromInputs(gate_goatd),
build: build_goatd,
},
CatalogEntry {
name: "goatd-primal",
param: None,
offers: MAX_GOATD_CANDIDATES,
td_based: true,
gate: Gate::FromInputs(gate_goatd),
build: build_goatd_primal,
},
CatalogEntry {
name: "force",
param: None,
offers: 1,
td_based: false,
gate: Gate::FromInputs(gate_force),
build: build_force,
},
CatalogEntry {
name: "hypergraph-bisect",
param: Some("imbalance=0.40"),
offers: 1,
td_based: false,
gate: Gate::FromDerived(gate_hypergraph_bisect),
build: build_hypergraph_bisect,
},
CatalogEntry {
name: "guided-bisect",
param: None,
offers: 1,
td_based: false,
gate: Gate::FromDerived(gate_guided_bisect),
build: build_guided_bisect,
},
]
}
pub(super) fn catalog_with_knobs(skip: &[&'static str]) -> Vec<CatalogEntry> {
catalog()
.into_iter()
.filter(|entry| !skip.contains(&entry.name))
.collect()
}
pub(crate) fn vtree_from_portfolio(
formula: &CnfFormula,
steps: i64,
iters: i32,
reading: crate::decompose::Reading,
ctx: &SelectionCtx,
limits: &BuildLimits,
) -> Result<VtreeArtifacts, VitriError> {
let history = &ctx.portfolio.build_history;
let _measured = MeasureBuild::new(history);
let seed = ctx.portfolio.seed;
let num_vars = formula.num_vars;
if num_vars == 0 {
return Err(VitriError::construction(
"portfolio",
crate::decompose::EMPTY_FORMULA,
));
}
if let Some(prefer) = &ctx.portfolio.prefer {
check_candidate_name(prefer.name())?;
}
let effort_scale = crate::budget::vtree_effort_scale(limits.budget_ms);
let fc_steps_eff = effort_scale.sqrt();
let fc_iters_eff = effort_scale.sqrt();
let reduced_steps = (if num_vars <= 2000 {
steps.min(200_000)
} else {
steps.min(50_000)
} as f64
* fc_steps_eff) as i64;
let iters = ((iters as f64) * fc_iters_eff).round().max(1.0) as i32;
let peak_mode = ctx.objective.is_peak();
let trace = ctx.portfolio.trace != TraceLevel::Off;
let trace_all = ctx.portfolio.trace == TraceLevel::All;
let flowcutter_cap_ms = if peak_mode && num_vars > 2000 {
ctx.portfolio.flowcutter_cap_ms
} else {
None
};
let t_build_real = std::time::Instant::now();
let t_build = crate::decompose::meter::now();
let rank_metric = match ctx.objective {
SelectionObjective::ClauseBalance => crate::candidates::CandidateRankMetric::Cost,
SelectionObjective::PeakWidthShow(_) => {
crate::candidates::CandidateRankMetric::PeakContextWidthShow
}
SelectionObjective::PeakWidthAll => {
crate::candidates::CandidateRankMetric::PeakContextWidthAll
}
};
let loaded_agg = if ctx.portfolio.ranker {
crate::score::agg::model()?
} else {
None
};
let agg_margin = if ctx.portfolio.ranker {
crate::score::agg::margin_from_env(loaded_agg.is_some())?
} else {
None
};
if peak_mode && loaded_agg.is_some() {
diag!(
"[agg-pick] {} projected selection; the ranker does not decide this component",
component_label(),
);
}
let score_agg = if peak_mode {
None
} else {
loaded_agg.as_deref()
};
let inp = Inputs {
formula,
source_profile: ctx.source_profile,
seed,
peak_mode,
show_mask: ctx.objective.show_mask(),
trace,
flowcutter_cap_ms,
t_build,
deadline: limits.deadline,
candidate_capacity: limits.candidates,
peak_tolerance: ctx.portfolio.peak_tolerance,
goatd: ctx.goatd,
effort_scale,
rank_metric,
reading,
conversion_trace: ctx.conversion.trace,
prefer: ctx.portfolio.prefer.as_ref(),
score_agg,
};
let mut run = RunState::new(reduced_steps, iters);
let entry_budget_ms = inp.remaining_ms();
let mut derived: Option<Derived> = None;
let catalog = catalog_with_knobs(&ctx.portfolio.skip);
let left_ms = inp.remaining_ms();
let measured = history.last_build_ms();
if !crate::decompose::meter::is_armed() && outspent(left_ms, measured) {
diag!(
"[portfolio] capped by the last build here ({measured}ms measured, {left}ms left), uncapped policy wanted {wanted}ms",
measured = measured.unwrap_or(0),
left = left_ms.unwrap_or(0),
wanted = crate::budget::vtree_budget_ms(left_ms.unwrap_or(0).max(0) as u64),
);
run.behind_schedule = true;
}
let mut skipped: Vec<&'static str> = Vec::new();
let mut last_attempt = false;
for (i, c) in catalog.iter().enumerate() {
if inp.out_of_time() {
if run.best.vtree.is_none() && run.cands.is_empty() {
diag!(
"[portfolio] deadline spent with nothing built; {} gets {LAST_ATTEMPT_MS}ms",
c.name,
);
last_attempt = true;
} else {
skipped.extend(catalog[i..].iter().map(|c| c.name));
break;
}
}
if last_attempt {
run.cand_cap_ms = Some(LAST_ATTEMPT_MS);
run.cand_wall_ms = Some(LAST_ATTEMPT_MS);
run.behind_schedule = true;
} else {
run.cand_cap_ms = inp.fair_share_ms(catalog.len() - i);
run.cand_wall_ms = inp.remaining_ms().map(|r| r.max(1));
}
let slice_start = crate::decompose::meter::now();
let open = match c.gate {
Gate::Always => true,
Gate::FromInputs(gate) => gate(&inp),
Gate::FromDerived(gate) => gate(
&inp,
derived.get_or_insert_with(|| Derived::compute(&inp, &run)),
),
};
if open {
for (index, built) in (c.build)(&inp, &mut run).into_iter().enumerate() {
run.fold(&inp, c, index, built);
}
}
if last_attempt {
skipped.extend(catalog[i + 1..].iter().map(|c| c.name));
break;
}
if run
.cand_cap_ms
.is_some_and(|cap| (work_ms_since(slice_start) as i64) > cap)
{
run.behind_schedule = true;
}
}
let RunState {
mut best,
mut trace_rows,
mut cands,
hypergraph_bisect_040_built,
preferred,
..
} = run;
let candidate_capacity = inp.candidate_capacity;
if trace_all && num_vars <= PORTFOLIO_HEAVY_MAX_VARS {
trace_rows.extend(trace_hg_bisect_family(
formula,
effort_scale,
hypergraph_bisect_040_built,
)?);
}
if let Some(model) = score_agg
&& model.is_pairwise()
{
let inputs: Vec<&[f64]> = cands
.iter()
.map(|c| match &c.agg {
Some(crate::score::agg::AggScore::Inputs(v)) => v.as_slice(),
_ => unreachable!("the boosted ranker leaves its inputs on every candidate"),
})
.collect();
let families = match ctx.portfolio.pairwise_weighting {
super::PairwiseWeighting::Candidate => None,
super::PairwiseWeighting::Family => {
Some(cands.iter().map(|c| c.name).collect::<Vec<_>>())
}
};
let scores = crate::score::agg::round_robin(model, &inputs, families.as_deref());
for (c, s) in cands.iter_mut().zip(scores) {
c.agg = Some(crate::score::agg::AggScore::Scalar(s));
}
}
if score_agg.is_some() && !cands.is_empty() {
let picked = select_agg(&cands, agg_margin);
best.adopt(
&picked.stats,
Arc::clone(&picked.vtree),
picked.meta.clone(),
picked.name,
picked.param,
);
}
if peak_mode && !cands.is_empty() {
let rel_tol = inp.peak_tolerance;
let pick = select_peak_band(&cands, rel_tol);
best.adopt(
&pick.stats,
Arc::clone(&pick.vtree),
pick.meta.clone(),
pick.name,
pick.param,
);
}
if let Some(prefer) = &ctx.portfolio.prefer {
match preferred {
Some(c) => best.adopt(&c.stats, c.vtree, c.meta, c.name, c.param),
None if prefer.is_required() => {
return Err(VitriError::construction(
"portfolio",
format!(
"the required candidate {} did not build over this formula",
prefer.name()
),
));
}
None => diag!(
"[portfolio] preferred candidate {} did not build; selecting on score",
prefer.name(),
),
}
}
let mut candidate_set = CandidateSet::default();
if candidate_capacity > 1
&& let Some(winner) = best.vtree.as_ref()
{
let scored: Vec<crate::candidates::ScoredVtree> = cands
.iter()
.map(|c| crate::candidates::ScoredVtree {
built_by: candidate_spec(c.name, c.param),
vtree: Arc::clone(&c.vtree),
scores: c.stats,
})
.collect();
candidate_set =
crate::candidates::from_scored(scored, winner, rank_metric, candidate_capacity);
}
diag!(
"[portfolio] wall_ms={wall} vars={num_vars} budget_ms={budget} skip={skip}",
wall = t_build_real.elapsed().as_millis(),
budget = entry_budget_ms
.map(|b| b.to_string())
.unwrap_or_else(|| "-".to_string()),
skip = if skipped.is_empty() {
"-".to_string()
} else {
skipped.join(",")
},
);
let report = limits_report(&skipped, t_build_real.elapsed());
let winner = candidate_spec(best.name, best.param);
report_selection(
&best,
&winner,
selection_metric(peak_mode, score_agg.is_some()),
trace,
&trace_rows,
derived.as_ref(),
num_vars,
);
let vtree = best
.vtree
.ok_or_else(|| VitriError::construction("portfolio", "every candidate failed"))?;
let scores = best
.scores
.expect("a selected portfolio vtree has already been scored");
history.record_winner(&winner, scores);
Ok(VtreeArtifacts {
vtree,
selection: SelectionRecord {
winning_spec: Some(winner),
scores: Some(scores),
td_meta: best.meta,
},
candidate_set,
limits: report,
})
}
fn trace_hg_bisect_family(
formula: &CnfFormula,
effort_scale: f64,
already_built: bool,
) -> Result<Vec<TraceRow>, VitriError> {
let mut rows = Vec::new();
for &imb in &[0.03_f64, 0.10, 0.20, 0.30, 0.40] {
if (imb - crate::decompose::multilevel_hg_bisect::IMBALANCE_PORTFOLIO_RELAXED).abs() < 1e-9
&& already_built
{
continue;
}
let dials = crate::decompose::BisectDials {
imbalance: imb,
base_seed: 0,
effort_scale,
};
if let Ok(v) = crate::decompose::multilevel_hg_bisect::vtree_from_hg_bisect(formula, dials)
{
let scores = VtreeScores::compute(&v, formula, None)?;
rows.push(TraceRow::from_scores(
"hypergraph-bisect",
format!("{imb:.2}"),
&scores,
false,
));
}
}
Ok(rows)
}
fn selection_metric(peak_mode: bool, agg: bool) -> &'static str {
match (peak_mode, agg) {
(true, _) => "peak-width",
(false, true) => "agg",
(false, false) => "cost",
}
}
fn report_selection(
best: &Incumbent,
winner: &str,
sel_metric: &str,
trace: bool,
trace_rows: &[TraceRow],
derived: Option<&Derived>,
num_vars: u32,
) {
diag!(
"[portfolio] selected: {winner} (metric={sel_metric}, stddev={stddev:.2}, cost={cost:.2})",
stddev = best.stddev,
cost = best.cost,
);
if trace {
for row in trace_rows {
let adopted = row.family == best.name;
diag!(
"[portfolio-trace] cand family={fam} param={param} stddev={sd:.4} mcl={mcl} peak={peak} cost={cost:.4} built={} adopted={}",
row.built as u8,
adopted as u8,
fam = row.family,
param = row.param,
sd = row.stddev,
mcl = row.mcl,
peak = row.peak_context_width_all,
cost = row.cost,
);
}
diag!(
"[portfolio-trace] pick name={name} stddev={stddev:.4} coloring_like={} gen_gate={} num_vars={num_vars}",
derived.is_some_and(|d| d.coloring_like) as u8,
derived.is_some_and(|d| d.hypergraph_bisect_gen_gate) as u8,
name = best.name,
stddev = best.stddev,
);
}
}