Architecture¶
SocioVerse 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[] — batched, 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¶
build() → Persona[] # P: built once, persistent ids, fixed thereafter
reset() # E_0
loop t = 1..N:
advance_to(t) # exogenous: scheduled events + broadcasts mutate E
observe_batch() → Observation[] # per-agent 4-quadrant view (macro/local × physical/info)
decide_batch() → Action[] # = B, batched decision (LLM or rule)
apply(actions) # endogenous feedback: E_t → E_{t+1}
collect() → metrics # measure
record() → DuckDB # persist panel rows + metrics + events
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
MessageBusplusinteraction_rounds(not O(N²) chatter): posts land on the bus, neighbours read them through the study'sInteractionStructure(engine/messaging.py,env_layers/neighbor_feed.py). - Bring-your-own data — a
ResourceManifestplus genericFilePopulationProvider/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:
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.