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.
  • Boundary systems on the society settlement cadence, which is Daily in this example. Each runs in a fixed settlement phase; together they 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.
终端窗口
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::DAILY, 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, the settlement cadence this example registers, which runs the society plugin’s settlement system, settle-social-transitions, in phase 7:

  • apply_pending_policies copies the pending decision onto council-alignment, then the transition rules run.
  • The same system then materializes what its changes invalidated, in one write of the root: the mobilization candidates (none in this run), the aggregates (the counts that main prints), and 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 society settlement, here 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.

The example registers SocietyPlugin::DAILY, so society settles at every boundary that carries SystemCadence::Daily. To settle on another cadence, register the plugin with it:

let plugin = SocietyPlugin::with_settlement_cadence(SystemCadence::Monthly);
  • A cadence is a category. Canwu keeps no calendar. A Monthly society settles whenever your host settles a Monthly boundary, however long your months are. For a week or another period, pick a category you do not otherwise use, such as EraScheduled, and settle a boundary of that category once per period. EventDriven is refused at registration.
  • A rate applies once per settlement. Author each rate for the cadence you register: a rule whose rate comes to 59,200 per million, as 01-awareness does above, moves that share of its source at every settlement, whether settlements are a day or a month apart. The remainder is carried exactly, so rounding never accumulates.
  • Everything waits for the cadence. Policy decisions, pressure revisions, due cohort transfers, rebases, and culture lifecycle changes are applied only at a settlement. If such a change must take effect within a day, keep the Daily cadence.
  • Due transfers settle as a batch by default. Every issuer of the period decided against the same state, so TransferSettlement::Batch, the default for SocietyPlugin::DAILY, default(), and with_settlement_cadence, checks each due transfer against the state at the start of the settlement and applies the ones that still fit in admission order, each to the state the earlier ones left; a transfer whose cohorts an earlier settlement changed after it was admitted settles as stale_source, and one whose quantity no longer fits its source cohort as insufficient_source. SocietyPlugin::with_transfer_settlement(TransferSettlement::OneByOne) restores the 0.15.0 rule: each due transfer is checked against the state the earlier transfers of the same settlement left, so of the due transfers that share a cohort one completes and the others settle as stale_source and must be reissued. Choose Batch under a coarse cadence, or whenever several transfers may move people out of or into one cohort between two settlements; choose OneByOne when each transfer must see the result of the one before it. The mode is part of the plugin identity, so restore and replay with a plugin of the same mode.
  • A settlement that changes nothing writes nothing. An idle society writes no new record version and no record-change evidence. An observer’s projection, with its observed_at time, changes only when that observer’s own estimate changes.
  • The cadence is saved with the run. It is part of the plugin descriptor, so restore and replay the run with a plugin of the same cadence. from_society_snapshot_json expects Daily; for another cadence, call from_society_snapshot_json_with_plugin(&saved, &plugin).
  • A coarse cadence makes the intake queue wait. The queue holds at most 4,096 packets (MAX_SOCIETY_INGRESS_QUEUE). A packet that arrives while it is full is refused by the boundary that admits it, with a terminal queue_full outcome in the cohort exchange ledger and a society_ingress_rejected_v1 event (or society_pressure_revision_rejected_v1 for a revision), and must be sent again; a revise_society_pressure command is refused at admission while the queue is full. The queue is a small head record plus one batch record per admitting boundary (SocietyIngressBatch, society_ingress_queue_entries reads it), so its evidence follows what each boundary admits, however long the queue waits.

set_institutional_policy changes three fields of one alignment. To change the pressure itself during a run, an institution uses the revise_society_pressure command. Its payload is a PressureRevisionDecision, and pressure_revision_ticket builds the decision ticket the way institutional_policy_ticket does for a policy. In this example the council could cut the network’s contact with the village:

PressureRevisionDecision {
alignment_id: "council-alignment".to_owned(),
decision_version: 1,
expected_version: 0,
policies: Vec::new(),
influence_edges: vec![InfluenceEdgeRevision {
edge_id: "network-contact".to_owned(),
active: false,
reach_per_mille: 700,
}],
organizations: Vec::new(),
}

Through an alignment, the institution may revise only what acts on the alignment’s affected cohorts:

  • the eight pressure fields of a PolicyPressure row of the same target that the institution itself issued (its issuer) and whose affected cohorts lie within the alignment’s. A row without an issuer, such as local-policy in this example, cannot be revised;
  • active and reach_per_mille of an influence edge of that target whose target cohort lies within the alignment’s;
  • active and base_reach_per_mille of an organization node of that target, only through an alignment that covers every cohort, because an organization acts on every cohort of its target.

A decision states every field of each row it revises, revises at most 256 rows, and applies as a whole or not at all. It never adds or removes a row and never changes a transition rule. Each alignment has its own sequence of revision versions: a decision applies when its decision_version is newer than the last applied one and its expected_version equals that last applied version, which is 0 before the first.

The command queues the decision as an internal packet. The next boundary admits the packet, and the first society settlement after that applies it, before that settlement’s transitions; set_institutional_policy takes effect one boundary sooner. The outcome is kept in SocietyCohortExchangeLedger::pressure_revisions and emitted as a society_pressure_revised_v1 or society_pressure_revision_rejected_v1 event. The pressure revision tests show each case.

  • 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 society settlement, as gap_g32_society_cohort_rebase in the same tests shows.
  • Refused commands. An expected refusal of a society command is recorded as a rejected command attempt, and its boundary still commits. For example, a resent policy decision is rejected as IdempotencyConflict, and a transfer that names an unknown cohort as EntityNotFound. A resent transfer or pressure revision is accepted and changes nothing.
  • 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