Local community diffusion and institutional response
The scenario
Section titled “The scenario”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.
What this example shows
Section titled “What this example shows”canwu-society, an optional domain extension crate that you add next tocanwu-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 aDispositionDistributionthat splits the headcount intoDispositionBuckets. - Diffusion:
SocialInfluenceEdgeandOrganizationNodeproduce signals,TransitionRulemoves whole people between profiles, andTransitionRemainderkeeps the fractions. - A decision ticket built by
institutional_policy_ticket, whose chosen option runs the domain commandset_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.
Run it
Section titled “Run it”cargo run -p canwu-society --example local_community_diffusionThe full output:
day 1: local diffusion: aware=118 assenting=5 public=0 hidden=2000 of 2000policy selected; population dispositions remain unchanged until the next daily boundaryday 2: institutional response applied: aware=229 assenting=23 public=5 hidden=1995 of 2000observer estimate: public=23 tied=20 (ground truth public=5 tied=0)snapshot restore, fork, and exact replay reproduced the same authoritative stateThe 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.
Walkthrough
Section titled “Walkthrough”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 |
1. Describe people as distributions
Section titled “1. Describe people as distributions”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.
2. Separate opportunity from conversion
Section titled “2. Separate opportunity from conversion”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.
3. Let the council choose a policy
Section titled “3. Let the council choose a policy”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_policiescopies the pending decision ontocouncil-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
mainprints), 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.
5. Read the observer’s estimate
Section titled “5. Read the observer’s estimate”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.
6. Restore, fork, and replay
Section titled “6. Restore, fork, and replay”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.
What to notice
Section titled “What to notice”- 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.
Try changing
Section titled “Try changing”- Policy choice. In
register_policy_decision, raise thecontrolinput oflimited-accessto 200. The utility policy selectslimited-access, whose enforcement is 0, so03-publichas a rate of 0 and day 2 printspublic=0. - Observer accuracy. Set
false_positive_per_mille: 0in the observer profile. The estimate becomespublic=4 tied=0, because5 × 800 / 1,000rounds down to 4. - Influence. Set
active: falseon thenetwork-contactedge. Only the market’s 128 per mille of influence remains, and day 1 printsaware=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.
Beyond the example
Section titled “Beyond the example”Mobilization candidates
Section titled “Mobilization candidates”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.
Settle on another cadence
Section titled “Settle on another cadence”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
Monthlysociety settles whenever your host settles aMonthlyboundary, however long your months are. For a week or another period, pick a category you do not otherwise use, such asEraScheduled, and settle a boundary of that category once per period.EventDrivenis 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-awarenessdoes 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
Dailycadence. - Due transfers settle as a batch by default. Every issuer of the period
decided against the same state, so
TransferSettlement::Batch, the default forSocietyPlugin::DAILY,default(), andwith_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 asstale_source, and one whose quantity no longer fits its source cohort asinsufficient_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 asstale_sourceand must be reissued. ChooseBatchunder a coarse cadence, or whenever several transfers may move people out of or into one cohort between two settlements; chooseOneByOnewhen 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_attime, 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_jsonexpectsDaily; for another cadence, callfrom_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 terminalqueue_fulloutcome in the cohort exchange ledger and asociety_ingress_rejected_v1event (orsociety_pressure_revision_rejected_v1for a revision), and must be sent again; arevise_society_pressurecommand 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_entriesreads it), so its evidence follows what each boundary admits, however long the queue waits.
Revise pressure at runtime
Section titled “Revise pressure at runtime”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
PolicyPressurerow of the same target that the institution itself issued (itsissuer) and whose affected cohorts lie within the alignment’s. A row without an issuer, such aslocal-policyin this example, cannot be revised; activeandreach_per_milleof an influence edge of that target whose target cohort lies within the alignment’s;activeandbase_reach_per_milleof 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.
Other features
Section titled “Other features”- Policy provenance. A
PolicyPressurecan record itsissuerand thedecision_versionof the decision that produced it, asgap_g31_society_policy_pressure_provenancein 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, asgap_g32_society_cohort_rebasein 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 asEntityNotFound. A resent transfer or pressure revision is accepted and changes nothing. - Culture lifecycle.
canwu-cultureinstalls culture targets into society state and moves them betweenActive,Dormant, andRetired; see Society, culture, and law.
Source
Section titled “Source”Open the runnable example
Read the society framework tests
Read the rebase and policy provenance tests