use std::collections::{BTreeMap, BTreeSet, HashMap, VecDeque};
use serde::{Deserialize, Serialize};
use petgraph::algo::kosaraju_scc;
use petgraph::graph::{DiGraph, NodeIndex};
use crate::config::ConsensusSection;
use crate::ops::Ballot;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum TrustSource {
Default,
Configured,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Settling {
Agreed,
Split,
Oscillating,
Anchored,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct AgentLimit {
pub agent: String,
pub voted: String,
pub limit: Vec<f64>,
pub power: Option<f64>,
pub susceptibility: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Outcome {
pub choices: Vec<String>,
pub agents: Vec<AgentLimit>,
pub settling: Settling,
pub consensus: Option<Vec<f64>>,
pub factions: Vec<Vec<String>>,
pub rounds: usize,
pub budget_reached: bool,
pub trust: TrustSource,
pub susceptibility: f64,
pub spread: f64,
}
impl Outcome {
#[must_use]
pub fn leader(&self) -> Option<(&str, f64)> {
let consensus = self.consensus.as_ref()?;
let mut ranked: Vec<(usize, f64)> = consensus.iter().copied().enumerate().collect();
ranked.sort_by(|a, b| b.1.total_cmp(&a.1));
let (top, share) = *ranked.first()?;
if ranked.len() > 1 && (ranked[1].1 - share).abs() < TIE_EPS {
return None;
}
Some((self.choices[top].as_str(), share))
}
}
impl Outcome {
#[must_use]
pub fn settled(&self) -> bool {
self.settling == Settling::Agreed && self.leader().is_some()
}
}
const TIE_EPS: f64 = 1e-6;
#[must_use]
pub fn settle(ballots: &[Ballot], cfg: &ConsensusSection) -> Outcome {
let agents: Vec<&Ballot> = {
let mut sorted: Vec<&Ballot> = ballots.iter().collect();
sorted.sort_by(|a, b| a.agent.cmp(&b.agent));
sorted
};
let choices: Vec<String> = agents
.iter()
.map(|b| b.choice.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let n = agents.len();
let m = choices.len();
if n == 0 {
return Outcome {
choices,
agents: Vec::new(),
settling: Settling::Agreed,
consensus: None,
factions: Vec::new(),
rounds: 0,
budget_reached: false,
trust: TrustSource::Default,
susceptibility: cfg.susceptibility,
spread: 0.0,
};
}
let names: Vec<&str> = agents.iter().map(|b| b.agent.as_str()).collect();
let (weights, trust) = influence(&names, cfg);
let pull: Vec<f64> = names
.iter()
.map(|name| {
cfg.susceptibility_of
.get(*name)
.copied()
.unwrap_or(cfg.susceptibility)
})
.collect();
let mut opinion = vec![vec![0.0f64; m]; n];
for (i, ballot) in agents.iter().enumerate() {
if let Some(at) = choices.iter().position(|c| *c == ballot.choice) {
opinion[i][at] = 1.0;
}
}
let anchored = pull.iter().any(|value| *value < 1.0);
let settling = if anchored {
Settling::Anchored
} else {
match structure(&weights) {
Structure::Convergent => Settling::Agreed,
Structure::Split => Settling::Split,
Structure::Periodic => Settling::Oscillating,
}
};
let start = opinion.clone();
let rounds = iterate(&mut opinion, &weights, &start, &pull, cfg, settling);
let consensus = (settling == Settling::Agreed).then(|| opinion[0].clone());
let power = (settling == Settling::Agreed).then(|| social_power(&weights, cfg));
let factions = match settling {
Settling::Split => group_by_limit(&names, &opinion, cfg.tolerance),
Settling::Agreed | Settling::Oscillating | Settling::Anchored => Vec::new(),
};
let spread = spread(&opinion, m);
Outcome {
choices,
agents: agents
.iter()
.enumerate()
.map(|(i, ballot)| AgentLimit {
agent: ballot.agent.clone(),
voted: ballot.choice.clone(),
limit: opinion[i].clone(),
power: power.as_ref().map(|p| p[i]),
susceptibility: pull[i],
})
.collect(),
settling,
consensus,
factions,
rounds,
budget_reached: settling != Settling::Oscillating && rounds >= cfg.max_iterations,
trust,
susceptibility: cfg.susceptibility,
spread,
}
}
pub fn of_issue(layout: &crate::config::Layout, id: &str) -> crate::error::Result<Outcome> {
let ballots = crate::ops::ballots(layout, id)?;
let cfg = crate::config::VissueConfig::load(layout)?.consensus;
Ok(settle(&ballots, &cfg))
}
pub fn of_plan(
layout: &crate::config::Layout,
plan: &str,
) -> crate::error::Result<crate::views::PlanConsensus> {
use crate::views::{ChildConsensus, PlanConsensus};
let recs = crate::catalog::load_recs(layout)?;
let service = crate::catalog::CatalogService::from_recs(&recs);
let parent = service.detail(plan)?;
let cfg = crate::config::VissueConfig::load(layout)?.consensus;
let mut children = Vec::new();
for hit in service.children(plan)? {
let ballots = crate::ops::ballots(layout, &hit.id)?;
let outcome = (!ballots.is_empty()).then(|| settle(&ballots, &cfg));
children.push(ChildConsensus {
id: hit.id,
state: hit.state,
title: hit.title,
ballots: ballots.len(),
settling: outcome.as_ref().map(|o| o.settling),
holds: outcome.as_ref().and_then(|o| {
o.leader()
.map(|(choice, share)| (choice.to_string(), share))
}),
});
}
Ok(PlanConsensus {
plan: parent.id,
title: parent.title,
children,
})
}
fn influence(names: &[&str], cfg: &ConsensusSection) -> (Vec<Vec<f64>>, TrustSource) {
let n = names.len();
let mut weights = vec![vec![0.0f64; n]; n];
let mut source = TrustSource::Default;
for (i, name) in names.iter().enumerate() {
let row = &mut weights[i];
let configured = cfg.trust.get(*name).map(|spec| {
let mut named = 0usize;
for (j, other) in names.iter().enumerate() {
if let Some(w) = spec.get(*other)
&& *w > 0.0
{
row[j] = *w;
named += 1;
}
}
(named > 0, spec.contains_key(*name))
});
match configured {
Some((true, names_itself)) => {
source = TrustSource::Configured;
if names_itself {
normalise(row, 1.0);
} else {
normalise(row, 1.0 - cfg.self_weight);
row[i] += cfg.self_weight;
}
}
_ => {
if n == 1 {
row[i] = 1.0;
} else {
let share = (1.0 - cfg.self_weight) / ((n - 1) as f64);
for weight in row.iter_mut() {
*weight = share;
}
row[i] = cfg.self_weight;
}
}
}
}
(weights, source)
}
fn normalise(row: &mut [f64], total: f64) {
let sum: f64 = row.iter().sum();
if sum <= 0.0 {
return;
}
for weight in row.iter_mut() {
*weight *= total / sum;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Structure {
Convergent,
Split,
Periodic,
}
fn structure(weights: &[Vec<f64>]) -> Structure {
let n = weights.len();
let mut graph = DiGraph::<usize, ()>::with_capacity(n, n);
let nodes: Vec<NodeIndex> = (0..n).map(|i| graph.add_node(i)).collect();
for (i, row) in weights.iter().enumerate() {
for (j, weight) in row.iter().enumerate() {
if *weight > 0.0 {
graph.add_edge(nodes[i], nodes[j], ());
}
}
}
let components = kosaraju_scc(&graph);
let mut component_of = vec![0usize; n];
for (at, component) in components.iter().enumerate() {
for node in component {
component_of[graph[*node]] = at;
}
}
let closed: Vec<usize> = components
.iter()
.enumerate()
.filter(|(at, component)| {
!component.iter().any(|node| {
graph
.neighbors(*node)
.any(|to| component_of[graph[to]] != *at)
})
})
.map(|(at, _)| at)
.collect();
let [only] = closed[..] else {
return Structure::Split;
};
if period(&graph, &components[only], &component_of, only) == 1 {
Structure::Convergent
} else {
Structure::Periodic
}
}
fn period(
graph: &DiGraph<usize, ()>,
component: &[NodeIndex],
component_of: &[usize],
at: usize,
) -> usize {
let Some(&root) = component.first() else {
return 1;
};
let mut level: HashMap<NodeIndex, i64> = HashMap::from([(root, 0)]);
let mut queue = VecDeque::from([root]);
while let Some(node) = queue.pop_front() {
let depth = level[&node];
for to in graph.neighbors(node) {
if component_of[graph[to]] != at || level.contains_key(&to) {
continue;
}
level.insert(to, depth + 1);
queue.push_back(to);
}
}
let mut divisor = 0i64;
for node in component {
let Some(&depth) = level.get(node) else {
continue;
};
for to in graph.neighbors(*node) {
if component_of[graph[to]] != at {
continue;
}
if let Some(&other) = level.get(&to) {
divisor = gcd(divisor, depth + 1 - other);
}
}
}
let period = divisor.unsigned_abs() as usize;
period.max(1)
}
fn gcd(a: i64, b: i64) -> i64 {
let (mut a, mut b) = (a.abs(), b.abs());
while b != 0 {
let t = b;
b = a % b;
a = t;
}
a
}
fn iterate(
opinion: &mut Vec<Vec<f64>>,
weights: &[Vec<f64>],
start: &[Vec<f64>],
pull: &[f64],
cfg: &ConsensusSection,
settling: Settling,
) -> usize {
if settling == Settling::Oscillating {
return 0;
}
let n = opinion.len();
let m = opinion.first().map_or(0, Vec::len);
for round in 0..cfg.max_iterations {
if settling == Settling::Agreed && spread(opinion, m) < cfg.tolerance {
return round;
}
let mut next = vec![vec![0.0f64; m]; n];
let mut step = 0.0f64;
for i in 0..n {
for c in 0..m {
let mut acc = 0.0;
for (j, row) in opinion.iter().enumerate() {
acc += weights[i][j] * row[c];
}
let value = pull[i] * acc + (1.0 - pull[i]) * start[i][c];
next[i][c] = value;
step = step.max((value - opinion[i][c]).abs());
}
}
*opinion = next;
if matches!(settling, Settling::Split | Settling::Anchored) && step < cfg.tolerance {
return round + 1;
}
}
cfg.max_iterations
}
fn spread(opinion: &[Vec<f64>], m: usize) -> f64 {
let mut worst = 0.0f64;
for c in 0..m {
let mut low = f64::INFINITY;
let mut high = f64::NEG_INFINITY;
for row in opinion {
low = low.min(row[c]);
high = high.max(row[c]);
}
worst = worst.max(high - low);
}
worst
}
fn social_power(weights: &[Vec<f64>], cfg: &ConsensusSection) -> Vec<f64> {
let n = weights.len();
let mut power = vec![1.0 / (n as f64); n];
for _ in 0..cfg.max_iterations {
let mut next = vec![0.0f64; n];
for j in 0..n {
for (i, row) in weights.iter().enumerate() {
next[j] += power[i] * row[j];
}
}
let step = next
.iter()
.zip(&power)
.map(|(a, b)| (a - b).abs())
.fold(0.0f64, f64::max);
power = next;
if step < cfg.tolerance {
break;
}
}
let sum: f64 = power.iter().sum();
if sum > 0.0 {
for weight in &mut power {
*weight /= sum;
}
}
power
}
fn group_by_limit(names: &[&str], opinion: &[Vec<f64>], tolerance: f64) -> Vec<Vec<String>> {
let mut groups: Vec<(Vec<f64>, Vec<String>)> = Vec::new();
for (i, name) in names.iter().enumerate() {
let row = &opinion[i];
match groups.iter_mut().find(|(seen, _)| {
seen.iter()
.zip(row)
.all(|(a, b)| (a - b).abs() < tolerance.max(TIE_EPS))
}) {
Some((_, members)) => members.push((*name).to_string()),
None => groups.push((row.clone(), vec![(*name).to_string()])),
}
}
groups.into_iter().map(|(_, members)| members).collect()
}
#[must_use]
pub fn tally(ballots: &[Ballot]) -> BTreeMap<String, Vec<String>> {
let mut counts: BTreeMap<String, Vec<String>> = BTreeMap::new();
for ballot in ballots {
counts
.entry(ballot.choice.clone())
.or_default()
.push(ballot.agent.clone());
}
counts
}
#[cfg(test)]
mod tests {
use super::*;
fn ballot(agent: &str, choice: &str) -> Ballot {
Ballot {
agent: agent.to_string(),
choice: choice.to_string(),
stamp: "[2026-09-07 Mon]".to_string(),
}
}
fn share(outcome: &Outcome, choice: &str) -> f64 {
let at = outcome
.choices
.iter()
.position(|c| c == choice)
.expect("choice");
outcome.consensus.as_ref().expect("consensus")[at]
}
#[test]
fn without_configuration_the_consensus_is_the_tally_as_a_fraction() {
let cfg = ConsensusSection::default();
let ballots = [
ballot("alice", "ship"),
ballot("bob", "ship"),
ballot("carol", "hold"),
];
let outcome = settle(&ballots, &cfg);
assert_eq!(outcome.settling, Settling::Agreed);
assert!((share(&outcome, "ship") - 2.0 / 3.0).abs() < 1e-6);
assert!((share(&outcome, "hold") - 1.0 / 3.0).abs() < 1e-6);
assert_eq!(outcome.leader().map(|(c, _)| c), Some("ship"));
for row in &outcome.agents {
assert!((row.power.expect("power") - 1.0 / 3.0).abs() < 1e-6);
}
}
#[test]
fn trust_can_move_the_group_off_the_plurality() {
let mut cfg = ConsensusSection::default();
cfg.trust.insert(
"alice".to_string(),
BTreeMap::from([("carol".to_string(), 1.0)]),
);
cfg.trust.insert(
"bob".to_string(),
BTreeMap::from([("carol".to_string(), 1.0)]),
);
cfg.trust.insert(
"carol".to_string(),
BTreeMap::from([("carol".to_string(), 4.0), ("alice".to_string(), 1.0)]),
);
let ballots = [
ballot("alice", "ship"),
ballot("bob", "ship"),
ballot("carol", "hold"),
];
let outcome = settle(&ballots, &cfg);
assert_eq!(outcome.settling, Settling::Agreed);
assert_eq!(
outcome.leader().map(|(c, _)| c),
Some("hold"),
"the plurality is ship; the group listens to carol: {outcome:?}"
);
let power = |who: &str| {
outcome
.agents
.iter()
.find(|a| a.agent == who)
.expect("agent")
.power
.expect("power")
};
assert!(power("carol") > power("alice"), "{outcome:?}");
assert!(power("bob") < 1e-6, "{outcome:?}");
}
#[test]
fn the_consensus_is_the_ballots_weighted_by_social_power() {
let mut cfg = ConsensusSection::default();
cfg.trust.insert(
"alice".to_string(),
BTreeMap::from([("bob".to_string(), 3.0), ("carol".to_string(), 1.0)]),
);
cfg.trust.insert(
"bob".to_string(),
BTreeMap::from([("carol".to_string(), 1.0)]),
);
let ballots = [
ballot("alice", "ship"),
ballot("bob", "hold"),
ballot("carol", "hold"),
];
let outcome = settle(&ballots, &cfg);
assert_eq!(outcome.settling, Settling::Agreed);
for (at, choice) in outcome.choices.iter().enumerate() {
let weighted: f64 = outcome
.agents
.iter()
.map(|a| {
let vote = f64::from(u8::from(a.voted == *choice));
a.power.expect("power") * vote
})
.sum();
assert!(
(weighted - outcome.consensus.as_ref().expect("consensus")[at]).abs() < 1e-6,
"{choice}: {weighted} vs {outcome:?}"
);
}
}
#[test]
fn two_closed_groups_do_not_reach_a_consensus() {
let mut cfg = ConsensusSection::default();
for (who, whom) in [
("alice", "bob"),
("bob", "alice"),
("carol", "dave"),
("dave", "carol"),
] {
cfg.trust
.insert(who.to_string(), BTreeMap::from([(whom.to_string(), 1.0)]));
}
let ballots = [
ballot("alice", "ship"),
ballot("bob", "ship"),
ballot("carol", "hold"),
ballot("dave", "hold"),
];
let outcome = settle(&ballots, &cfg);
assert_eq!(outcome.settling, Settling::Split, "{outcome:?}");
assert!(outcome.consensus.is_none());
assert!(outcome.leader().is_none());
assert_eq!(outcome.factions.len(), 2, "{:?}", outcome.factions);
assert_eq!(
outcome.factions[0],
vec!["alice".to_string(), "bob".to_string()]
);
}
#[test]
fn a_periodic_trust_graph_is_reported_as_oscillating() {
let mut cfg = ConsensusSection {
self_weight: 0.0,
max_iterations: 50,
..ConsensusSection::default()
};
cfg.trust.insert(
"alice".to_string(),
BTreeMap::from([("bob".to_string(), 1.0)]),
);
cfg.trust.insert(
"bob".to_string(),
BTreeMap::from([("alice".to_string(), 1.0)]),
);
let ballots = [ballot("alice", "ship"), ballot("bob", "hold")];
let outcome = settle(&ballots, &cfg);
assert_eq!(outcome.settling, Settling::Oscillating, "{outcome:?}");
assert!(outcome.consensus.is_none());
}
#[test]
fn weight_on_an_agent_that_did_not_vote_is_dropped() {
let ballots = [ballot("alice", "ship"), ballot("bob", "hold")];
let mut with_absentee = ConsensusSection::default();
with_absentee.trust.insert(
"alice".to_string(),
BTreeMap::from([("absent".to_string(), 9.0), ("bob".to_string(), 1.0)]),
);
let mut without = ConsensusSection::default();
without.trust.insert(
"alice".to_string(),
BTreeMap::from([("bob".to_string(), 1.0)]),
);
assert_eq!(
settle(&ballots, &with_absentee).agents,
settle(&ballots, &without).agents,
"nine parts trust in an agent that did not vote changed the answer"
);
}
#[test]
fn a_slowly_mixing_group_still_reaches_a_consensus() {
let cfg = ConsensusSection {
self_weight: 0.99,
max_iterations: 40,
..ConsensusSection::default()
};
let ballots = [
ballot("alice", "ship"),
ballot("bob", "ship"),
ballot("carol", "hold"),
];
let outcome = settle(&ballots, &cfg);
assert_eq!(outcome.settling, Settling::Agreed, "{outcome:?}");
assert!(
outcome.budget_reached,
"40 rounds cannot settle this one, and the report has to say so"
);
}
#[test]
fn an_anchor_leaves_the_minority_still_holding_its_position() {
let ballots = [
ballot("alice", "ship"),
ballot("bob", "ship"),
ballot("carol", "hold"),
];
let unanchored = settle(&ballots, &ConsensusSection::default());
assert_eq!(unanchored.settling, Settling::Agreed);
assert!(unanchored.spread < 1e-6, "{unanchored:?}");
let anchored = settle(
&ballots,
&ConsensusSection {
susceptibility: 0.6,
..ConsensusSection::default()
},
);
assert_eq!(anchored.settling, Settling::Anchored, "{anchored:?}");
assert!(anchored.consensus.is_none(), "no one position to report");
assert!(
anchored.spread > 0.1,
"the disagreement is the result: {anchored:?}"
);
let hold = anchored
.choices
.iter()
.position(|c| c == "hold")
.expect("hold");
let carol = anchored
.agents
.iter()
.find(|a| a.agent == "carol")
.expect("carol");
let alice = anchored
.agents
.iter()
.find(|a| a.agent == "alice")
.expect("alice");
assert!(
carol.limit[hold] > alice.limit[hold],
"carol voted hold and stays nearer it: {anchored:?}"
);
}
#[test]
fn the_anchored_limit_solves_the_friedkin_johnsen_equation() {
let mut cfg = ConsensusSection {
susceptibility: 0.7,
..ConsensusSection::default()
};
cfg.susceptibility_of.insert("carol".to_string(), 0.25);
cfg.trust.insert(
"alice".to_string(),
BTreeMap::from([("carol".to_string(), 2.0), ("bob".to_string(), 1.0)]),
);
let ballots = [
ballot("alice", "ship"),
ballot("bob", "ship"),
ballot("carol", "hold"),
];
let outcome = settle(&ballots, &cfg);
assert_eq!(outcome.settling, Settling::Anchored);
let names: Vec<&str> = outcome.agents.iter().map(|a| a.agent.as_str()).collect();
let (weights, _) = influence(&names, &cfg);
for (i, row) in outcome.agents.iter().enumerate() {
for (c, choice) in outcome.choices.iter().enumerate() {
let neighbours: f64 = outcome
.agents
.iter()
.enumerate()
.map(|(j, other)| weights[i][j] * other.limit[c])
.sum();
let own = f64::from(u8::from(row.voted == *choice));
let pull = row.susceptibility;
let want = pull * neighbours + (1.0 - pull) * own;
assert!(
(want - row.limit[c]).abs() < 1e-6,
"{} on {choice}: {want} vs {}",
row.agent,
row.limit[c]
);
}
}
}
#[test]
fn a_named_agent_carries_its_own_susceptibility() {
let mut cfg = ConsensusSection {
susceptibility: 0.9,
..ConsensusSection::default()
};
cfg.susceptibility_of.insert("maintainer".to_string(), 0.1);
let ballots = [
ballot("maintainer", "hold"),
ballot("newcomer", "ship"),
ballot("other", "ship"),
];
let outcome = settle(&ballots, &cfg);
assert_eq!(outcome.settling, Settling::Anchored);
let of = |who: &str| {
outcome
.agents
.iter()
.find(|a| a.agent == who)
.expect("agent")
};
assert!((of("maintainer").susceptibility - 0.1).abs() < f64::EPSILON);
assert!((of("newcomer").susceptibility - 0.9).abs() < f64::EPSILON);
let hold = outcome
.choices
.iter()
.position(|c| c == "hold")
.expect("hold");
assert!(
of("maintainer").limit[hold] > of("newcomer").limit[hold],
"{outcome:?}"
);
}
#[test]
fn an_agent_at_zero_never_leaves_its_ballot() {
let mut cfg = ConsensusSection::default();
cfg.susceptibility_of.insert("rock".to_string(), 0.0);
let ballots = [
ballot("rock", "hold"),
ballot("a", "ship"),
ballot("b", "ship"),
];
let outcome = settle(&ballots, &cfg);
let hold = outcome
.choices
.iter()
.position(|c| c == "hold")
.expect("hold");
let rock = outcome
.agents
.iter()
.find(|a| a.agent == "rock")
.expect("rock");
assert!(
(rock.limit[hold] - 1.0).abs() < 1e-9,
"it voted hold and never moved: {outcome:?}"
);
let a = outcome.agents.iter().find(|x| x.agent == "a").expect("a");
assert!(a.limit[hold] > 0.0, "{outcome:?}");
}
#[test]
fn naming_nobody_is_the_scalar_case() {
let ballots = [ballot("a", "ship"), ballot("b", "hold")];
let scalar = ConsensusSection {
susceptibility: 0.5,
..ConsensusSection::default()
};
let mut spelled_out = scalar.clone();
for who in ["a", "b"] {
spelled_out.susceptibility_of.insert(who.to_string(), 0.5);
}
assert_eq!(
settle(&ballots, &scalar).agents,
settle(&ballots, &spelled_out).agents
);
}
#[test]
fn an_anchor_removes_the_periodic_case() {
let mut cfg = ConsensusSection {
self_weight: 0.0,
susceptibility: 0.9,
max_iterations: 500,
..ConsensusSection::default()
};
cfg.trust.insert(
"alice".to_string(),
BTreeMap::from([("bob".to_string(), 1.0)]),
);
cfg.trust.insert(
"bob".to_string(),
BTreeMap::from([("alice".to_string(), 1.0)]),
);
let ballots = [ballot("alice", "ship"), ballot("bob", "hold")];
let unanchored = settle(
&ballots,
&ConsensusSection {
susceptibility: 1.0,
..cfg.clone()
},
);
assert_eq!(unanchored.settling, Settling::Oscillating);
let anchored = settle(&ballots, &cfg);
assert_eq!(anchored.settling, Settling::Anchored, "{anchored:?}");
assert!(
!anchored.budget_reached,
"a contraction settles well inside the budget: {anchored:?}"
);
}
#[test]
fn full_susceptibility_is_the_unanchored_model() {
let ballots = [
ballot("alice", "ship"),
ballot("bob", "hold"),
ballot("carol", "ship"),
];
let default = settle(&ballots, &ConsensusSection::default());
let explicit = settle(
&ballots,
&ConsensusSection {
susceptibility: 1.0,
..ConsensusSection::default()
},
);
assert_eq!(default.settling, explicit.settling);
assert_eq!(default.agents, explicit.agents);
assert_eq!(default.consensus, explicit.consensus);
}
#[test]
fn an_exact_tie_has_no_leader() {
let cfg = ConsensusSection::default();
let ballots = [ballot("alice", "ship"), ballot("bob", "hold")];
let outcome = settle(&ballots, &cfg);
assert_eq!(outcome.settling, Settling::Agreed);
assert!(outcome.leader().is_none(), "{outcome:?}");
}
#[test]
fn a_single_ballot_settles_on_itself() {
let cfg = ConsensusSection::default();
let outcome = settle(&[ballot("alice", "ship")], &cfg);
assert_eq!(outcome.settling, Settling::Agreed);
assert_eq!(outcome.agents.len(), 1);
assert!((share(&outcome, "ship") - 1.0).abs() < 1e-9);
assert!((outcome.agents[0].power.unwrap() - 1.0).abs() < 1e-9);
}
#[test]
fn no_ballots_leaves_no_consensus_to_report() {
let outcome = settle(&[], &ConsensusSection::default());
assert!(outcome.agents.is_empty());
assert!(outcome.consensus.is_none());
assert!(outcome.leader().is_none());
}
#[test]
fn every_influence_row_is_stochastic() {
let mut cfg = ConsensusSection::default();
cfg.trust.insert(
"alice".to_string(),
BTreeMap::from([("bob".to_string(), 7.5), ("carol".to_string(), 0.25)]),
);
cfg.trust.insert(
"bob".to_string(),
BTreeMap::from([("bob".to_string(), 4.0), ("alice".to_string(), 1.0)]),
);
let (weights, source) = influence(&["alice", "bob", "carol"], &cfg);
assert_eq!(source, TrustSource::Configured);
for row in &weights {
let sum: f64 = row.iter().sum();
assert!((sum - 1.0).abs() < 1e-12, "{row:?} sums to {sum}");
}
assert!((weights[1][1] - 0.8).abs() < 1e-12, "{:?}", weights[1]);
assert!((weights[0][0] - cfg.self_weight).abs() < 1e-12);
}
}