1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
51#[serde(rename_all = "lowercase")]
52pub enum TrustSource {
53 Default,
55 Configured,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
61#[serde(rename_all = "lowercase")]
62pub enum Settling {
63 Agreed,
65 Split,
68 Oscillating,
72 Anchored,
79}
80
81#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83pub struct AgentLimit {
84 pub agent: String,
86 pub voted: String,
88 pub limit: Vec<f64>,
91 pub power: Option<f64>,
95 pub susceptibility: f64,
101}
102
103#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
105pub struct Outcome {
106 pub choices: Vec<String>,
108 pub agents: Vec<AgentLimit>,
110 pub settling: Settling,
112 pub consensus: Option<Vec<f64>>,
114 pub factions: Vec<Vec<String>>,
116 pub rounds: usize,
118 pub budget_reached: bool,
121 pub trust: TrustSource,
123 pub susceptibility: f64,
127 pub spread: f64,
132}
133
134impl Outcome {
135 #[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 #[must_use]
162 pub fn settled(&self) -> bool {
163 self.settling == Settling::Agreed && self.leader().is_some()
164 }
165}
166
167const TIE_EPS: f64 = 1e-6;
173
174#[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 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 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 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 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
299pub 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
311pub 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
357fn 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
413fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
433enum Structure {
434 Convergent,
436 Split,
438 Periodic,
440}
441
442fn 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 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
486fn 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
543fn 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
595fn 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
610fn 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
646fn 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#[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 #[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 #[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 assert!(power("bob") < 1e-6, "{outcome:?}");
762 }
763
764 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 #[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 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 #[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 let a = outcome.agents.iter().find(|x| x.agent == "a").expect("a");
1071 assert!(a.limit[hold] > 0.0, "{outcome:?}");
1072 }
1073
1074 #[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 #[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 #[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 #[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 #[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 #[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 #[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 assert!((weights[1][1] - 0.8).abs() < 1e-12, "{:?}", weights[1]);
1209 assert!((weights[0][0] - cfg.self_weight).abs() < 1e-12);
1211 }
1212}