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:
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:
- 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. - Batch the decision.
decide_batchgets every observation at once. If it calls an LLM, it makes batched calls — never a per-agent loop. - Two time channels, kept separate. Exogenous change (scheduled events,
broadcasts) belongs in
advance_to(t); endogenous feedback (the agents' own actions) belongs inapply(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.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):
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.