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) — the whole step in, Action[] out


@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. One step in, one Action per agent out. decide_batch gets every observation of the step at once and returns one Action per agent. If it calls an LLM, it calls it once per agent, sending the step's calls concurrently on a bounded thread pool (max_workers in decision_args), and parses and falls back per agent. Never ask for one JSON reply covering the whole population: long structured replies get truncated, and one bad reply fails the whole step. See LLM Clients & Dry Runs.
  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:                               # one concurrent LLM call per agent
    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": …,
               # the filler reason follows the prompt's language (zh for the shipped prompt)
               "reason": _FALLBACK_REASON[_prompt_lang(build_student_prompt(ob))]}
        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.json → provider_ref: "yourstudy.env"
  • population.json → provider_ref: "yourstudy.pop"
  • simulation.json → decision_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 — the llm_kind: "scripted" mode of your decision model — and run the full loop with a fixed seed. When the plumbing is proven, switch decision_args to "llm_kind": "openai" for the real LLM.

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