Computaco

Driving a machine

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.

FieldCompiles to
schema_version, id1; the id is appended to the world's (northstar-company-2026+rumor-cascade).
worldThe 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.
seedThe world's seed and the root of every draw the society makes. --seed overrides it; sweep.replicates runs one experiment per seed.
clockquantum (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, operationstructure 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.
outcomespreregistered[] and (an addition) secondary[], each {id, metric, direction, at}.
interventionsdesign: matched_fork, fork_at (a round, or +2h), arms[] of {id, ops}.
sweepreplicates: a list of seeds.
tracesAccepted; 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.

RuleParam
{"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 valuea constant

Policies#

kindWhat the person does when active
llmLooks 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.
scriptedFor 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.
procedureDraws 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.
idleNothing.

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:

OpEffect
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":

ArmRumor reposts (preregistered)Rumor likesReplies to the rumorResident postsAll repostsDecisionsGestures
control460125237215
norm-all00046232191
norm-half130122252228

Rumor reposts by round (residents holding a repost at the round's end; a repost can be taken back):

Round01234567
control24444554
norm-all00000000
norm-half11111111

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:

Round01234567
norm-all reached1216161616161616
norm-half reached1014141516161616
norm-half screens differ913141516161616

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:

TriggerFires
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.channels and orgs.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 in uncompiled.