Skip to content

Local community diffusion and institutional response

A market of 1,000 people lies next to a village of 2,000. In the market, 200 people already support a new idea, and a small local network promotes it. Over two days, contact spreads awareness into the village. Between the two days, the local council chooses a public policy, which takes effect on day two. An observer then estimates how many villagers publicly align with the idea.

The question the example answers: how do you model shifting beliefs as integer headcounts that save, restore, and replay exactly, while the policy decision and the observer’s view go through Canwu’s authority and visibility checks?

The idea, cohorts, and numbers are synthetic and do not depict a real doctrine or event.

  • canwu-society, an optional domain extension crate that you add next to canwu-api. It brings its own types, record schemas, commands, and ingress, and is informally called the social diffusion simulation module.
  • Sparse population state: each cohort (SocietyCohort, a population group in one territory) has an integer headcount. Each pair of a cohort and a target (AffiliationTarget, the idea or group people take a stance on) has a DispositionDistribution that splits the headcount into DispositionBuckets.
  • Diffusion: SocialInfluenceEdge and OrganizationNode produce signals, TransitionRule moves whole people between profiles, and TransitionRemainder keeps the fractions.
  • A decision ticket built by institutional_policy_ticket, whose chosen option runs the domain command set_institutional_policy.
  • Daily boundary systems, each in a fixed settlement phase, that apply the policy, settle transitions, aggregate, and materialize projections.
  • An actor-relative estimate from projection_for_viewer.
  • Snapshot restore with from_society_snapshot_json, fork, and exact replay.
Terminal window
cargo run -p canwu-society --example local_community_diffusion

The full output:

day 1: local diffusion: aware=118 assenting=5 public=0 hidden=2000 of 2000
policy selected; population dispositions remain unchanged until the next daily boundary
day 2: institutional response applied: aware=229 assenting=23 public=5 hidden=1995 of 2000
observer estimate: public=23 tied=20 (ground truth public=5 tied=0)
snapshot restore, fork, and exact replay reproduced the same authoritative state

The counts are villagers for the target new-idea. aware have heard of it, assenting privately sympathize or more, public conform or advocate in public, hidden have Private or Hidden visibility, and tied have a tie to an organization.

All code is in local_community_diffusion.rs. main reuses the entities, world, and knowledge of the deprecated compatibility scenario Canwu::demo(42), adds one society record built by tutorial_state(), registers SocietyPlugin, and settles four boundaries:

Boundary Called from What happens
1 settle_day(&mut canwu, 1) Daily: day-1 diffusion
2 step_canonical() in register_policy_decision The controller is registered and the policy ticket opens
3 step_canonical() after drive_decision The trace is recorded and set_institutional_policy runs
4 settle_day(&mut canwu, 2) Daily: the policy applies, then day-2 diffusion

A DispositionProfile keeps seven dimensions apart. The market’s supporters use this profile:

let source = DispositionProfile {
awareness: AwarenessBand::Aware,
assent: AssentBand::Sympathetic,
practice: PracticeBand::Occasional,
public_alignment: PublicAlignmentBand::Advocating,
organizational_tie: OrganizationalTieBand::Member,
mobilization: MobilizationBand::Latent,
visibility: VisibilityBand::Public,
};

tutorial_state gives each cohort one distribution for new-idea:

insert_distribution(
&mut state,
"market",
1_000,
vec![(neutral, 800), (source, 200)],
);
insert_distribution(&mut state, "village", 2_000, vec![(neutral, 2_000)]);

SocietyState::validate requires the buckets of a distribution to add up to the cohort headcount. Only the pairs you insert get a distribution, and a transition rule creates a neutral one on demand for a cohort it affects. Each target has its own distribution, so one cohort can hold independent stances toward several targets.

Two SocialInfluenceEdges point at the village: market-contact from the market cohort and network-contact from the organization local-network. Each adds source strength × reach × trust / 1,000,000 to the village’s influence signal. In the market, 200 per mille of people support the idea, so 200 × 800 × 800 gives 128. The network has no links to other organizations, so its strength is its base reach of 300 per mille, and 300 × 700 × 800 gives 168. Together the influence signal is 296 per mille.

Three TransitionRules decide who actually changes profile, starting with awareness:

insert_rule(
&mut state,
"01-awareness",
neutral,
aware,
TransitionWeights {
influence: 200_000,
..TransitionWeights::default()
},
);

settle_transitions in solver.rs turns that weight into 296 × 200,000 / 1,000 = 59,200 per million. 2,000 × 59,200 / 1,000,000 = 118.4, so 118 people become aware on day 1, and the 0.4 person is stored as a TransitionRemainder and added in on day 2.

02-assent moves aware people to sympathetic, driven by influence and institutional support and slowed by policy coercion. 03-public moves sympathetic people to public conformity, driven only by institutional enforcement and policy coercion, and both start at 0. Rules run in rule-ID order, each rule visits its cohorts in cohort-ID order, and each rule sees the moves of the rules before it. On day 1, 02-assent therefore acts on the 118 newly aware villagers and moves 5 of them.

register_policy_decision binds the controller council-controller to the commander’s authority, with the government as command subject. It opens a ticket with institutional_policy_ticket, which turns each PolicyChoice into a decision option that carries a set_institutional_policy command:

