Skip to main content

vissue_core/
consensus.rs

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