Skip to main content

amberfork_align/
consensus.rs

1//! Multi-reference consensus: one suspect run against *many* reference runs (issue #45).
2//!
3//! `diff` answers "where did my run diverge from **this** good run". Agents are
4//! non-deterministic, so a single reference may be a lucky sample and that answer inherits the
5//! sample's luck. Consensus converts the assumption "the reference is representative" into a
6//! measurement: align the suspect against each reference independently, then report the
7//! **modal fork step and how many references voted for it**. "7 of 10 good runs fork here" is a
8//! claim about the suspect; "1 of 1" was a claim about a coin flip.
9//!
10//! Deliberately the *cheap* version: pairwise [`diff`] plus a tally. No partial-order
11//! alignment, no consensus DAG, no clustering — the aggregation lives here and the moat
12//! ([`crate::align`], [`crate::find_fork`]) is untouched. Whether the expensive version is ever
13//! worth building is gated on this one beating single-reference on the dev fixtures.
14
15use crate::cost::CostModel;
16use crate::diff::{DiffParams, diff};
17use crate::params::ParamError;
18use amberfork_model::Run;
19use std::collections::BTreeMap;
20
21/// What N references agree on about one suspect run.
22///
23/// Two absences are distinct and both are honest: `references == 0` (nothing was asked) and
24/// every reference converging (asked, and the suspect looks normal to all of them) both leave
25/// [`Consensus::modal_step`] as `None`. [`Consensus::references`] tells them apart.
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Consensus {
28    /// One entry per reference, in input order: the *suspect*-side step that reference
29    /// localized the fork to ([`amberfork_model::DiffResult::fork_step_observed`]), or `None`
30    /// where that reference converged with the suspect.
31    ///
32    /// Kept per-reference rather than pre-summed so a caller can see the shape of the
33    /// disagreement — a 6/4 split at adjacent steps and a 6/4 split at opposite ends of the
34    /// run are very different situations that one modal number hides.
35    pub votes: Vec<Option<usize>>,
36    /// The step the plurality of *forking* references named, or `None` when none forked.
37    pub modal_step: Option<usize>,
38    /// How many references voted for [`Consensus::modal_step`]. `0` exactly when it is `None`.
39    pub support: usize,
40}
41
42impl Consensus {
43    /// How many references were consulted.
44    #[must_use]
45    pub fn references(&self) -> usize {
46        self.votes.len()
47    }
48
49    /// How many references forked at all. The denominator that makes `support` a rate:
50    /// references that converged never voted, so counting them against the modal step would
51    /// understate agreement among the ones that actually saw a divergence.
52    #[must_use]
53    pub fn forked(&self) -> usize {
54        self.votes.iter().filter(|v| v.is_some()).count()
55    }
56
57    /// Share of *forking* references backing the modal step, in `[0, 1]`; `None` when no
58    /// reference forked (a rate with a zero denominator is not zero, it is undefined).
59    #[must_use]
60    pub fn agreement(&self) -> Option<f64> {
61        let forked = self.forked();
62        (forked > 0).then(|| self.support as f64 / forked as f64)
63    }
64}
65
66/// Diff `suspect` against every run in `references` and tally where they say it forked.
67///
68/// Tie-break is the **lowest step index**, which is not arbitrary: it matches the fork rule's
69/// own bias (first non-sync block that never re-syncs) and points at the earliest plausible
70/// origin, and — unlike "first one seen" — it makes the answer independent of the order the
71/// references were passed in.
72///
73/// # Errors
74/// The first [`ParamError`] any reference produces, references taken in order. A run over the
75/// size guard is a configuration fact about that run, not a vote to be silently dropped.
76pub fn consensus(
77    references: &[Run],
78    suspect: &Run,
79    cost_model: &impl CostModel,
80    params: &DiffParams,
81) -> Result<Consensus, ParamError> {
82    let mut votes = Vec::with_capacity(references.len());
83    for reference in references {
84        votes.push(diff(reference, suspect, cost_model, params)?.fork_step_observed());
85    }
86
87    // BTreeMap, not HashMap: iterating tallies in step order makes `>` alone break ties toward
88    // the lowest step, and keeps the walk deterministic across builds.
89    let mut tally: BTreeMap<usize, usize> = BTreeMap::new();
90    for step in votes.iter().flatten() {
91        *tally.entry(*step).or_default() += 1;
92    }
93    let (modal_step, support) = tally
94        .into_iter()
95        .fold(
96            None,
97            |best: Option<(usize, usize)>, (step, count)| match best {
98                Some((_, best_count)) if best_count >= count => best,
99                _ => Some((step, count)),
100            },
101        )
102        .map_or((None, 0), |(step, count)| (Some(step), count));
103
104    Ok(Consensus {
105        votes,
106        modal_step,
107        support,
108    })
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114    use crate::cost::LexicalCost;
115    use crate::nw::AlignParams;
116    use amberfork_model::{Outcome, test_support};
117
118    fn run(id: &str, outcome: Outcome, outputs: &[&str]) -> Run {
119        let steps = outputs
120            .iter()
121            .enumerate()
122            .map(|(i, o)| test_support::step(i, "act").text_output(*o).build())
123            .collect();
124        test_support::run(id, steps)
125            .task("find the census figure")
126            .outcome(outcome)
127            .build()
128    }
129
130    /// The suspect: a plausible run whose third step picks up a bad source.
131    fn suspect() -> Run {
132        run(
133            "bad",
134            Outcome::Fail,
135            &[
136                "plan search for census data",
137                "search census gov top result",
138                "fetch blogspot page the city has grown to 9,100,000",
139                "answer population is 9,100,000",
140            ],
141        )
142    }
143
144    /// A reference that shares the suspect's first two steps then does the right thing —
145    /// forks the suspect at step 2.
146    fn reference_forking_at_2(id: &str) -> Run {
147        run(
148            id,
149            Outcome::Pass,
150            &[
151                "plan search for census data",
152                "search census gov top result",
153                "fetch census gov page population 8,443,000",
154                "answer population is 8,443,000",
155            ],
156        )
157    }
158
159    /// A reference that diverges a step earlier — forks the suspect at step 1.
160    fn reference_forking_at_1(id: &str) -> Run {
161        run(
162            id,
163            Outcome::Pass,
164            &[
165                "plan search for census data",
166                "consult the almanac index directly",
167                "read almanac entry population 8,443,000",
168                "answer population is 8,443,000",
169            ],
170        )
171    }
172
173    fn tally(references: &[Run], suspect: &Run) -> Consensus {
174        consensus(references, suspect, &LexicalCost, &DiffParams::default())
175            .expect("fixtures are under the size guard")
176    }
177
178    #[test]
179    fn unanimous_references_all_back_one_step() {
180        let refs = [
181            reference_forking_at_2("g0"),
182            reference_forking_at_2("g1"),
183            reference_forking_at_2("g2"),
184        ];
185        let c = tally(&refs, &suspect());
186
187        assert_eq!(c.votes, vec![Some(2), Some(2), Some(2)]);
188        assert_eq!(c.modal_step, Some(2));
189        assert_eq!(c.support, 3);
190        assert_eq!(c.references(), 3);
191        assert_eq!(c.agreement(), Some(1.0));
192    }
193
194    #[test]
195    fn the_plurality_wins_and_the_minority_stays_visible() {
196        let refs = [
197            reference_forking_at_2("g0"),
198            reference_forking_at_1("g1"),
199            reference_forking_at_2("g2"),
200        ];
201        let c = tally(&refs, &suspect());
202
203        assert_eq!(c.modal_step, Some(2));
204        assert_eq!(
205            c.support, 2,
206            "2 of 3 — not unanimity, and it must not read as one"
207        );
208        assert_eq!(
209            c.votes,
210            vec![Some(2), Some(1), Some(2)],
211            "the dissenting reference is still on the record"
212        );
213        assert_eq!(c.agreement(), Some(2.0 / 3.0));
214    }
215
216    #[test]
217    fn a_tie_breaks_to_the_earliest_step_whatever_the_input_order() {
218        let early = reference_forking_at_1("g_early");
219        let late = reference_forking_at_2("g_late");
220
221        let one_way = tally(&[early.clone(), late.clone()], &suspect());
222        let other_way = tally(&[late, early], &suspect());
223
224        assert_eq!(one_way.modal_step, Some(1), "earliest wins a 1–1 tie");
225        assert_eq!(one_way.support, 1);
226        assert_eq!(
227            one_way.modal_step, other_way.modal_step,
228            "input order must not decide the answer"
229        );
230        assert_eq!(one_way.support, other_way.support);
231    }
232
233    #[test]
234    fn one_reference_matches_the_pairwise_diff_exactly() {
235        let suspect = suspect();
236        let reference = reference_forking_at_2("g0");
237        let pairwise = diff(&reference, &suspect, &LexicalCost, &DiffParams::default())
238            .expect("fixtures are under the size guard");
239
240        let c = tally(std::slice::from_ref(&reference), &suspect);
241
242        assert_eq!(
243            c.modal_step,
244            pairwise.fork_step_observed(),
245            "N=1 consensus is the single-reference answer, unchanged"
246        );
247        assert_eq!(c.support, 1);
248        assert_eq!(c.agreement(), Some(1.0));
249    }
250
251    #[test]
252    fn references_that_converge_leave_no_modal_fork() {
253        let suspect = suspect();
254        let refs = [suspect.clone(), suspect.clone()];
255        let c = tally(&refs, &suspect);
256
257        assert_eq!(c.votes, vec![None, None], "a self-diff converges");
258        assert_eq!(c.modal_step, None);
259        assert_eq!(c.support, 0);
260        assert_eq!(c.forked(), 0);
261        assert_eq!(
262            c.agreement(),
263            None,
264            "no reference saw a divergence — that is undefined agreement, not 0%"
265        );
266    }
267
268    #[test]
269    fn converged_references_do_not_dilute_the_forking_ones() {
270        let suspect = suspect();
271        let refs = [
272            suspect.clone(),
273            reference_forking_at_2("g1"),
274            reference_forking_at_2("g2"),
275        ];
276        let c = tally(&refs, &suspect);
277
278        assert_eq!(c.votes, vec![None, Some(2), Some(2)]);
279        assert_eq!(c.modal_step, Some(2));
280        assert_eq!(c.references(), 3);
281        assert_eq!(c.forked(), 2);
282        assert_eq!(
283            c.agreement(),
284            Some(1.0),
285            "both references that forked agreed; the abstainer is not a dissent"
286        );
287    }
288
289    #[test]
290    fn no_references_is_an_empty_consensus_not_a_converged_one() {
291        let c = tally(&[], &suspect());
292
293        assert!(c.votes.is_empty());
294        assert_eq!(c.modal_step, None);
295        assert_eq!(c.support, 0);
296        assert_eq!(
297            c.references(),
298            0,
299            "the caller can tell 'nobody was asked' from 'everybody converged'"
300        );
301        assert_eq!(c.agreement(), None);
302    }
303
304    #[test]
305    fn the_size_guard_propagates_from_any_reference() {
306        let params = DiffParams {
307            align: AlignParams {
308                max_steps: 1,
309                ..AlignParams::default()
310            },
311            ..DiffParams::default()
312        };
313        let refs = [reference_forking_at_2("g0")];
314
315        assert_eq!(
316            consensus(&refs, &suspect(), &LexicalCost, &params).unwrap_err(),
317            ParamError::StepsExceedMax { steps: 4, max: 1 },
318            "an unalignable reference is an error, never a silently dropped vote"
319        );
320    }
321}