1use crate::cost::CostModel;
16use crate::diff::{DiffParams, diff};
17use crate::params::ParamError;
18use amberfork_model::Run;
19use std::collections::BTreeMap;
20
21#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Consensus {
28 pub votes: Vec<Option<usize>>,
36 pub modal_step: Option<usize>,
38 pub support: usize,
40}
41
42impl Consensus {
43 #[must_use]
45 pub fn references(&self) -> usize {
46 self.votes.len()
47 }
48
49 #[must_use]
53 pub fn forked(&self) -> usize {
54 self.votes.iter().filter(|v| v.is_some()).count()
55 }
56
57 #[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
66pub 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 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 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 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 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, ¶ms).unwrap_err(),
317 ParamError::StepsExceedMax { steps: 4, max: 1 },
318 "an unalignable reference is an error, never a silently dropped vote"
319 );
320 }
321}