PolicyChoice {
id: "public-conformity".to_owned(),
label: "Require public conformity".to_owned(),
decision: PolicyDecision {
alignment_id: "council-alignment".to_owned(),
decision_version: 1,
support_per_mille: 300,
enforcement_per_mille: 800,
access_grant_per_mille: 700,
},
utility_inputs: BTreeMap::from([("control".to_owned(), 100)]),
},

The other choice, limited-access, has enforcement_per_mille: 0 and a control input of 10. main scores the ticket with a WeightedUtilityPolicy that weights control by 1, so public-conformity wins 100 to 10. The ticket and controller mechanics are the same as in A warlord asks a neighbor for military aid.

The set_institutional_policy handler in plugin.rs accepts the command only from the ticket’s controller, for the institution that owns the alignment (InstitutionalAlignment), on behalf of the alignment’s authorized_actor, and with a decision_version newer than the applied and pending ones. It stores the decision as a pending plugin component, and main confirms that the distributions have not moved:

let immediately_after_decision = load_society_state(&canwu)?;
assert_eq!(
immediately_after_decision.distributions, before_policy.distributions,
"the policy choice must not instantly rewrite population dispositions"
);

4. Apply the policy at the next Daily boundary

Section titled “4. Apply the policy at the next Daily boundary”

settle_day settles a boundary with SystemCadence::Daily, which runs the society plugin’s Daily systems:

  • Phase 7, settle-social-transitions: apply_pending_policies copies the pending decision onto council-alignment, then the transition rules run.
  • Phase 10, evaluate-mobilization-candidates: none in this run.
  • Phase 12, aggregate-social-state: the counts that main prints.
  • Phase 13, materialize-society-projections: one estimate per observer.

With enforcement at 800 per mille, 03-public runs at 800 × 300,000 / 1,000 = 240,000 per million, and 5 of the 23 sympathetic villagers become publicly conforming.

let viewer = canwu.viewer_context(ids.observer)?;
let projection = projection_for_viewer(&canwu, &viewer)?;

projection_for_viewer checks that the ViewerContext equals the one Canwu issues for that actor, then returns the SocietyProjection materialized for the actor in phase 13. It reads only materialized projections, and an actor without one gets an InvalidAuthority error.

The observer’s ObserverProfile detects public alignment at 800 per mille and reports false positives at 10 per mille. Each estimate is (true count × detection + everyone else × false positive rate) / 1,000, rounded down:

  • public: (5 × 800 + 1,995 × 10) / 1,000 = 23
  • tied: the network’s concealment of 500 per mille halves the private detection rate of 200 to 100, so (0 × 100 + 2,000 × 10) / 1,000 = 20

False positives across 1,995 villagers outweigh the 5 real conformers, so the observer overestimates.

let saved = canwu.snapshot_json()?;
let restored = from_society_snapshot_json(&saved)?;
assert_eq!(restored.snapshot(), canwu.snapshot());
let forked = canwu.fork();
assert_eq!(forked.snapshot(), canwu.snapshot());
let replayed = Canwu::replay_from_journal(&[&plugin], &canwu.replay_journal())?;
assert_eq!(replayed.snapshot(), canwu.snapshot());

After the engine’s snapshot checks, from_society_snapshot_json runs validate_society_runtime. It checks that the society record’s core references match the entities its payload names, recomputes the stored aggregates, mobilization candidates, and projections to compare them, and re-derives any queued society ingress from the ingress journal. Remainders are part of the record, so a restored run carries the same fractions forward.

  • A policy decision changes an alignment’s signals at the next Daily boundary. People then move only through transition rules, so your rule weights decide how enforcement affects public conformity and private assent.
  • Fractions of a person are kept in TransitionRemainder, saved with the state, and added in at the next boundary.
  • The observer reads a projection with both missed and false detections. That estimate is what a client shows the player.
  • Policy choice. In register_policy_decision, raise the control input of limited-access to 200. The utility policy selects limited-access, whose enforcement is 0, so 03-public has a rate of 0 and day 2 prints public=0.
  • Observer accuracy. Set false_positive_per_mille: 0 in the observer profile. The estimate becomes public=4 tied=0, because 5 × 800 / 1,000 rounds down to 4.
  • Influence. Set active: false on the network-contact edge. Only the market’s 128 per mille of influence remains, and day 1 prints aware=51.
  • More days. Call settle_day(&mut canwu, 3) before reading the projection and watch how the counts and the estimate change on a third day.

When a bucket reaches mobilization: MobilizationBand::Active, the phase-10 system records a MobilizationCandidate for that distribution with the mobilized headcount, the target’s organization capacity, and the policy coercion on the cohort. Your political or security extension decides whether a candidate becomes a protest, a riot, a migration, or something else.

  • Policy provenance. A PolicyPressure can record its issuer and the decision_version of the decision that produced it, as gap_g31_society_policy_pressure_provenance in the rebase and policy provenance tests shows.
  • Cohort headcount rebase. This ingress (cohort_headcount_rebase_v1) resets a cohort’s headcount to a cited population record owned by another plugin and rescales its distributions at the next Daily boundary, as gap_g32_society_cohort_rebase in the same tests shows.
  • Culture lifecycle. canwu-culture installs culture targets into society state and moves them between Active, Dormant, and Retired; see Society, culture, and law.

Open the runnable example

Read the society framework tests

Read the rebase and policy provenance tests