use crate::cost::CostModel;
use crate::diff::{DiffParams, diff};
use crate::params::ParamError;
use amberfork_model::Run;
use std::collections::BTreeMap;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Consensus {
pub votes: Vec<Option<usize>>,
pub modal_step: Option<usize>,
pub support: usize,
}
impl Consensus {
#[must_use]
pub fn references(&self) -> usize {
self.votes.len()
}
#[must_use]
pub fn forked(&self) -> usize {
self.votes.iter().filter(|v| v.is_some()).count()
}
#[must_use]
pub fn agreement(&self) -> Option<f64> {
let forked = self.forked();
(forked > 0).then(|| self.support as f64 / forked as f64)
}
}
pub fn consensus(
references: &[Run],
suspect: &Run,
cost_model: &impl CostModel,
params: &DiffParams,
) -> Result<Consensus, ParamError> {
let mut votes = Vec::with_capacity(references.len());
for reference in references {
votes.push(diff(reference, suspect, cost_model, params)?.fork_step_observed());
}
let mut tally: BTreeMap<usize, usize> = BTreeMap::new();
for step in votes.iter().flatten() {
*tally.entry(*step).or_default() += 1;
}
let (modal_step, support) = tally
.into_iter()
.fold(
None,
|best: Option<(usize, usize)>, (step, count)| match best {
Some((_, best_count)) if best_count >= count => best,
_ => Some((step, count)),
},
)
.map_or((None, 0), |(step, count)| (Some(step), count));
Ok(Consensus {
votes,
modal_step,
support,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cost::LexicalCost;
use crate::nw::AlignParams;
use amberfork_model::{Outcome, test_support};
fn run(id: &str, outcome: Outcome, outputs: &[&str]) -> Run {
let steps = outputs
.iter()
.enumerate()
.map(|(i, o)| test_support::step(i, "act").text_output(*o).build())
.collect();
test_support::run(id, steps)
.task("find the census figure")
.outcome(outcome)
.build()
}
fn suspect() -> Run {
run(
"bad",
Outcome::Fail,
&[
"plan search for census data",
"search census gov top result",
"fetch blogspot page the city has grown to 9,100,000",
"answer population is 9,100,000",
],
)
}
fn reference_forking_at_2(id: &str) -> Run {
run(
id,
Outcome::Pass,
&[
"plan search for census data",
"search census gov top result",
"fetch census gov page population 8,443,000",
"answer population is 8,443,000",
],
)
}
fn reference_forking_at_1(id: &str) -> Run {
run(
id,
Outcome::Pass,
&[
"plan search for census data",
"consult the almanac index directly",
"read almanac entry population 8,443,000",
"answer population is 8,443,000",
],
)
}
fn tally(references: &[Run], suspect: &Run) -> Consensus {
consensus(references, suspect, &LexicalCost, &DiffParams::default())
.expect("fixtures are under the size guard")
}
#[test]
fn unanimous_references_all_back_one_step() {
let refs = [
reference_forking_at_2("g0"),
reference_forking_at_2("g1"),
reference_forking_at_2("g2"),
];
let c = tally(&refs, &suspect());
assert_eq!(c.votes, vec![Some(2), Some(2), Some(2)]);
assert_eq!(c.modal_step, Some(2));
assert_eq!(c.support, 3);
assert_eq!(c.references(), 3);
assert_eq!(c.agreement(), Some(1.0));
}
#[test]
fn the_plurality_wins_and_the_minority_stays_visible() {
let refs = [
reference_forking_at_2("g0"),
reference_forking_at_1("g1"),
reference_forking_at_2("g2"),
];
let c = tally(&refs, &suspect());
assert_eq!(c.modal_step, Some(2));
assert_eq!(
c.support, 2,
"2 of 3 — not unanimity, and it must not read as one"
);
assert_eq!(
c.votes,
vec![Some(2), Some(1), Some(2)],
"the dissenting reference is still on the record"
);
assert_eq!(c.agreement(), Some(2.0 / 3.0));
}
#[test]
fn a_tie_breaks_to_the_earliest_step_whatever_the_input_order() {
let early = reference_forking_at_1("g_early");
let late = reference_forking_at_2("g_late");
let one_way = tally(&[early.clone(), late.clone()], &suspect());
let other_way = tally(&[late, early], &suspect());
assert_eq!(one_way.modal_step, Some(1), "earliest wins a 1–1 tie");
assert_eq!(one_way.support, 1);
assert_eq!(
one_way.modal_step, other_way.modal_step,
"input order must not decide the answer"
);
assert_eq!(one_way.support, other_way.support);
}
#[test]
fn one_reference_matches_the_pairwise_diff_exactly() {
let suspect = suspect();
let reference = reference_forking_at_2("g0");
let pairwise = diff(&reference, &suspect, &LexicalCost, &DiffParams::default())
.expect("fixtures are under the size guard");
let c = tally(std::slice::from_ref(&reference), &suspect);
assert_eq!(
c.modal_step,
pairwise.fork_step_observed(),
"N=1 consensus is the single-reference answer, unchanged"
);
assert_eq!(c.support, 1);
assert_eq!(c.agreement(), Some(1.0));
}
#[test]
fn references_that_converge_leave_no_modal_fork() {
let suspect = suspect();
let refs = [suspect.clone(), suspect.clone()];
let c = tally(&refs, &suspect);
assert_eq!(c.votes, vec![None, None], "a self-diff converges");
assert_eq!(c.modal_step, None);
assert_eq!(c.support, 0);
assert_eq!(c.forked(), 0);
assert_eq!(
c.agreement(),
None,
"no reference saw a divergence — that is undefined agreement, not 0%"
);
}
#[test]
fn converged_references_do_not_dilute_the_forking_ones() {
let suspect = suspect();
let refs = [
suspect.clone(),
reference_forking_at_2("g1"),
reference_forking_at_2("g2"),
];
let c = tally(&refs, &suspect);
assert_eq!(c.votes, vec![None, Some(2), Some(2)]);
assert_eq!(c.modal_step, Some(2));
assert_eq!(c.references(), 3);
assert_eq!(c.forked(), 2);
assert_eq!(
c.agreement(),
Some(1.0),
"both references that forked agreed; the abstainer is not a dissent"
);
}
#[test]
fn no_references_is_an_empty_consensus_not_a_converged_one() {
let c = tally(&[], &suspect());
assert!(c.votes.is_empty());
assert_eq!(c.modal_step, None);
assert_eq!(c.support, 0);
assert_eq!(
c.references(),
0,
"the caller can tell 'nobody was asked' from 'everybody converged'"
);
assert_eq!(c.agreement(), None);
}
#[test]
fn the_size_guard_propagates_from_any_reference() {
let params = DiffParams {
align: AlignParams {
max_steps: 1,
..AlignParams::default()
},
..DiffParams::default()
};
let refs = [reference_forking_at_2("g0")];
assert_eq!(
consensus(&refs, &suspect(), &LexicalCost, ¶ms).unwrap_err(),
ParamError::StepsExceedMax { steps: 4, max: 1 },
"an unalignable reference is an error, never a silently dropped vote"
);
}
}