Skip to content

Build a Study from Scratch (Path B)

This is the path when no existing study covers your question and you have no legacy simulator to wrap: implement the four core interfaces natively, register them, and let the engine assemble everything. The shipped template is studies/opinion_diffusion/ — its study.yaml carries "demonstrates": ["from-scratch-core", …] precisely so you can crib from it.

The template is selected, not hard-coded

/sv-build-model does not look for a study called opinion_diffusion. It queries the catalog for studies that are reference: true and whose demonstrates includes from-scratch-core, then reads the closest match's model.py and its test. So the shipped demo set can change without touching the skill — and if your study needs a specific pattern (multi-round-messaging, info-broadcast, …), prefer the reference study that demonstrates it. Each candidate's teaches line says what it is the exemplar of.

You can let the workflow write this for you

In Claude Code, /sv-build-model implements this whole page from your study.yaml. What follows is the same work done by hand — worth reading either way, because it is what you'll review.

0. Anchor the step in time, first

Before you write a single per-step magnitude, decide what one step means and record it in study.yaml:

time_unit: month           # day | week | month | quarter | year | abstract
step_meaning: "每步=1个月的消费决策周期"

Pick the scale the real behaviour unfolds on — a canteen-vs-delivery meal choice is monthly, an election-opinion shift is weekly, a housing move is yearly — and then make every rate in the model consistent with that unit: price drift, decay, budgets, arrival rates, move probabilities. A "0.02 drift" means nothing until the step has a unit; the same constant is a plausible monthly figure and an absurd daily one.

Don't silently default to abstract

time_unit defaults to "abstract" and is not validated, so an unanswered question and a deliberate choice look identical on disk. abstract is legitimate only when no calendar mapping is meaningful — the step really is a pure decision round — and then step_meaning must say so and justify it. Fixing the unit later means revisiting every constant you wrote, which is why this is step 0 and not step 6.

Field details: Anatomy of a Study.

1. Implement the four interfaces

All four live in your study's model.py. Signatures come from socioverse/abc/ (full details in the API reference):

from socioverse.abc import (
    EnvironmentProvider, PopulationProvider, DecisionModel, MetricCollector,
)
from socioverse.engine.registry import register


@register("environment", "yourstudy.env")
class YourEnvironmentProvider(EnvironmentProvider):
    def reset(self, seed): ...                 # build E_0 from self.bundle
    def advance_to(self, t): ...               # fire scheduled events + broadcasts (exogenous)
    def observe_batch(self, agent_ids, t, round_idx=0): ...  # 4-quadrant Observation per agent
    def apply(self, actions): ...              # fold behaviour back into E (endogenous)
    def agent_state(self, agent_id): ...       # the per-agent panel row


@register("population", "yourstudy.pop")
class YourPopulationProvider(PopulationProvider):
    def build(self, seed): ...                 # deterministic Personas with PERSISTENT ids


@register("decision", "yourstudy.decision")
class YourDecisionModel(DecisionModel):
    def decide_batch(self, obs, memories): ... # B = f(P, E) — batched, returns Action[]


@register("collector", "yourstudy.collector")
class YourMetricCollector(MetricCollector):
    def collect(self, env, actions, t): ...    # one flat dict of metrics per step
    def columns(self): ...                     # declared metric names (for the reporter)

Three invariants to respect:

  1. Persistent, deterministic ids. build(seed) must produce the same agents with the same ids every time — e.g. f"yourstudy-{i:03d}". The id is the longitudinal key; the pool never changes mid-run.
  2. Batch the decision. decide_batch gets every observation at once. If it calls an LLM, it makes batched calls — never a per-agent loop.
  3. Two time channels, kept separate. Exogenous change (scheduled events, broadcasts) belongs in advance_to(t); endogenous feedback (the agents' own actions) belongs in apply(actions).

An LLM decision must emit a first-person reason

When decide_batch is LLM-driven, the prompt must make the model return — alongside its action — a one-sentence reason written in the simulated person's own voice: how this persona would explain the choice to a peer, in their register and their language. Not an analyst's third-person summary, not a rubric score. One sentence.

Then persist it in all three places:

where why
payload["reason"] rides into the panel's action_payload
Action.rationale=[reason] the runtime's declared rationale channel
agent_state(agent_id) lands in roster.jsonl and the panel table's state — this is what the dashboard's agent inspector shows

A working precedent is studies/campus_dining_choice/model.py, whose build_student_prompt forces a strict JSON response and tells the agent what the field is for ("reason 会作为你分享给舍友的一句话"):

# in the prompt, verbatim shape:
#   {"choice": "canteen"|"delivery"|"cook", "satisfaction": <0~1>, "reason": "<一句话理由>"}

for ob, resp in results:
    dec = parse_decision(resp)
    if dec is None:                                    # unparseable → keep last choice
        keep = ob.local_physical["state"].get("choice") or "canteen"
        dec = {"choice": keep, "satisfaction": , "reason": "(作答未解析,维持上月选择)"}
        source = "fallback"
    actions.append(Action(
        agent_id=ob.agent_id, step=ob.step, kind="choose_meal",
        payload={"choice": dec["choice"], "satisfaction": dec["satisfaction"],
                 "reason": dec.get("reason", "")},
        source=source, rationale=[dec.get("reason") or ""]))

Note that even the parse-failure fallback writes a reason — the field is never silently empty.

This is a contract, not a nicety

The rationale is the only human-readable evidence of why B came out of f(P, E). A panel row without it records that agent 042 switched to delivery in month 3 and gives a reviewer no way to judge whether the switch was reasoning or noise. With it, the row is auditable: you can read the population's own account of the mechanism, spot personas that are role-playing the prompt instead of their profile, and quote real agent voices in the report. A rule-based decision may use a short templated rationale — but for an LLM run the field must never be empty.

2. Register and assemble

The @register(kind, key) decorator (kinds: environment, population, decision, collector, store, reporter) publishes your classes under a string key. Your artifacts then reference those keys:

  • environment.jsonprovider_ref: "yourstudy.env"
  • population.jsonprovider_ref: "yourstudy.pop"
  • simulation.jsondecision_ref / collector_ref

Make sure importing the study package triggers the decorators — the template's studies/opinion_diffusion/__init__.py simply imports model.

Assembly is generic from here (see the Quickstart for the full snippet):

sim = build_simulator(env_bundle=, pop_bundle=, sim_config=, store_path=)
history = sim.run()

Useful engine helpers along the way:

helper what it does
materialize_initial(env_bundle, pop_bundle, seed) instantiate P and E₀ without running — this is how the population stage writes roster.jsonl
build_providers(env_bundle, pop_bundle) resolve and instantiate just E and P
registry.available(kind) list everything registered for a kind

3. Author the artifacts

Fill the four artifacts (study.yaml, environment/environment.json, population/population.json, simulation/simulation.json) — every one is a Pydantic schema, and validate_handoff(path, Schema) fails loudly on malformed content. See Anatomy of a Study.

Don't skip the discovery fields in study.yaml (domain, tags, legacy_simulator: "from_scratch", provider_refs, adjustable_params, status, demonstrates): they are what makes your study routable, so the next question that matches gets a fork of yours instead of a rebuild.

4. Ground the numbers

Every load-bearing constant in your model — a threshold, a rate, an initial distribution — should cite a fact id or an assumption id from the study's grounding sidecar. Ground before you invent: see Grounding & Provenance.

5. Dry-run before you spend

Wire a deterministic decision path first (rule-based, or a deterministic LLM client) and run the full loop with a fixed seed. When the plumbing is proven, switch the decision model to the real LLM.

pytest tests -q          # the suite runs entirely without a key