Skip to content

Architecture

Three layers of infrastructure

Both loops — the longitudinal simulation loop inside a run, the controllable research loop across versions — sit on one infrastructure with three layers. Each layer owns different files, and knowing which layer a question belongs to is usually the fastest way to find the code.

layer what it does where it lives
workflow the sv-* skills. Each reads the artifacts of earlier stages, writes exactly one validated artifact of its own, and stops at a checkpoint .claude/skills/sv-*/SKILL.md; shared helpers in skills/sv_workspace.py (study dirs, versions, branches) and skills/sv_grounding.py
service resolves P and the exogenous part of E from real-world data, as typed MCP tools called at build time — the population service (sv-user-pool) and the event service (sv-event-tool) deployed server-side, reached over MCP; results are materialized into population/ and environment/ and the runtime never calls out. See Data Services
governance records what each stage did, which data it used, and what the researcher approved grounding/grounding.json, resources.json, versions.json, and the events table in trajectory/study.duckdb

This page is about what those layers stand on: the runtime.

Core runtime and study layer

The runtime is split into a Core runtime you reuse and a study layer you write. Core defines the contract and drives the loop; a study supplies the implementations. You never modify the engine to add a study.

socioverse/                CORE RUNTIME — reused, you don't touch it
├── schemas/               typed contracts (StudySpec, EnvironmentBundle, PopulationBundle,
│                          SimulationConfig, Observation, Action, TrajectoryRecord, …)
├── abc/                   the interfaces a study implements (EnvironmentProvider,
│                          PopulationProvider, DecisionModel, MetricCollector)
│                          + Simulator / TrajectoryStore / Reporter / MessageBus
├── engine/                LongitudinalSimulator (the loop), AgentMemory, registry,
│                          InMemoryMessageBus, build_simulator (assembly)
├── env_layers/            information-axis helpers (broadcast delivery, neighbour feed)
├── io/duckdb_store.py     the queryable panel / metrics / events / messages store
├── providers.py           generic File / MCP population providers
└── validation.py          validate_handoff — the strict stage-boundary guard

studies/<id>/              STUDY LAYER — what you write (or generate)
└── model.py + artifacts   four interface implementations + study.yaml / env / pop / sim

The four interfaces

A study is complete when it implements four abstract base classes from socioverse/abc/:

interface responsibility key methods
EnvironmentProvider own the world state E reset, advance_to(t) (exogenous), observe_batch() (4-quadrant views), apply(actions) (endogenous)
PopulationProvider build the fixed pool P build() → Persona[] with deterministic persistent ids + interaction structure
DecisionModel compute B = f(P, E) decide_batch(observations) → Action[] — the whole step in, typed
MetricCollector measure each step collect() → the step's aggregate metrics

Core provides the rest: the LongitudinalSimulator loop, the trajectory store, the reporter, the message bus, agent memory.

The runtime path

LongitudinalSimulator.run() (socioverse/engine/loop.py) builds P once, resets E, and then runs the same five phases every step, in this order:

build()  → Persona[]                     # P: built once, persistent ids, fixed thereafter
reset()                                  # E_0 — recorded as the step-0 panel rows
loop t = 1..N:
    ① exogenous update  advance_to(t)                    # scheduled events + broadcasts mutate E
    ② observe           observe_batch() → Observation[]  # per-agent 4-quadrant view
    ③ decide            decide_batch()  → Action[]       # = B_t = f(P, E_t, memory)
    ④ apply             apply(actions)                   # endogenous feedback: E_{t+1} = g(E_t, B_t)
    ⑤ record            collect() + store.record()       # panel rows + metrics + fired events
phase the call why it sits there
① exogenous update env.advance_to(t) → fired events an intervention enters before anyone observes, so every agent in the round decides against one and the same state
② observe env.observe_batch(ids, t, round) the only channel through which E reaches f — macro/local × physical/information
③ decide decision.decide_batch(obs, memories) the whole step in, typed out: one Action per agent, LLM or rule
④ apply env.apply(actions) on a from-scratch study this is a pure function of the actions passed in, which is what makes branch replay exact
⑤ record collector.collect(), store.record(...) memory window first, then one panel row per agent, one metrics row, and the events fired in ①

Two variations on the same five phases:

  • interaction_rounds = K > 1 repeats ②–④ K times inside the step; only the last round's actions reach the memory window and the panel row. See Multi-Round Interaction.
  • A branch replays ①, ④ and ⑤ from its parent version's stored actions (_replay_inherited) and starts calling the decision model only at its fork step. See Intervention & Branching.

The hand-offs between these calls are typed (Persona[] → Observation[] → Action[] → metrics), with every type defined in socioverse/schemas/. Schemas are a contract layer everything references — not a stage that data "flows through".

Scenario hooks built into Core

Three recurring needs are already solved in Core, so studies declare rather than implement them:

  • Audience-scoped information — Broadcast / InformationProgram (env_layers/information.py): audience: "all" reaches every agent's macro-information quadrant; a selector dict targets matching agents' local-information quadrant.
  • Large-scale agent interaction — a mediated MessageBus plus interaction_rounds (not O(N²) chatter): posts land on the bus, neighbours read them through the study's InteractionStructure (engine/messaging.py, env_layers/neighbor_feed.py).
  • Bring-your-own data — a ResourceManifest plus generic FilePopulationProvider / McpPopulationProvider (socioverse/providers.py) for populations sourced from files or external services.

One name clash worth internalizing

  • The Core Engine is the reused LongitudinalSimulator — the loop.
  • An engine-seam is study-internal glue that wraps a legacy simulator (see Wrap a Legacy Simulator). Core never calls it; only the study's own providers reference it.

Registration & assembly

Studies register their implementations with the @register(kind, key) decorator (socioverse.engine.registry), and the study's artifacts point at them by key (provider_ref, decision_ref, collector_ref, …). Assembly is then generic:

build_simulator(env_bundle=…, pop_bundle=…, sim_config=…, store_path=…)

resolves every *_ref against the registry, instantiates the providers, wires the DuckDB store, and returns a ready LongitudinalSimulator. That is the whole integration surface — no engine edits, no plugin system to learn.