Skip to main content

vissue_core/
consensus.rs

1//! DeGroot consensus over the ballots on one issue.
2//!
3//! A tally counts. Counting is the right answer when every voter is worth the
4//! same, and agents are not: a maintainer, a reviewer that has been wrong twice,
5//! and a fresh worker all cast one ballot each, and a plurality reports them as
6//! three equal opinions. `vote` already refuses to call a plurality agreement,
7//! but it has nothing to say about *whose* agreement it is.
8//!
9//! DeGroot's model (1974) is the standard answer and is one line: each agent holds
10//! an opinion, listens to the agents it trusts, and replaces its opinion with the
11//! weighted average of theirs. Written as a matrix, `x(t+1) = W x(t)` with `W`
12//! row-stochastic. Where that iteration settles is the group's position, and it
13//! is not the mean unless the trust is symmetric.
14//!
15//! Three things come out of it that a count cannot give:
16//!
17//! - the limit itself, which weights each ballot by how much the group actually
18//!   listens to the agent that cast it;
19//! - social power, the left Perron vector `π` of `W`, which says how much each
20//!   agent moved the result: the limit is `πᵀ x(0)`;
21//! - the failure to reach one. `W` converges to agreement only when the trust
22//!   graph has a single closed group every agent can reach (Berger, 1981). Two teams
23//!   that cite only each other never converge, and that is a fact about the team
24//!   worth reporting rather than a number worth averaging.
25//!
26//! The opinion here is a distribution over the choices already on the ballots, so
27//! nothing new has to be cast: an agent that voted `ship` starts at one on
28//! `ship`, and the limit is how much of the group's weight ends up on each
29//! option. With no trust configured every agent listens to every other equally,
30//! `W` is doubly stochastic, `π` is uniform, and the consensus is the tally as a
31//! fraction. Configuration only ever moves weight away from that.
32//!
33//! M. H. DeGroot, "Reaching a Consensus", J. Am. Stat. Assoc. 69(345), 1974.
34//!
35//! R. A. Berger, "A necessary and sufficient condition for reaching a consensus
36//! using DeGroot's method", J. Am. Stat. Assoc. 76(374), 1981.
37
38use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
39
40use serde::{Deserialize, Serialize};
41
42use petgraph::algo::kosaraju_scc;
43use petgraph::graph::{DiGraph, NodeIndex};
44
45use crate::config::ConsensusSection;
46use crate::ops::Ballot;
47
48/// Where the influence matrix came from, for a reader wondering why a result
49/// looks the way it does.
50#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "lowercase")]
52pub enum TrustSource {
53    /// No row in the configuration named any agent that voted.
54    Default,
55    /// At least one voting agent had a configured row.
56    Configured,
57}
58
59/// Whether the iteration settled, and on what.
60#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum Settling {
63    /// Every agent holds the same opinion: a consensus.
64    Agreed,
65    /// The iteration reached a fixed point on which agents still differ, which
66    /// means the trust graph holds more than one closed group.
67    Split,
68    /// No fixed point inside the iteration budget: a periodic trust graph, which
69    /// is what a pair that listen only to each other and not at all to
70    /// themselves produce.
71    Oscillating,
72    /// The agents settled while still holding different opinions, because each
73    /// stayed partly anchored to the ballot it cast.
74    ///
75    /// Not a failure and not the same thing as a split. Under Friedkin and
76    /// Johnsen the persistent disagreement *is* the result: reporting one
77    /// number for the group would be reporting a position none of them holds.
78    Anchored,
79}
80
81/// One agent's row of the result.
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct AgentLimit {
84    /// Identity that cast the ballot.
85    pub agent: String,
86    /// The choice it voted for.
87    pub voted: String,
88    /// Where that agent's opinion ended up, aligned with [`Outcome::choices`].
89    /// The opinion it started with, when the trust graph never settles.
90    pub limit: Vec<f64>,
91    /// How much of the consensus this agent's ballot accounts for, when the
92    /// group agreed. `None` when it did not, because a split group has no single
93    /// weighting to report.
94    pub power: Option<f64>,
95    /// How far this agent was allowed to move off its own ballot.
96    ///
97    /// Per agent rather than on the outcome, because Friedkin and Johnsen's
98    /// susceptibility is a diagonal: two agents in one run can hold different
99    /// values, and the row is where a reader looks to see which.
100    pub susceptibility: f64,
101}
102
103/// The result of running DeGroot over one issue's ballots.
104#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105pub struct Outcome {
106    /// Distinct choices, sorted, indexing every `limit` vector.
107    pub choices: Vec<String>,
108    /// One row per voting agent, sorted by identity.
109    pub agents: Vec<AgentLimit>,
110    /// Whether the iteration settled, and on what.
111    pub settling: Settling,
112    /// The shared limit when the group agreed, aligned with `choices`.
113    pub consensus: Option<Vec<f64>>,
114    /// Agents grouped by the opinion they settled on, when they did not agree.
115    pub factions: Vec<Vec<String>>,
116    /// Rounds the iteration took.
117    pub rounds: usize,
118    /// Whether the iteration stopped because it ran out of rounds rather than
119    /// because it settled. The shares are then an estimate, not the limit.
120    pub budget_reached: bool,
121    /// Whether any voting agent had a configured trust row.
122    pub trust: TrustSource,
123    /// The susceptibility an agent gets when nothing names it. One is DeGroot;
124    /// below one is Friedkin and Johnsen. A named agent carries its own on
125    /// [`AgentLimit::susceptibility`].
126    pub susceptibility: f64,
127    /// The largest gap left between any two agents on any one choice.
128    ///
129    /// Zero within tolerance when they agreed. Under an anchor it is the
130    /// disagreement the group keeps, which is the quantity worth reading.
131    pub spread: f64,
132}
133
134impl Outcome {
135    /// The winning choice and its share, when the group agreed and one choice
136    /// leads.
137    ///
138    /// `None` on a split, on an oscillation, and on an exact tie inside a
139    /// consensus, because reporting the first of two equal options as the
140    /// group's position is how a coin toss gets recorded as agreement.
141    #[must_use]
142    pub fn leader(&self) -> Option<(&str, f64)> {
143        let consensus = self.consensus.as_ref()?;
144        let mut ranked: Vec<(usize, f64)> = consensus.iter().copied().enumerate().collect();
145        ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
146        let (top, share) = *ranked.first()?;
147        if ranked.len() > 1 && (ranked[1].1 - share).abs() < TIE_EPS {
148            return None;
149        }
150        Some((self.choices[top].as_str(), share))
151    }
152}
153
154impl Outcome {
155    /// Whether a gate over this issue should pass.
156    ///
157    /// True only when the group agreed and one choice leads. A plurality, a
158    /// tie, a split and an oscillation are all cases where acting on the number
159    /// would be acting on agreement that is not there, which is what the verb
160    /// exists to make visible.
161    #[must_use]
162    pub fn settled(&self) -> bool {
163        self.settling == Settling::Agreed && self.leader().is_some()
164    }
165}
166
167/// Two shares this close are a tie rather than a lead.
168///
169/// Coarser than the settling tolerance on purpose: the question is whether a
170/// reader would call the result a win, and a lead in the twelfth decimal is an
171/// artefact of the arithmetic rather than a position the group holds.
172const TIE_EPS: f64 = 1e-6;
173
174/// Settle `ballots` under `cfg`.
175///
176/// DeGroot when `cfg.susceptibility` is one, which is the default: every agent
177/// gives up its own starting position and the group converges on a single
178/// number. Below one it is Friedkin and Johnsen's generalisation, `x(t+1) = λ W
179/// x(t) + (1 - λ) x(0)`, where each agent stays partly anchored to the ballot it
180/// cast and what settles is a profile of persistent disagreement.
181///
182/// Returns an empty outcome when nobody has voted; a single ballot settles on
183/// itself in one round, which the caller reports as the one opinion it is rather
184/// than as agreement.
185#[must_use]
186pub fn settle(ballots: &[Ballot], cfg: &ConsensusSection) -> Outcome {
187    let agents: Vec<&Ballot> = {
188        let mut sorted: Vec<&Ballot> = ballots.iter().collect();
189        sorted.sort_by(|a, b| a.agent.cmp(&b.agent));
190        sorted
191    };
192    let choices: Vec<String> = agents
193        .iter()
194        .map(|b| b.choice.clone())
195        .collect::<BTreeSet<_>>()
196        .into_iter()
197        .collect();
198    let n = agents.len();
199    let m = choices.len();
200    if n == 0 {
201        return Outcome {
202            choices,
203            agents: Vec::new(),
204            settling: Settling::Agreed,
205            consensus: None,
206            factions: Vec::new(),
207            rounds: 0,
208            budget_reached: false,
209            trust: TrustSource::Default,
210            susceptibility: cfg.susceptibility,
211            spread: 0.0,
212        };
213    }
214
215    let names: Vec<&str> = agents.iter().map(|b| b.agent.as_str()).collect();
216    let (weights, trust) = influence(&names, cfg);
217    let pull: Vec<f64> = names
218        .iter()
219        .map(|name| {
220            cfg.susceptibility_of
221                .get(*name)
222                .copied()
223                .unwrap_or(cfg.susceptibility)
224        })
225        .collect();
226
227    // One-hot: an agent that voted `ship` puts all of its opinion on `ship`. The
228    // choice set was collected from these same ballots, so the position is
229    // always there; written as a match rather than an unwrap so the function
230    // has no panicking path at all.
231    let mut opinion = vec![vec![0.0f64; m]; n];
232    for (i, ballot) in agents.iter().enumerate() {
233        if let Some(at) = choices.iter().position(|c| *c == ballot.choice) {
234            opinion[i][at] = 1.0;
235        }
236    }
237
238    // An anchor makes the iteration a contraction whatever the trust graph
239    // looks like, so it always settles, and it settles on agents that still
240    // differ. That is the model working rather than failing, so the structural
241    // question below is only asked of the unanchored case.
242    //
243    // Without an anchor the trust graph alone decides whether there is a
244    // consensus to reach, and the iteration only works out what it is. Deciding
245    // it from the iteration instead means asking whether a number stopped
246    // moving, and a chain that mixes slowly stops moving long before its agents
247    // agree, which reads as a split that is not there.
248    // One agent keeping part of its own ballot is enough to make the whole run
249    // a contraction, so the structural question below belongs to the case where
250    // nobody does.
251    let anchored = pull.iter().any(|value| *value < 1.0);
252    let settling = if anchored {
253        Settling::Anchored
254    } else {
255        match structure(&weights) {
256            Structure::Convergent => Settling::Agreed,
257            Structure::Split => Settling::Split,
258            Structure::Periodic => Settling::Oscillating,
259        }
260    };
261    let start = opinion.clone();
262    let rounds = iterate(&mut opinion, &weights, &start, &pull, cfg, settling);
263    let consensus = (settling == Settling::Agreed).then(|| opinion[0].clone());
264    // Social power is the left Perron vector of the trust matrix, which weighs
265    // the ballots into the one position the group reached. Under an anchor
266    // there is no one position, so there is nothing for it to weigh.
267    let power = (settling == Settling::Agreed).then(|| social_power(&weights, cfg));
268    let factions = match settling {
269        Settling::Split => group_by_limit(&names, &opinion, cfg.tolerance),
270        // Nothing converged on, so there is no position to group agents by.
271        Settling::Agreed | Settling::Oscillating | Settling::Anchored => Vec::new(),
272    };
273    let spread = spread(&opinion, m);
274
275    Outcome {
276        choices,
277        agents: agents
278            .iter()
279            .enumerate()
280            .map(|(i, ballot)| AgentLimit {
281                agent: ballot.agent.clone(),
282                voted: ballot.choice.clone(),
283                limit: opinion[i].clone(),
284                power: power.as_ref().map(|p| p[i]),
285                susceptibility: pull[i],
286            })
287            .collect(),
288        settling,
289        consensus,
290        factions,
291        rounds,
292        budget_reached: settling != Settling::Oscillating && rounds >= cfg.max_iterations,
293        trust,
294        susceptibility: cfg.susceptibility,
295        spread,
296    }
297}
298
299/// The consensus on one issue of a tracker, under that tracker's trust rows.
300///
301/// # Errors
302///
303/// Returns an error if `id` is not in the corpus, the corpus cannot be read, or
304/// the configuration names a weight the iteration cannot use.
305pub fn of_issue(layout: &crate::config::Layout, id: &str) -> crate::error::Result<Outcome> {
306    let ballots = crate::ops::ballots(layout, id)?;
307    let cfg = crate::config::VissueConfig::load(layout)?.consensus;
308    Ok(settle(&ballots, &cfg))
309}
310
311/// What each child of `plan` settled on.
312///
313/// Every child is read on its own, and the rows are left as rows. See the
314/// design note: no weighting over children can be picked without a judgement
315/// the tracker has no basis for, a split child has no position to fold in, and
316/// an unvoted child is absent rather than neutral.
317///
318/// # Errors
319///
320/// Returns an error if `plan` is not in the corpus, the corpus cannot be read,
321/// or the configuration names a weight the iteration cannot use.
322pub fn of_plan(
323    layout: &crate::config::Layout,
324    plan: &str,
325) -> crate::error::Result<crate::views::PlanConsensus> {
326    use crate::views::{ChildConsensus, PlanConsensus};
327
328    let recs = crate::catalog::load_recs(layout)?;
329    let service = crate::catalog::CatalogService::from_recs(&recs);
330    let parent = service.detail(plan)?;
331    let cfg = crate::config::VissueConfig::load(layout)?.consensus;
332
333    let mut children = Vec::new();
334    for hit in service.children(plan)? {
335        let ballots = crate::ops::ballots(layout, &hit.id)?;
336        let outcome = (!ballots.is_empty()).then(|| settle(&ballots, &cfg));
337        children.push(ChildConsensus {
338            id: hit.id,
339            state: hit.state,
340            title: hit.title,
341            ballots: ballots.len(),
342            settling: outcome.as_ref().map(|o| o.settling),
343            holds: outcome.as_ref().and_then(|o| {
344                o.leader()
345                    .map(|(choice, share)| (choice.to_string(), share))
346            }),
347        });
348    }
349
350    Ok(PlanConsensus {
351        plan: parent.id,
352        title: parent.title,
353        children,
354    })
355}
356
357/// Build the row-stochastic influence matrix over `names`.
358///
359/// A configured row names the agents this one listens to, in whatever units the
360/// author found natural; only the ratios matter, because the row is normalised.
361/// Weight on an agent that did not vote is dropped: it has no opinion to average,
362/// and keeping it would quietly scale everyone else down.
363///
364/// The agent's weight on itself is `self_weight` unless its own row names it, in
365/// which case that value is used as written and the whole row is normalised
366/// together. An agent with no row, or whose row named nobody who voted, listens
367/// to itself with `self_weight` and splits the rest equally: that is the prior
368/// that makes the no-configuration case reduce to the tally.
369fn influence(names: &[&str], cfg: &ConsensusSection) -> (Vec<Vec<f64>>, TrustSource) {
370    let n = names.len();
371    let mut weights = vec![vec![0.0f64; n]; n];
372    let mut source = TrustSource::Default;
373    for (i, name) in names.iter().enumerate() {
374        let row = &mut weights[i];
375        let configured = cfg.trust.get(*name).map(|spec| {
376            let mut named = 0usize;
377            for (j, other) in names.iter().enumerate() {
378                if let Some(w) = spec.get(*other)
379                    && *w > 0.0
380                {
381                    row[j] = *w;
382                    named += 1;
383                }
384            }
385            (named > 0, spec.contains_key(*name))
386        });
387        match configured {
388            Some((true, names_itself)) => {
389                source = TrustSource::Configured;
390                if names_itself {
391                    normalise(row, 1.0);
392                } else {
393                    normalise(row, 1.0 - cfg.self_weight);
394                    row[i] += cfg.self_weight;
395                }
396            }
397            _ => {
398                if n == 1 {
399                    row[i] = 1.0;
400                } else {
401                    let share = (1.0 - cfg.self_weight) / ((n - 1) as f64);
402                    for weight in row.iter_mut() {
403                        *weight = share;
404                    }
405                    row[i] = cfg.self_weight;
406                }
407            }
408        }
409    }
410    (weights, source)
411}
412
413/// Scale `row` so it sums to `total`. A row that sums to nothing is left alone;
414/// the caller only calls this on a row with a positive entry.
415fn normalise(row: &mut [f64], total: f64) {
416    let sum: f64 = row.iter().sum();
417    if sum <= 0.0 {
418        return;
419    }
420    for weight in row.iter_mut() {
421        *weight *= total / sum;
422    }
423}
424
425/// What the trust graph alone determines about the outcome.
426///
427/// DeGroot's iteration converges to agreement exactly when the graph holds one
428/// closed group that every agent can reach, and that group is aperiodic (Berger,
429/// 1981). All three are properties of which weights are positive, not of their
430/// sizes, so they are decided here once rather than inferred from a number that
431/// stopped moving.
432#[derive(Debug, Clone, Copy, PartialEq, Eq)]
433enum Structure {
434    /// One closed group, aperiodic: every agent converges on it.
435    Convergent,
436    /// More than one closed group: agents inside different ones never agree.
437    Split,
438    /// One closed group with a period: opinions cycle instead of settling.
439    Periodic,
440}
441
442/// Classify the trust graph `i -> j` for every positive weight agent `i` puts on
443/// agent `j`.
444fn structure(weights: &[Vec<f64>]) -> Structure {
445    let n = weights.len();
446    let mut graph = DiGraph::<usize, ()>::with_capacity(n, n);
447    let nodes: Vec<NodeIndex> = (0..n).map(|i| graph.add_node(i)).collect();
448    for (i, row) in weights.iter().enumerate() {
449        for (j, weight) in row.iter().enumerate() {
450            if *weight > 0.0 {
451                graph.add_edge(nodes[i], nodes[j], ());
452            }
453        }
454    }
455    let components = kosaraju_scc(&graph);
456    let mut component_of = vec![0usize; n];
457    for (at, component) in components.iter().enumerate() {
458        for node in component {
459            component_of[graph[*node]] = at;
460        }
461    }
462    let closed: Vec<usize> = components
463        .iter()
464        .enumerate()
465        .filter(|(at, component)| {
466            !component.iter().any(|node| {
467                graph
468                    .neighbors(*node)
469                    .any(|to| component_of[graph[to]] != *at)
470            })
471        })
472        .map(|(at, _)| at)
473        .collect();
474    // A row-stochastic matrix always has at least one closed group, so the only
475    // interesting count is more than one.
476    let [only] = closed[..] else {
477        return Structure::Split;
478    };
479    if period(&graph, &components[only], &component_of, only) == 1 {
480        Structure::Convergent
481    } else {
482        Structure::Periodic
483    }
484}
485
486/// The period of one strongly connected component: the greatest common divisor
487/// of its cycle lengths.
488///
489/// Read off a breadth-first layering rather than by enumerating cycles: for
490/// every edge inside the component, `level(u) + 1 - level(v)` is the length of a
491/// cycle through the tree, and the gcd of those is the period. A component
492/// holding a self-loop has period one, which is why a positive `self_weight`
493/// makes the default case converge.
494fn period(
495    graph: &DiGraph<usize, ()>,
496    component: &[NodeIndex],
497    component_of: &[usize],
498    at: usize,
499) -> usize {
500    let Some(&root) = component.first() else {
501        return 1;
502    };
503    let mut level: HashMap<NodeIndex, i64> = HashMap::from([(root, 0)]);
504    let mut queue = VecDeque::from([root]);
505    while let Some(node) = queue.pop_front() {
506        let depth = level[&node];
507        for to in graph.neighbors(node) {
508            if component_of[graph[to]] != at || level.contains_key(&to) {
509                continue;
510            }
511            level.insert(to, depth + 1);
512            queue.push_back(to);
513        }
514    }
515    let mut divisor = 0i64;
516    for node in component {
517        let Some(&depth) = level.get(node) else {
518            continue;
519        };
520        for to in graph.neighbors(*node) {
521            if component_of[graph[to]] != at {
522                continue;
523            }
524            if let Some(&other) = level.get(&to) {
525                divisor = gcd(divisor, depth + 1 - other);
526            }
527        }
528    }
529    let period = divisor.unsigned_abs() as usize;
530    period.max(1)
531}
532
533fn gcd(a: i64, b: i64) -> i64 {
534    let (mut a, mut b) = (a.abs(), b.abs());
535    while b != 0 {
536        let t = b;
537        b = a % b;
538        a = t;
539    }
540    a
541}
542
543/// Iterate the opinions and return the rounds it took.
544///
545/// The step is `x_i(t+1) = λ_i (W x(t))_i + (1 - λ_i) x_i(0)`, with `λ` a
546/// diagonal rather than a scalar so two agents in one run can be differently
547/// movable. At `λ_i = 1` the anchor term vanishes for that agent and its row is
548/// DeGroot; below one it keeps pulling back toward the ballot it cast, which is
549/// what makes the step a contraction and the settling certain.
550///
551/// What counts as done depends on what the run can do. An unanchored convergent
552/// graph is run until the agents agree, because that is the quantity being
553/// reported. A split graph never will, and an anchored run never should, so
554/// both are run to a fixed point instead. A periodic unanchored graph has no
555/// limit at all, so it is not run: the opinions each agent holds are the ones it
556/// started with.
557fn iterate(
558    opinion: &mut Vec<Vec<f64>>,
559    weights: &[Vec<f64>],
560    start: &[Vec<f64>],
561    pull: &[f64],
562    cfg: &ConsensusSection,
563    settling: Settling,
564) -> usize {
565    if settling == Settling::Oscillating {
566        return 0;
567    }
568    let n = opinion.len();
569    let m = opinion.first().map_or(0, Vec::len);
570    for round in 0..cfg.max_iterations {
571        if settling == Settling::Agreed && spread(opinion, m) < cfg.tolerance {
572            return round;
573        }
574        let mut next = vec![vec![0.0f64; m]; n];
575        let mut step = 0.0f64;
576        for i in 0..n {
577            for c in 0..m {
578                let mut acc = 0.0;
579                for (j, row) in opinion.iter().enumerate() {
580                    acc += weights[i][j] * row[c];
581                }
582                let value = pull[i] * acc + (1.0 - pull[i]) * start[i][c];
583                next[i][c] = value;
584                step = step.max((value - opinion[i][c]).abs());
585            }
586        }
587        *opinion = next;
588        if matches!(settling, Settling::Split | Settling::Anchored) && step < cfg.tolerance {
589            return round + 1;
590        }
591    }
592    cfg.max_iterations
593}
594
595/// The largest disagreement between any two agents on any one choice.
596fn spread(opinion: &[Vec<f64>], m: usize) -> f64 {
597    let mut worst = 0.0f64;
598    for c in 0..m {
599        let mut low = f64::INFINITY;
600        let mut high = f64::NEG_INFINITY;
601        for row in opinion {
602            low = low.min(row[c]);
603            high = high.max(row[c]);
604        }
605        worst = worst.max(high - low);
606    }
607    worst
608}
609
610/// The left Perron vector of `weights`: how much each agent's starting opinion
611/// accounts for in the limit.
612///
613/// Power iteration from uniform, which is what the model itself does with the
614/// rows transposed. Reported only when the group agreed, because a matrix with
615/// more than one closed group has more than one such vector and none of them is
616/// the answer.
617fn social_power(weights: &[Vec<f64>], cfg: &ConsensusSection) -> Vec<f64> {
618    let n = weights.len();
619    let mut power = vec![1.0 / (n as f64); n];
620    for _ in 0..cfg.max_iterations {
621        let mut next = vec![0.0f64; n];
622        for j in 0..n {
623            for (i, row) in weights.iter().enumerate() {
624                next[j] += power[i] * row[j];
625            }
626        }
627        let step = next
628            .iter()
629            .zip(&power)
630            .map(|(a, b)| (a - b).abs())
631            .fold(0.0f64, f64::max);
632        power = next;
633        if step < cfg.tolerance {
634            break;
635        }
636    }
637    let sum: f64 = power.iter().sum();
638    if sum > 0.0 {
639        for weight in &mut power {
640            *weight /= sum;
641        }
642    }
643    power
644}
645
646/// Agents that settled on the same opinion, as sorted groups.
647fn group_by_limit(names: &[&str], opinion: &[Vec<f64>], tolerance: f64) -> Vec<Vec<String>> {
648    let mut groups: Vec<(Vec<f64>, Vec<String>)> = Vec::new();
649    for (i, name) in names.iter().enumerate() {
650        let row = &opinion[i];
651        match groups.iter_mut().find(|(seen, _)| {
652            seen.iter()
653                .zip(row)
654                .all(|(a, b)| (a - b).abs() < tolerance.max(TIE_EPS))
655        }) {
656            Some((_, members)) => members.push((*name).to_string()),
657            None => groups.push((row.clone(), vec![(*name).to_string()])),
658        }
659    }
660    groups.into_iter().map(|(_, members)| members).collect()
661}
662
663/// The plain count, for the line that shows what the weighting changed.
664#[must_use]
665pub fn tally(ballots: &[Ballot]) -> BTreeMap<String, Vec<String>> {
666    let mut counts: BTreeMap<String, Vec<String>> = BTreeMap::new();
667    for ballot in ballots {
668        counts
669            .entry(ballot.choice.clone())
670            .or_default()
671            .push(ballot.agent.clone());
672    }
673    counts
674}
675
676#[cfg(test)]
677mod tests {
678    use super::*;
679
680    fn ballot(agent: &str, choice: &str) -> Ballot {
681        Ballot {
682            agent: agent.to_string(),
683            choice: choice.to_string(),
684            stamp: "[2026-09-07 Mon]".to_string(),
685        }
686    }
687
688    fn share(outcome: &Outcome, choice: &str) -> f64 {
689        let at = outcome
690            .choices
691            .iter()
692            .position(|c| c == choice)
693            .expect("choice");
694        outcome.consensus.as_ref().expect("consensus")[at]
695    }
696
697    /// With nothing configured every agent listens to every other equally, so the
698    /// matrix is doubly stochastic and the limit is the mean: the tally as a
699    /// fraction. This is the property that makes the verb safe to run on a
700    /// tracker nobody has configured.
701    #[test]
702    fn without_configuration_the_consensus_is_the_tally_as_a_fraction() {
703        let cfg = ConsensusSection::default();
704        let ballots = [
705            ballot("alice", "ship"),
706            ballot("bob", "ship"),
707            ballot("carol", "hold"),
708        ];
709        let outcome = settle(&ballots, &cfg);
710        assert_eq!(outcome.settling, Settling::Agreed);
711        assert!((share(&outcome, "ship") - 2.0 / 3.0).abs() < 1e-6);
712        assert!((share(&outcome, "hold") - 1.0 / 3.0).abs() < 1e-6);
713        assert_eq!(outcome.leader().map(|(c, _)| c), Some("ship"));
714        for row in &outcome.agents {
715            assert!((row.power.expect("power") - 1.0 / 3.0).abs() < 1e-6);
716        }
717    }
718
719    /// The case the verb exists for: a plurality that the group's own weighting
720    /// reverses. Two agents vote ship, one votes hold, both of the two listen to
721    /// the third, and the third mostly listens to itself.
722    #[test]
723    fn trust_can_move_the_group_off_the_plurality() {
724        let mut cfg = ConsensusSection::default();
725        cfg.trust.insert(
726            "alice".to_string(),
727            BTreeMap::from([("carol".to_string(), 1.0)]),
728        );
729        cfg.trust.insert(
730            "bob".to_string(),
731            BTreeMap::from([("carol".to_string(), 1.0)]),
732        );
733        cfg.trust.insert(
734            "carol".to_string(),
735            BTreeMap::from([("carol".to_string(), 4.0), ("alice".to_string(), 1.0)]),
736        );
737        let ballots = [
738            ballot("alice", "ship"),
739            ballot("bob", "ship"),
740            ballot("carol", "hold"),
741        ];
742        let outcome = settle(&ballots, &cfg);
743        assert_eq!(outcome.settling, Settling::Agreed);
744        assert_eq!(
745            outcome.leader().map(|(c, _)| c),
746            Some("hold"),
747            "the plurality is ship; the group listens to carol: {outcome:?}"
748        );
749        let power = |who: &str| {
750            outcome
751                .agents
752                .iter()
753                .find(|a| a.agent == who)
754                .expect("agent")
755                .power
756                .expect("power")
757        };
758        assert!(power("carol") > power("alice"), "{outcome:?}");
759        // Nobody listens to bob, and an opinion nobody listens to moves the
760        // group by nothing at all. A count cannot express that.
761        assert!(power("bob") < 1e-6, "{outcome:?}");
762    }
763
764    /// The limit is `πᵀ x(0)`: social power is the weighting the consensus
765    /// applies to the ballots, not a separate statistic that happens to be
766    /// printed beside it.
767    #[test]
768    fn the_consensus_is_the_ballots_weighted_by_social_power() {
769        let mut cfg = ConsensusSection::default();
770        cfg.trust.insert(
771            "alice".to_string(),
772            BTreeMap::from([("bob".to_string(), 3.0), ("carol".to_string(), 1.0)]),
773        );
774        cfg.trust.insert(
775            "bob".to_string(),
776            BTreeMap::from([("carol".to_string(), 1.0)]),
777        );
778        let ballots = [
779            ballot("alice", "ship"),
780            ballot("bob", "hold"),
781            ballot("carol", "hold"),
782        ];
783        let outcome = settle(&ballots, &cfg);
784        assert_eq!(outcome.settling, Settling::Agreed);
785        for (at, choice) in outcome.choices.iter().enumerate() {
786            let weighted: f64 = outcome
787                .agents
788                .iter()
789                .map(|a| {
790                    let vote = f64::from(u8::from(a.voted == *choice));
791                    a.power.expect("power") * vote
792                })
793                .sum();
794            assert!(
795                (weighted - outcome.consensus.as_ref().expect("consensus")[at]).abs() < 1e-6,
796                "{choice}: {weighted} vs {outcome:?}"
797            );
798        }
799    }
800
801    /// Two groups that cite only each other never converge, and DeGroot says so
802    /// rather than averaging across them. Reporting that is the point: it is a
803    /// fact about the reviewers, not a number to round.
804    #[test]
805    fn two_closed_groups_do_not_reach_a_consensus() {
806        let mut cfg = ConsensusSection::default();
807        for (who, whom) in [
808            ("alice", "bob"),
809            ("bob", "alice"),
810            ("carol", "dave"),
811            ("dave", "carol"),
812        ] {
813            cfg.trust
814                .insert(who.to_string(), BTreeMap::from([(whom.to_string(), 1.0)]));
815        }
816        let ballots = [
817            ballot("alice", "ship"),
818            ballot("bob", "ship"),
819            ballot("carol", "hold"),
820            ballot("dave", "hold"),
821        ];
822        let outcome = settle(&ballots, &cfg);
823        assert_eq!(outcome.settling, Settling::Split, "{outcome:?}");
824        assert!(outcome.consensus.is_none());
825        assert!(outcome.leader().is_none());
826        assert_eq!(outcome.factions.len(), 2, "{:?}", outcome.factions);
827        assert_eq!(
828            outcome.factions[0],
829            vec!["alice".to_string(), "bob".to_string()]
830        );
831    }
832
833    /// A pair that listen only to each other and not at all to themselves swap
834    /// opinions forever. Periodicity is a different failure from a split, and
835    /// calling it one would suggest the two sides had settled.
836    #[test]
837    fn a_periodic_trust_graph_is_reported_as_oscillating() {
838        let mut cfg = ConsensusSection {
839            self_weight: 0.0,
840            max_iterations: 50,
841            ..ConsensusSection::default()
842        };
843        cfg.trust.insert(
844            "alice".to_string(),
845            BTreeMap::from([("bob".to_string(), 1.0)]),
846        );
847        cfg.trust.insert(
848            "bob".to_string(),
849            BTreeMap::from([("alice".to_string(), 1.0)]),
850        );
851        let ballots = [ballot("alice", "ship"), ballot("bob", "hold")];
852        let outcome = settle(&ballots, &cfg);
853        assert_eq!(outcome.settling, Settling::Oscillating, "{outcome:?}");
854        assert!(outcome.consensus.is_none());
855    }
856
857    /// Weight on an agent that never voted has no opinion behind it. Keeping it
858    /// in the row would scale down everyone who did vote, so a trusted absentee
859    /// would quietly pull the result toward the truster's own position.
860    #[test]
861    fn weight_on_an_agent_that_did_not_vote_is_dropped() {
862        let ballots = [ballot("alice", "ship"), ballot("bob", "hold")];
863        let mut with_absentee = ConsensusSection::default();
864        with_absentee.trust.insert(
865            "alice".to_string(),
866            BTreeMap::from([("absent".to_string(), 9.0), ("bob".to_string(), 1.0)]),
867        );
868        let mut without = ConsensusSection::default();
869        without.trust.insert(
870            "alice".to_string(),
871            BTreeMap::from([("bob".to_string(), 1.0)]),
872        );
873
874        assert_eq!(
875            settle(&ballots, &with_absentee).agents,
876            settle(&ballots, &without).agents,
877            "nine parts trust in an agent that did not vote changed the answer"
878        );
879    }
880
881    /// A group that mixes slowly still agrees. Deciding that from the size of
882    /// the last step called it a split, because an agent that weights itself
883    /// heavily stops moving long before it has finished moving, and the whole
884    /// point of the structural test is that the answer does not depend on how
885    /// far along the arithmetic happens to be.
886    #[test]
887    fn a_slowly_mixing_group_still_reaches_a_consensus() {
888        let cfg = ConsensusSection {
889            self_weight: 0.99,
890            max_iterations: 40,
891            ..ConsensusSection::default()
892        };
893        let ballots = [
894            ballot("alice", "ship"),
895            ballot("bob", "ship"),
896            ballot("carol", "hold"),
897        ];
898        let outcome = settle(&ballots, &cfg);
899        assert_eq!(outcome.settling, Settling::Agreed, "{outcome:?}");
900        assert!(
901            outcome.budget_reached,
902            "40 rounds cannot settle this one, and the report has to say so"
903        );
904    }
905
906    /// An anchor is what keeps a minority position from being averaged away.
907    /// Under DeGroot the whole group lands on one number; under an anchor the
908    /// agent that voted the other way is still visibly holding it.
909    #[test]
910    fn an_anchor_leaves_the_minority_still_holding_its_position() {
911        let ballots = [
912            ballot("alice", "ship"),
913            ballot("bob", "ship"),
914            ballot("carol", "hold"),
915        ];
916        let unanchored = settle(&ballots, &ConsensusSection::default());
917        assert_eq!(unanchored.settling, Settling::Agreed);
918        assert!(unanchored.spread < 1e-6, "{unanchored:?}");
919
920        let anchored = settle(
921            &ballots,
922            &ConsensusSection {
923                susceptibility: 0.6,
924                ..ConsensusSection::default()
925            },
926        );
927        assert_eq!(anchored.settling, Settling::Anchored, "{anchored:?}");
928        assert!(anchored.consensus.is_none(), "no one position to report");
929        assert!(
930            anchored.spread > 0.1,
931            "the disagreement is the result: {anchored:?}"
932        );
933        let hold = anchored
934            .choices
935            .iter()
936            .position(|c| c == "hold")
937            .expect("hold");
938        let carol = anchored
939            .agents
940            .iter()
941            .find(|a| a.agent == "carol")
942            .expect("carol");
943        let alice = anchored
944            .agents
945            .iter()
946            .find(|a| a.agent == "alice")
947            .expect("alice");
948        assert!(
949            carol.limit[hold] > alice.limit[hold],
950            "carol voted hold and stays nearer it: {anchored:?}"
951        );
952    }
953
954    /// The anchored limit is the fixed point of `x = λ W x + (1 - λ) x(0)`.
955    /// Asserting the equation rather than a number is what makes this a test of
956    /// the model rather than of the arithmetic that happened to run.
957    #[test]
958    fn the_anchored_limit_solves_the_friedkin_johnsen_equation() {
959        let mut cfg = ConsensusSection {
960            susceptibility: 0.7,
961            ..ConsensusSection::default()
962        };
963        // A diagonal rather than one number, since that is what the model says
964        // and a scalar would let the wrong arithmetic pass this.
965        cfg.susceptibility_of.insert("carol".to_string(), 0.25);
966        cfg.trust.insert(
967            "alice".to_string(),
968            BTreeMap::from([("carol".to_string(), 2.0), ("bob".to_string(), 1.0)]),
969        );
970        let ballots = [
971            ballot("alice", "ship"),
972            ballot("bob", "ship"),
973            ballot("carol", "hold"),
974        ];
975        let outcome = settle(&ballots, &cfg);
976        assert_eq!(outcome.settling, Settling::Anchored);
977
978        let names: Vec<&str> = outcome.agents.iter().map(|a| a.agent.as_str()).collect();
979        let (weights, _) = influence(&names, &cfg);
980        for (i, row) in outcome.agents.iter().enumerate() {
981            for (c, choice) in outcome.choices.iter().enumerate() {
982                let neighbours: f64 = outcome
983                    .agents
984                    .iter()
985                    .enumerate()
986                    .map(|(j, other)| weights[i][j] * other.limit[c])
987                    .sum();
988                let own = f64::from(u8::from(row.voted == *choice));
989                let pull = row.susceptibility;
990                let want = pull * neighbours + (1.0 - pull) * own;
991                assert!(
992                    (want - row.limit[c]).abs() < 1e-6,
993                    "{} on {choice}: {want} vs {}",
994                    row.agent,
995                    row.limit[c]
996                );
997            }
998        }
999    }
1000
1001    /// The susceptibility is a diagonal. An agent named in the configuration
1002    /// uses its own value and every other agent uses the default, which is the
1003    /// case the model exists to express: a maintainer and a first-time reviewer
1004    /// are not equally movable.
1005    #[test]
1006    fn a_named_agent_carries_its_own_susceptibility() {
1007        let mut cfg = ConsensusSection {
1008            susceptibility: 0.9,
1009            ..ConsensusSection::default()
1010        };
1011        cfg.susceptibility_of.insert("maintainer".to_string(), 0.1);
1012        let ballots = [
1013            ballot("maintainer", "hold"),
1014            ballot("newcomer", "ship"),
1015            ballot("other", "ship"),
1016        ];
1017        let outcome = settle(&ballots, &cfg);
1018        assert_eq!(outcome.settling, Settling::Anchored);
1019
1020        let of = |who: &str| {
1021            outcome
1022                .agents
1023                .iter()
1024                .find(|a| a.agent == who)
1025                .expect("agent")
1026        };
1027        assert!((of("maintainer").susceptibility - 0.1).abs() < f64::EPSILON);
1028        assert!((of("newcomer").susceptibility - 0.9).abs() < f64::EPSILON);
1029
1030        // The one that barely moves ends up nearest what it voted for.
1031        let hold = outcome
1032            .choices
1033            .iter()
1034            .position(|c| c == "hold")
1035            .expect("hold");
1036        assert!(
1037            of("maintainer").limit[hold] > of("newcomer").limit[hold],
1038            "{outcome:?}"
1039        );
1040    }
1041
1042    /// Susceptibility zero is the stubborn end of the model: the agent listens,
1043    /// and does not move at all. Worth pinning because it is the one value where
1044    /// the anchor term is the whole update.
1045    #[test]
1046    fn an_agent_at_zero_never_leaves_its_ballot() {
1047        let mut cfg = ConsensusSection::default();
1048        cfg.susceptibility_of.insert("rock".to_string(), 0.0);
1049        let ballots = [
1050            ballot("rock", "hold"),
1051            ballot("a", "ship"),
1052            ballot("b", "ship"),
1053        ];
1054        let outcome = settle(&ballots, &cfg);
1055        let hold = outcome
1056            .choices
1057            .iter()
1058            .position(|c| c == "hold")
1059            .expect("hold");
1060        let rock = outcome
1061            .agents
1062            .iter()
1063            .find(|a| a.agent == "rock")
1064            .expect("rock");
1065        assert!(
1066            (rock.limit[hold] - 1.0).abs() < 1e-9,
1067            "it voted hold and never moved: {outcome:?}"
1068        );
1069        // And the others still moved toward it, so this is not a frozen run.
1070        let a = outcome.agents.iter().find(|x| x.agent == "a").expect("a");
1071        assert!(a.limit[hold] > 0.0, "{outcome:?}");
1072    }
1073
1074    /// A configuration that names nobody is the scalar case, and it has to stay
1075    /// exactly the scalar case: the diagonal is a generalisation, not a change.
1076    #[test]
1077    fn naming_nobody_is_the_scalar_case() {
1078        let ballots = [ballot("a", "ship"), ballot("b", "hold")];
1079        let scalar = ConsensusSection {
1080            susceptibility: 0.5,
1081            ..ConsensusSection::default()
1082        };
1083        let mut spelled_out = scalar.clone();
1084        for who in ["a", "b"] {
1085            spelled_out.susceptibility_of.insert(who.to_string(), 0.5);
1086        }
1087        assert_eq!(
1088            settle(&ballots, &scalar).agents,
1089            settle(&ballots, &spelled_out).agents
1090        );
1091    }
1092
1093    /// Any anchor at all makes the step a contraction, so the pair that swap
1094    /// opinions forever under DeGroot settle instead. The periodic case is a
1095    /// property of the unanchored model, not of the group.
1096    #[test]
1097    fn an_anchor_removes_the_periodic_case() {
1098        let mut cfg = ConsensusSection {
1099            self_weight: 0.0,
1100            susceptibility: 0.9,
1101            max_iterations: 500,
1102            ..ConsensusSection::default()
1103        };
1104        cfg.trust.insert(
1105            "alice".to_string(),
1106            BTreeMap::from([("bob".to_string(), 1.0)]),
1107        );
1108        cfg.trust.insert(
1109            "bob".to_string(),
1110            BTreeMap::from([("alice".to_string(), 1.0)]),
1111        );
1112        let ballots = [ballot("alice", "ship"), ballot("bob", "hold")];
1113
1114        let unanchored = settle(
1115            &ballots,
1116            &ConsensusSection {
1117                susceptibility: 1.0,
1118                ..cfg.clone()
1119            },
1120        );
1121        assert_eq!(unanchored.settling, Settling::Oscillating);
1122
1123        let anchored = settle(&ballots, &cfg);
1124        assert_eq!(anchored.settling, Settling::Anchored, "{anchored:?}");
1125        assert!(
1126            !anchored.budget_reached,
1127            "a contraction settles well inside the budget: {anchored:?}"
1128        );
1129    }
1130
1131    /// Full susceptibility is exactly DeGroot, which is what makes the knob
1132    /// safe to add: a tracker that never sets it sees the model it had.
1133    #[test]
1134    fn full_susceptibility_is_the_unanchored_model() {
1135        let ballots = [
1136            ballot("alice", "ship"),
1137            ballot("bob", "hold"),
1138            ballot("carol", "ship"),
1139        ];
1140        let default = settle(&ballots, &ConsensusSection::default());
1141        let explicit = settle(
1142            &ballots,
1143            &ConsensusSection {
1144                susceptibility: 1.0,
1145                ..ConsensusSection::default()
1146            },
1147        );
1148        assert_eq!(default.settling, explicit.settling);
1149        assert_eq!(default.agents, explicit.agents);
1150        assert_eq!(default.consensus, explicit.consensus);
1151    }
1152
1153    /// An exact tie is not a lead. Reporting the first of two equal options as
1154    /// the group's position is how a coin toss becomes a decision.
1155    #[test]
1156    fn an_exact_tie_has_no_leader() {
1157        let cfg = ConsensusSection::default();
1158        let ballots = [ballot("alice", "ship"), ballot("bob", "hold")];
1159        let outcome = settle(&ballots, &cfg);
1160        assert_eq!(outcome.settling, Settling::Agreed);
1161        assert!(outcome.leader().is_none(), "{outcome:?}");
1162    }
1163
1164    /// One ballot settles on itself immediately. The verb still has to say that
1165    /// nobody has agreed with it, which the caller does from the agent count.
1166    #[test]
1167    fn a_single_ballot_settles_on_itself() {
1168        let cfg = ConsensusSection::default();
1169        let outcome = settle(&[ballot("alice", "ship")], &cfg);
1170        assert_eq!(outcome.settling, Settling::Agreed);
1171        assert_eq!(outcome.agents.len(), 1);
1172        assert!((share(&outcome, "ship") - 1.0).abs() < 1e-9);
1173        assert!((outcome.agents[0].power.unwrap() - 1.0).abs() < 1e-9);
1174    }
1175
1176    /// No ballots is not a consensus of zero agents that agree; it is nothing to
1177    /// report, and the caller says so.
1178    #[test]
1179    fn no_ballots_leaves_no_consensus_to_report() {
1180        let outcome = settle(&[], &ConsensusSection::default());
1181        assert!(outcome.agents.is_empty());
1182        assert!(outcome.consensus.is_none());
1183        assert!(outcome.leader().is_none());
1184    }
1185
1186    /// Every row of the influence matrix sums to one, whatever units the trust
1187    /// was written in. This is what makes the iteration an averaging rather than
1188    /// a growth, and a row that did not would send an opinion off to infinity.
1189    #[test]
1190    fn every_influence_row_is_stochastic() {
1191        let mut cfg = ConsensusSection::default();
1192        cfg.trust.insert(
1193            "alice".to_string(),
1194            BTreeMap::from([("bob".to_string(), 7.5), ("carol".to_string(), 0.25)]),
1195        );
1196        cfg.trust.insert(
1197            "bob".to_string(),
1198            BTreeMap::from([("bob".to_string(), 4.0), ("alice".to_string(), 1.0)]),
1199        );
1200        let (weights, source) = influence(&["alice", "bob", "carol"], &cfg);
1201        assert_eq!(source, TrustSource::Configured);
1202        for row in &weights {
1203            let sum: f64 = row.iter().sum();
1204            assert!((sum - 1.0).abs() < 1e-12, "{row:?} sums to {sum}");
1205        }
1206        // bob named itself, so its own weight is what the row said rather than
1207        // the default: 4 of 5.
1208        assert!((weights[1][1] - 0.8).abs() < 1e-12, "{:?}", weights[1]);
1209        // alice did not, so it keeps self_weight and splits the rest by ratio.
1210        assert!((weights[0][0] - cfg.self_weight).abs() < 1e-12);
1211    }
1212}