import whileai.simulations as wai, then wai.name.
simulate
whileai/simulations/simulation.py.
Generate situations for an agent, roll them out, and return the rows.
Reach for it first: it is the run everything else reads. Give it the
agent and it writes a grid of human asks (ordinary, vague, complex,
adversarial), plays each one against the agent, and returns a
SimulationData: rows() (one row per rollout, with the prompt,
the tool calls, the final reply and its lineage), warnings
(plain-words notes, each naming the call that changes it),
report(), search (how the budget was spent), pass_at
once graded, and save(path). Nothing is graded unless you ask:
pass grade=True to grade against the rubric with the judge, the
same wai.Judge(rubric=...) that data.grade runs, so rows carry
reward, judge_status and judge_name (no key stops before
any budget is spent, nothing is substituted), grade="conduct" for the
deterministic conduct check by name (what the agent did, not whether
it did the job; rows carry label_source="conduct"), a callable
grader= to score inside the loop, or grade later with
data.grade(...) or grade().
The agent and the budget:
agent: a callablemessage -> trajectory, played single-turn (one message in, one trajectory out); or a backend object (wai.OpenAI("gpt-4.1-mini")) or spec string ("openai:gpt-4.1-mini"), which the SDK plays multi-turn fromtoolsandsystem_prompt. Leave itNoneto play the modelconfigure(agent=)set, else the model While hosts, fromtools,system_prompt(aliaspolicy) andbackend(its model);spec=is the third way in.budget: rows the run may produce, 1000 by default, a per-run cap whenrunsis above 1 (runs=3, budget=100returns up to 300 rows, andreport()["budget_per_run"]carries the cap).time_budgetis the same cap in seconds.mode:"explore"(default) spends the budget on new coverage."rl"gives every ask several repeats, so pass rates and RL groups exist, and defaultsrepeat_policyto"successive", which stops early on unanimous asks;"fixed"gives every ask all k repeats.situations(N, distinct worlds),phrasings(n, wordings of one world; aliasrequests_per_situation) andrepeats(k, independent runs of one wording; aliasrollouts_per_request) are three independent counts. Do not collapse them. Undermode="rl"repeatsis a floor, not a count: dynamic sampling (Yu et al. 2025 (DAPO), arXiv:2503.14476) re-rolls uneven groups, so some asks end with more than k rollouts andpass_atreports the smallest k. Follow-ups branch on the run.unique_situations=True(aliasunique) keeps picking new worlds (n=1, k=1 unless you set them). Withsituations=Nthe run stops once all N have their rollouts (stopped_because="situations_exhausted"), whateverbudgetstill allows; a budget abovesituations x phrasings x repeatsis not spent.concurrency(32 parallel rollouts),seed,sampling,temperature,timeout,logprobs,fault_rate,max_turns/avg_turns(model-backed agents only;avg_turns=1is one user line and one reply, the follow-up branch never runs): each isNoneunless you set it, and a misspelled keyword is aTypeError, never silently ignored. Writer completions areadvanced["completions_per_request"]; seed openers areadvanced["seed_prompts"].timeout: seconds one agent call may take. Unset, it is 300 s or the reply budget at 4 tokens a second, whichever is longer (max(300, agent_max_tokens / 4): 1,024 s atagent_max_tokens=4096), so a long reply is not re-rolled for taking the time it was allowed; a call that runs past it is an agent error and is re-rolled up torepeatstimes. Set it when you know the server’s rate:timeout >= agent_max_tokens / tokens-per-second-per-request.
simulator: the situation writer."hosted"is the default written out, the same as leaving it unset;Falseis the offline template writer, no key needed.user_modelplays the simulated person (a backend spec;Nonemeans the writer’s model, the agent’s own by default).seeds: opening asks the writer keeps and varies. Every seed is run and becomes at least one situation:situationsis sized up tolen(seeds)when you pass a smaller number, and the search never spends a seed’s slot on an ask it wrote itself. The one thing that can still drop a seed isbudget, which pays forlen(seeds) * repeatsrows before anything else; when it cannot,warningssays which seeds were dropped andsearch["seeds_dropped"]lists them before rolling out. With a callable agent whose world has real ids (order numbers, account names), put those ids in the seeds or the tool descriptions, or the writer invents ids and every rollout is “not found”. Seeds are asks to build a run around, not the eval set: to check that a fixed list of asks all ran and how each scored, useevaluate(eval_set=asks).traces: rows or a JSONL path of production traces. The grid then aims at the tools, faults and world states those traces show instead of the whole space (without it the grid comes from the agent’s tools and policy alone, a cold start), and any generated row that near-copies a source trace is dropped, so held-out traces stay out of training. Traces reproduce world-visible situations. A failure that lives in how a reply is worded (an unsupported claim, an estimate not labelled as one, two questions where one was asked for) has no world-visible trigger, so traces alone cannot aim at it; put a grader in the loop for those.hard_share: the difficulty dial, the share of situations drawn from the ambiguous, boundary and adversarial tiers, 0.40 by default, where a base fails most often.search["tier_mix"]reports the share asked for and the share drawn;dimensions={"stance": [...]}pins one axis and keeps the other axes of the grid.tasks: a previous run (aSimulationData, its rows, or its JSONL path) whose task set is replayed instead of drawing a new one. Every distinct prompt is rolled out again, on its ownscenario_idandscenario_dimensions, under the same faults and world state, and nothing else is generated. An unpinned re-run draws by seed and, aboveconcurrency: 1, by completion order, so it shares only part of its tasks with the first andcompare_runsdrops the rest; pinning is how an A/B (a prompt edit, a model swap, another seed) keeps every pair. k comes from this call’srepeatswhen given, otherwise from the pinned run (the most rollouts any of its prompts has), never from this call’smode, sopass_atreports the same k on both sides. The run stops when every pinned prompt has its rollouts (stopped_because="tasks_done") or the budget is spent.runs: replay the same task set that many times in one call and stamplineage.eval_run(0, 1, 2, …) on every row, which is whatdelta_reportneeds before it will call a change real (Lambert 2025, chapter Evaluation and its evaluation-variance appendix: one evaluation is a draw, three give a standard deviation).simulate(tasks=base, runs=3)is the usual form; withouttasks=the first run draws the task set (fromseeds=when given) and the rest replay it. Between runs nothing changes but the agent’s own sampling (same tasks, faults, world state and seed), so a deterministic agent gives identical runs and a zero re-run band. All rows come back in oneSimulationData(output=holds them all);search["eval_runs"]lists the rows and stop reason per run, andeval_variance(data.rows())splits byeval_runon its own. Replayed rows keep the writer of the run they replay onwriter_modeland saylineage.replayed_from_run, sodelta_reporton two runs of one call sees one writer.
- Progress goes to the
whileai.simulationslogger at INFO (and to stderr when nothing listens) every 10 finished rollouts or 10 s, re-rolls and losses counted as events too, so a run that only re-rolls still speaks:120/2404 rollouts, 601 situations written, 1h2m elapsed, ~19h left, 96 re-rolled, 3 lost (3 agent error).on_progress=is a callable that receives the same numbers as a dict on every line, whatever the budget:rows(indata.rows()so far, resumed ones included),cap,landed(this call),resumed,rerolledandrerolled_by(agent_error,empty_reply,tool_markup),timed_out(the agent errors that were call timeouts),lostandlost_by,inflight,situations,elapsed_s. The run’s final counts aresearch["rollouts"], andwarnings(plus aUserWarning) says so when more rollouts were re-rolled than landed, since the run then spent most of its time on calls that never became rows. checkpoint: a JSONL path every row is appended to the moment it lands, so a killed run keeps its rows. Call again with the samecheckpoint=andtasks=to resume: the rows on disk are loaded, a task with itsrepeatsrows is skipped, one with fewer gets only the missing rollouts, and the returnedSimulationDatais the union (search["rollouts"]["resumed"]counts the loaded rows;lineage.resumedmarks each). Withouttasks=the rows on disk are loaded and count towardbudget, and the run draws new situations for the rest.output=still writes the whole run at the end;checkpoint=is the file that survives a kill.
grader: a callable judge. Its verdict steers the search the way a tool fault already does: a row the grader failed is re-rolled and its ask is mutated into new ones, so the budget moves toward what the grader catches, not only toward broken tools. A grader that fails reply-form rules is exactly the signal traces cannot give (measured with a 12-rule grader: every rule with a tool-result trigger was reproduced and every rule about the reply’s wording was not), and without a grader there is no verdict to steer by, so there is no switch to set: the grader is the switch. A graded failure is a reward under 0.5 (a 0 from a 0/1 judge, a failed verifier, a rubric below half); markers ride along on the row but do not aim on their own, since their direction differs per marker.search["mutation_aims"]counts the parents and the mutated rows per aim,world_faultandgraded_failure. To grade beside the loop and still steer by tool faults alone, passadvanced={"mutate_graded_failures": False}.execute: your world, a function(tool, arguments) -> resultthat answers every tool call for real, against your repo, database or service. Without it the mock world answers, which fits record-shaped tools and not code. Scheduled faults still apply first.whileai.simulations.generate.agents.current_rolloutis a thread-local set before each rollout withprompt,rollout_indexandseed, soexecutecan tell which run it is answering.patience: how long the simulated person keeps answering the agent’s questions."normal"(the default) always tries to answer the first question, and from the second on may walk away (35% on the second, 60% on each after that, drawn per thread so a seeded run reproduces); at any question the person may also leave when it asks for something they could not or would not know."short"walks away sooner (60% then 90%);"endless"never walks away, so the person answers every question until the depth cap and no rubric criterion about asking can fail. The odds are a default, not a measurement: to ground them, fit a Kaplan-Meier hazard per question index on source traces and set the levels from it. A row the person left carriesended_by="user_left"and ends on the agent’s question;search["ended_on_question"]is\{"share", "n", "user_left"\}: ofnrows, the share that ended on a question and how many of those the person left.scaffold: generation-only guidance appended to the system prompt of the model-backed teacher during rollout (and to the scene writer). It never entersprofile.policy, so exports and evals stay on the plain policy; it is ignored for callable agents. Measured to help some agents and hurt others, so there is no default: configure it per agent.reproducible: the default,None, isTrueunlesstime_budgetis set.Truemakes a seeded run bit-for-bit at any concurrency, apart from timing fields and per-invocation identity (withgrader=every row’slineage.scoring_run_idnames that one scoring pass, a fresh id per call; passrun_id=torun_judgeto pin it): each batch of rollouts finishes, and every verdict of the batch lands, before the next is chosen, so every round sees the same state. A slow rollout holds its batch, so uneven latency costs throughput; passFalseto trade the same task set on every machine for that throughput. A clock turns it off on its own, since a clock stop lands wherever the run happens to be. It wasFalsebefore 0.111: three runs of one seed at the default concurrency then drew three different task sets (59, 58 and 58 tasks from onebudget=160), because which rows land before the cap depended on thread timing, and a lesson that wanted the same file on every machine had to pinconcurrency=1. Either way the draw is a function of the seed and the inputs alone, not of the interpreter: every float sum on the row-selection path is correctly rounded (math.fsum), so one seed picks the same rows on CPython 3.10 through 3.13, and a golden-value test pins that draw. Releases before this fix used the builtinsum, whose algorithm changed in CPython 3.12, and could swap one row per run between minor versions (issue #410).
sampling (temperature, max_tokens and
model as the model backend resolved them), because a result is only
comparable with its sampling settings on record (Lambert 2025, chapter
Evaluation); a callable agent samples however it samples, so its rows
carry sampling: None unless you pass sampling={...}, which is
recorded as given. Three models can take part, the agent (agent= or
backend=), the situation writer (simulator=) and the simulated
user (user_model=), and every row names all three next to
model_version: writer_model, user_model, and
judge_meta.model once graded. When the agent model also wrote the
situations or played the user, the run’s degraded list carries
same_model and warnings says which call separates them, since
training on a model’s own unfiltered output teaches it its own habits
(Lambert 2025, chapter Synthetic Data and Distillation).