Societies#
A society is many simulated people, each acting through the screen of their own phone or
computer, in one world. A spec says who they are, what devices and accounts they have,
who follows whom, how each one decides and when, what is injected from outside, and
what experiment is run over them. scripts/society/ compiles it
down to the engine's own primitives and runs it on the world clock, so a society run is
a pure function of the world, the spec, the seed and a journal of model responses: it
replays to the same state hash, and its arms can be compared person by person.
The design, and what it borrows from OASIS, AgentSociety, Y Social, RePair and Klein et al.'s common random numbers, is argued in the coordination survey. This page is the implementation: the schema as it runs, how it compiles, the matched-fork design, and a measured experiment.
python scripts/society/experiment.py worlds/company-2026/societies/rumor-cascade.json \
--out target/society/rumor-cascade # runs it, asking the model
python scripts/society/experiment.py worlds/company-2026/societies/rumor-cascade.json \
--out target/society/replay --offline \
--journal worlds/company-2026/societies/rumor-cascade.journal.jsonl # replays it, asking nothing
python scripts/society/test_society.py # offline checks, no model
The spec#
Specs are JSON and live beside the world they run in:
worlds/<world>/societies/*.json. The field names are the survey's
schema sketch; where this
implementation adds a field or leaves part of the sketch uncompiled, the table says so.
| Field | Compiles to |
|---|---|
schema_version, id | 1; the id is appended to the world's (northstar-company-2026+rumor-cascade). |
world | The base world (worlds/company-2026 or a world.json). It is copied, never edited. Omitted, the world whose societies/ folder holds the spec. |
world_overlay (an addition) | An entities overlay (scripts/entities) applied to world with the entity compiler's own apply_overlay (new services, service_patches, internet_overlays, network, id_suffix), so a spec can name a base world and an overlay instead of a copied world.json. |
seed | The world's seed and the root of every draw the society makes. --seed overrides it; sweep.replicates runs one experiment per seed. |
clock | quantum (a round, e.g. 1h), rounds or horizon (8h is eight one-hour rounds), slot (2m, each active person's share of a round) and start (the wall-clock time of round 0, read for its hour of day). |
structure, operation | structure is description only. operation.observation is semantic (granted semantic.v1) or pixels; max_actions_per_session becomes the session's action budget and a cap on a person's gestures; max_actions_per_activation (an addition) caps gestures per activation, default 4. |
people[] | Named individuals: id, devices, accounts, persona, rules, policy, activation, unit. |
populations[] | count people sampled from sample, with persona, device (or devices), accounts, rules, policy and activation as templates over the sampled values ({name}, {age}, {handle}, {index}, {id}). Member ids are <population>-<index> (id_format changes that). |
devices[] | One computer each: machine, profile (ios, android, macos, windows, ubuntu), presentation (default by profile: phones are phone), apps, open (the app launched at boot, default the first), viewport (default 390×844 for a phone, 1280×800 otherwise), files. |
accounts | {site: handle} per person. On a social site (x-social, mastodon, bsky, linkedin, …) an account with actor set to the person, so the site knows who is signed in. Accounts on other kinds of service are listed as uncompiled. |
accounts[] (top level, an addition) | Accounts no person holds: a news outlet, a rumor mill. existing: true names one the base world already has (NorthstarHQ) without restating it. |
graphs[] | Follows on a social site: generator watts_strogatz (k, p; mutual), barabasi_albert (m, reciprocal), bernoulli (p, optionally from and to groups, directed) or complete, over a group. |
exogenous[] | {at: round, as: person or handle, do: {service, post: {id, text, reply_to, quoted, likes, reposts}}}. Round 0 posts are initial state; later ones are owner writes at the start of their round. |
orgs[] | units and reports_to become lines of each member's persona ("You work at … in the CAD unit", "You report to …"). channels and repos are listed as uncompiled. |
checks[] | {id, metric, min, max}, evaluated in every arm at every round. |
outcomes | preregistered[] and (an addition) secondary[], each {id, metric, direction, at}. |
interventions | design: matched_fork, fork_at (a round, or +2h), arms[] of {id, ops}. |
sweep | replicates: a list of seeds. |
traces | Accepted; everything is traced. deltas (an addition) lists services whose changes each activation records as a delta (see the ledger). |
Groups, wherever a field takes one: everyone, people, people.<id>,
populations.<id>, accounts.<handle>, or a list of them.
Sampling#
Each member of a population is the engine's draw:
computerworld.instantiate on a pseudo-procedure whose params are built from sample,
seeded from population/<id> at the member's index. So the draws are the procedure
engine's own, the same on every platform and binding.
| Rule | Param |
|---|---|
{"choice": [...], "weights": [3, 2]} | choice (weights repeat entries; whole numbers) |
{"choose": 2, "from": [...]} | choice over every combination |
{"dist": "uniform", "min": 18, "max": 70} | integer |
{"dist": "normal", "mean": 38, "sd": 13, "min": 18} | the sum of four integers (Irwin–Hall) with that mean and deviation, clamped; an sd under 5 is drawn in hundredths |
{"format": "{name}-{index}"} | filled after the draw |
| a plain value | a constant |
Policies#
kind | What the person does when active |
|---|---|
llm | Looks at the screen (windows, the controls that are on screen, their painted text), and asks the model for one gesture with persona, rules and intent, up to max_actions_per_activation times or until it answers done or wait. The prompt is scripts/society/policy.py's; the model is DeepSeek (DEEPSEEK_MODEL, default deepseek-flash, reasoning effort low) unless policy.model names another. |
scripted | For each post on screen not yet considered, one draw from policy/<person>/<post> chooses repost, like, reply (with reply_text) or nothing by the given chances. avoid_repost lists posts it never reposts. |
procedure | Draws an instance of procedure (a path or an inline procedure) from procedure/<person> at the round, stages it and projects it through the person's session. |
idle | Nothing. |
Every gesture goes through World.perform: the same grounding a procedure gets, so a
person never invents coordinates and every action is an ordinary recorded one. A
scroll over a post that nothing a click reaches falls back to a wheel over the middle
of the screen, as a thumb would.
Metrics#
Outcomes and checks are expressions over world.inspect()
(metrics.py):
count(x-social.reposts.rumor) # members of a set
size(x-social.likes) # members of every set: all likes
count(x-social.posts where reply_to = "rumor") # items passing a filter
count(x-social.posts where author in populations.residents and text contains "Northstar")
sum(likes of x-social.posts where author = "NorthstarHQ") # also min, max, mean
share(x-social.posts where quoted = "rumor") # passing / all
rate(count(x-social.reposts.rumor), 16) # a / b, 0 when b is 0
count(x-social.reposts.rumor) / 16 # + - * / between metrics
A source is a JSON Pointer into inspect()["state"] or <service>.<key>…, short for
/services/<service>/state/<key>/…. Filters compare a field (. the item, @key its
key, a.b nested, items.0.product a list element by index) with =, !=, <, <=, >, >=, contains, or in a list or a
group (its actors and handles), joined by and.
How it runs#
Round r owns the ticks [base + r·quantum, base + (r+1)·quantum). At its start the
injections due are written. Then each active person acts, the *k*-th in the round's
order starting at exactly base + r·quantum + (k+1)·slot. Whether a person is active
is uniform(seed, "activation/<person>", r) < propensity(hour); the order is a shuffle
from order/<r>. Both depend on the seed alone, so every arm activates the same people
in the same order at the same ticks, whatever anyone did — provided the binding can
move the clock (World.advance). Without it time moves only with actions, and a person
who acts more in one arm shifts every later screen's clock.
An active person's device first reloads the app (browser.v1 navigate to the site),
as picking a phone up does. Each round ends by recording the state hash, every
person's screen digest (a hash of the scene they would see now) and every metric.
The trace is JSON lines per arm (trace-<arm>.jsonl): boot, round_start (who is
active), activate, decision (the screen digest, the journal key, the model's answer,
the gesture and the steps it took, or the error), inject, procedure, intervention
and round_end.
The LLM journal#
Model responses are kept in journal.jsonl, one line per response, keyed by
sha256 of (model, prompt messages) and, for the *n*-th time one person meets the same
prompt (n > 0), n. Every reply is kept, readable or not, because it is replay input.
The first line is a header pinning the journal to what it was recorded against: the
engine version and SHA-256 digests of the compiled world definition and the spec. Since
prompts are keyed by exact screen bytes, a journal replays only against the screens it
was recorded from; one whose header differs from the run's (or that has none) is refused
before the run starts, naming what differs, instead of missing on its first lookup.
With --parallel the arms run in threads, and a prompt asked by two arms at once is
asked of the model once while the other arm waits for that answer. Each arm's world
rebuilds the script realms it forked with on its own thread (the binding releases the
GIL in step, perform, observe, scene, render and advance), and a threaded
run reaches the same hashes, decisions and outcomes as a sequential one
(scripts/society/test_society.py). --offline answers only from the journal and fails on a miss.
The matched-fork design#
boot ─ compile ─ run to fork_at ─ snapshot ─┬─ fork → control ─ run to horizon
├─ fork → arm ops → B ─ run to horizon
└─ fork → arm ops → C ─ run to horizon
fork_at is a round. The world is forked (World.fork of one snapshot) and every
person's runner state (memory, rules, policy, counters) is copied. Each arm then
applies its ops:
| Op | Effect |
|---|---|
append_rule {select, text} | Appends a rule to the selected people's personas (RePair's local rules). |
set_policy {select, set} (an addition) | Merges keys into the selected people's policies. |
set_state {service, pointer, value} | An owner write to a service's state: an RFC 7386 merge patch of that one service (World.patch_service_state), journaled so a replay makes it again. |
post {as, at, service, post} (an addition) | An exogenous post in this arm alone: as a person or account handle, at a round. Due at or before the fork it is written at once; later, at the start of its round, like a spec-wide exogenous post. |
select takes population, people, group, any sampled field (equal, or contained in a
list value), and fraction (a draw per person from select/<arm>/<op>).
Because activation, order, slot ticks and scripted draws are per-person named streams,
and model responses are looked up by prompt, a person whose screen, memory and persona
are byte-identical in two arms gets the identical response and does the identical
thing. The arms differ only where the intervention reached someone. The summary checks
this: crn.<arm> pairs every decision with the first arm's at the same (round, person,
step), and among pairs with the same input (the journal key, or a scripted person's
screen, parameters and history) counts how many made the same decision. Any pair that
did not is a violation.
Divergence is reported per round against the first arm: whether the state hashes are equal, how many people's screens differ, and how many people have been reached (made a decision on a different input, or a different number of decisions).
The rumor cascade#
worlds/company-2026/societies/rumor-cascade.json:
sixteen Seattle residents on iPhones and Android phones (3:2), each with a sampled name,
age, job, two interests and a temperament, using x.com in the Northstar world. They
follow each other in a small world (Watts–Strogatz, k = 4, p = 0.15); each follows
an unverified account, @sea_insider_now, with probability ½, and @NorthstarHQ with
probability 0.3. At round 0 the insider posts that Northstar is laying off half its
Seattle staff and shutting Atlas down ("Repost before they make me delete this"), and
four residents post ordinary things. At round 4 Northstar denies it. Eight one-hour
rounds from 17:00; a resident is active in a round with probability 0.6 until 20:00
and 0.4 after, and takes up to four gestures.
Arms, forked before round 0: control; norm-all, in which every resident's profile
gains "Before you repost a claim, check whether anyone credible has confirmed it."; and
norm-half, the same rule for a random half. The preregistered outcome is the number of
residents who reposted the rumor at the horizon (count(x-social.reposts.rumor)),
expected to decrease; likes, replies and quotes of the rumor are secondary.
Measured#
Run once with DeepSeek (deepseek-flash, reasoning effort low) and seed 7, the three
arms in parallel threads (--parallel), on the engine in which reposts reach
followers' Home timelines ("Omar reposted") and post ages read "4m", "2h":
| Arm | Rumor reposts (preregistered) | Rumor likes | Replies to the rumor | Resident posts | All reposts | Decisions | Gestures |
|---|---|---|---|---|---|---|---|
control | 4 | 6 | 0 | 12 | 5 | 237 | 215 |
norm-all | 0 | 0 | 0 | 4 | 6 | 232 | 191 |
norm-half | 1 | 3 | 0 | 12 | 2 | 252 | 228 |
Rumor reposts by round (residents holding a repost at the round's end; a repost can be taken back):
| Round | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
control | 2 | 4 | 4 | 4 | 4 | 5 | 5 | 4 |
norm-all | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 |
norm-half | 1 | 1 | 1 | 1 | 1 | 1 | 1 | 1 |
The same 67 activations happened in every arm (the activation streams do not depend on
what anyone did) and every one started on its slot's tick. With the rule, residents
searched X before deciding: 34 search decisions in norm-all and 28 in norm-half,
against 5 in control. In norm-all five residents reposted Northstar's denial after
round 4; nobody did in the other arms. In norm-half the fraction: 0.5
draw told 5 of the 16; the one rumor repost left at the horizon is by a resident who
was not told, and the one told resident who reposted it took the repost back the next
round. Nobody replied to the rumor itself in any arm. This is one seed of one
population. The differences are counts of a handful of people, not estimates with an
error bar; sweep.replicates is how to get those.
Divergence and common random numbers. Per round, against control, as people reached (made a decision on a different input, or a different number of decisions) and people whose screen differs at the round's end:
| Round | 0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 |
|---|---|---|---|---|---|---|---|---|
norm-all reached | 12 | 16 | 16 | 16 | 16 | 16 | 16 | 16 |
norm-half reached | 10 | 14 | 14 | 15 | 16 | 16 | 16 | 16 |
norm-half screens differ | 9 | 13 | 14 | 15 | 16 | 16 | 16 | 16 |
Every resident was reached by the end in both arms. Of 225 norm-half decisions
paired with control, 16 had byte-identical prompts, and all 16 got the identical
response and took the identical gesture. In norm-all every prompt carries the rule, so
no pair is identical and the check is empty. crn.holds was true for both arms.
Cost. 693 model calls: 774,084 prompt tokens and 150,143 completion tokens, about 1,117 and 217 per call. 28 answers were served from the journal during the run because another arm had already asked the identical prompt (and was waited for, when both asked at once). With the arms in parallel the run took 625 s; the previous, sequential run of this experiment took 1,645 s for 681 calls.
Replay. The journal (693 entries after its header,
rumor-cascade.journal.jsonl,
kept beside the spec) replays the whole experiment with no model calls: 721 journal
lookups in about 20 s. Replayed in sequence, twice, it reproduces the live parallel
run's state hashes in every round of every arm (control's last is 0eea06388cccdc50),
as does a parallel replay. A build whose screens differ refuses the journal by its
header rather than replaying it wrongly:
J=worlds/company-2026/societies/rumor-cascade.journal.jsonl
python scripts/society/experiment.py worlds/company-2026/societies/rumor-cascade.json \
--out target/society/replay-a --journal $J --offline
python scripts/society/experiment.py worlds/company-2026/societies/rumor-cascade.json \
--out target/society/replay-b --journal $J --offline --expect target/society/replay-a/summary.json
# replay: identical
The exposure-and-action ledger#
Two trace fields turn a run into an event-level ledger of who saw what and then did
what. Every decision carries seen: the posts inside that person's viewport when they
decided (each post on a social site has its own content:<post>-like, -repost and
-replies controls, and only controls on screen are listed). With traces.deltas
naming a service, every activation that changed it leaves a delta record: the merge
patch of the change and the patch that undoes it. Only one person acts in an activation,
so every change in it is theirs; reading the service's state costs about 0.2 ms, against
~140 ms for inspect().
ledger.py reads them back: exposures (sightings,
first ones marked), actions (likes, reposts, follows, posts, replies, quotes and
messages, with who and when), lineage (each post's artifact family through
reply_to and quoted), attribute (each conversion's credit split over the posts its
person saw before it: first, last, linear, or full, which hands every touched
post the whole conversion and so double counts), anchor (scale credit to a lift the
matched forks measured), and shapley and increments for artifacts tested in
combination. python scripts/society/test_ledger.py checks it offline.
The launch-campaign study is
its first user.
Organisations: work#
A named person's work is a list of procedures they are responsible for. Each entry is
{procedure, as, joins, at | on, overrides, mode, seed}. Entries naming the same
procedure are one item. Everyone in it plays the role their entry gives (as). The entry
without joins owns the item, and its trigger is the item's:
| Trigger | Fires |
|---|---|
at: {round: r} | at round r |
on: {procedure: id, event: verified} (or completed) | once that item has verified |
on: {service, event: message, from, to, channel, mentions} | the first round the service holds a matching message (a mail from one person to another, a chat message in a channel mentioning someone; one present at boot fires in round 0) |
At the start of each round, before anyone's activation, the runner runs every item
whose trigger has fired. Due rounds go first, then events, in spec order, repeating
until none fires. Before an item, everyone in it closes the windows their last one left
open. The item is instantiated (its seed, operation.work_seed, or a draw from
work/<procedure>), staged, and projected with one session per role
(world.project({role: session}, …)), in mode (operation.work_mode, default gui).
Its verdict goes to the trace. A device with existing: true is a machine the base world
already has: the person acts as its user through one session on it. A procedure
person with no procedure of its own does nothing when merely activated.
worlds/robotics-co runs nine such items
across nine people this way: 735 GUI actions, all nine verified.
Gaps#
Found while building this, and not fixed here:
inspect()is the only way to read service state: about 140 ms on company-2026, paid per round for metrics and per round per message trigger.- Uncompiled parts of the sketch:
orgs.channelsandorgs.repos(Slack, mail, tracker and repository membership), accounts on services that are not social sites,activation.response_delay,policy.share: archetype,fidelity_sample, a ranker parameter, and a joining work entry's own trigger. The compiler lists each inuncompiled.