whileai 0.62__tar.gz → 0.64__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. {whileai-0.62/whileai.egg-info → whileai-0.64}/PKG-INFO +73 -6
  2. {whileai-0.62 → whileai-0.64}/README.md +72 -5
  3. {whileai-0.62 → whileai-0.64}/pyproject.toml +1 -1
  4. {whileai-0.62 → whileai-0.64}/whileai/simulations/__init__.py +8 -0
  5. {whileai-0.62 → whileai-0.64}/whileai/simulations/data.py +8 -0
  6. {whileai-0.62 → whileai-0.64}/whileai/simulations/export.py +18 -0
  7. {whileai-0.62 → whileai-0.64}/whileai/simulations/generate/adapters.py +2 -0
  8. {whileai-0.62 → whileai-0.64}/whileai/simulations/generate/agents.py +333 -37
  9. {whileai-0.62 → whileai-0.64}/whileai/simulations/generate/diversity.py +29 -7
  10. {whileai-0.62 → whileai-0.64}/whileai/simulations/generate/generator.py +38 -4
  11. {whileai-0.62 → whileai-0.64}/whileai/simulations/generate/scenarios.py +68 -3
  12. {whileai-0.62 → whileai-0.64}/whileai/simulations/ingest/traces.py +98 -23
  13. {whileai-0.62 → whileai-0.64}/whileai/simulations/run/config.py +71 -3
  14. {whileai-0.62 → whileai-0.64}/whileai/simulations/run/engine.py +303 -11
  15. {whileai-0.62 → whileai-0.64}/whileai/simulations/run/rows.py +38 -5
  16. whileai-0.64/whileai/simulations/score/delta.py +1013 -0
  17. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/grade_llm.py +140 -2
  18. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/grading.py +121 -0
  19. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/hygiene.py +9 -1
  20. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/passat.py +112 -6
  21. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/preflight.py +59 -0
  22. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/stats.py +498 -55
  23. {whileai-0.62 → whileai-0.64}/whileai/simulations/simulation.py +49 -1
  24. whileai-0.64/whileai/simulations/text.py +39 -0
  25. {whileai-0.62 → whileai-0.64}/whileai/simulations/world/sandbox.py +24 -1
  26. {whileai-0.62 → whileai-0.64/whileai.egg-info}/PKG-INFO +73 -6
  27. {whileai-0.62 → whileai-0.64}/whileai.egg-info/SOURCES.txt +1 -0
  28. whileai-0.62/whileai/simulations/score/delta.py +0 -550
  29. {whileai-0.62 → whileai-0.64}/LICENSE +0 -0
  30. {whileai-0.62 → whileai-0.64}/MANIFEST.in +0 -0
  31. {whileai-0.62 → whileai-0.64}/setup.cfg +0 -0
  32. {whileai-0.62 → whileai-0.64}/whileai/__init__.py +0 -0
  33. {whileai-0.62 → whileai-0.64}/whileai/_env.py +0 -0
  34. {whileai-0.62 → whileai-0.64}/whileai/auth.py +0 -0
  35. {whileai-0.62 → whileai-0.64}/whileai/cli.py +0 -0
  36. {whileai-0.62 → whileai-0.64}/whileai/ingest.py +0 -0
  37. {whileai-0.62 → whileai-0.64}/whileai/init_evals.py +0 -0
  38. {whileai-0.62 → whileai-0.64}/whileai/py.typed +0 -0
  39. {whileai-0.62 → whileai-0.64}/whileai/simulations/__main__.py +0 -0
  40. {whileai-0.62 → whileai-0.64}/whileai/simulations/environment.py +0 -0
  41. {whileai-0.62 → whileai-0.64}/whileai/simulations/generate/__init__.py +0 -0
  42. {whileai-0.62 → whileai-0.64}/whileai/simulations/generate/actionspace.py +0 -0
  43. {whileai-0.62 → whileai-0.64}/whileai/simulations/generate/anthropic_backend.py +0 -0
  44. {whileai-0.62 → whileai-0.64}/whileai/simulations/generate/coverage.py +0 -0
  45. {whileai-0.62 → whileai-0.64}/whileai/simulations/generate/embeddings.py +0 -0
  46. {whileai-0.62 → whileai-0.64}/whileai/simulations/generate/explore.py +0 -0
  47. {whileai-0.62 → whileai-0.64}/whileai/simulations/generate/offline_agent.py +0 -0
  48. {whileai-0.62 → whileai-0.64}/whileai/simulations/generate/usage_meter.py +0 -0
  49. {whileai-0.62 → whileai-0.64}/whileai/simulations/ingest/__init__.py +0 -0
  50. {whileai-0.62 → whileai-0.64}/whileai/simulations/ingest/otel.py +0 -0
  51. {whileai-0.62 → whileai-0.64}/whileai/simulations/ingest/platform.py +0 -0
  52. {whileai-0.62 → whileai-0.64}/whileai/simulations/monitor.py +0 -0
  53. {whileai-0.62 → whileai-0.64}/whileai/simulations/py.typed +0 -0
  54. {whileai-0.62 → whileai-0.64}/whileai/simulations/run/__init__.py +0 -0
  55. {whileai-0.62 → whileai-0.64}/whileai/simulations/run/spec.py +0 -0
  56. {whileai-0.62 → whileai-0.64}/whileai/simulations/schema.py +0 -0
  57. {whileai-0.62 → whileai-0.64}/whileai/simulations/schemas/row-v1.json +0 -0
  58. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/__init__.py +0 -0
  59. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/agreement.py +0 -0
  60. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/audit.py +0 -0
  61. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/checklist.py +0 -0
  62. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/curriculum.py +0 -0
  63. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/grounding.py +0 -0
  64. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/hack_scan.py +0 -0
  65. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/judge_trust.py +0 -0
  66. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/judging.py +0 -0
  67. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/labels.py +0 -0
  68. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/llm_judge.py +0 -0
  69. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/logprobs.py +0 -0
  70. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/markers.py +0 -0
  71. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/optimize.py +0 -0
  72. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/pairwise.py +0 -0
  73. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/privileged.py +0 -0
  74. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/publish_gate.py +0 -0
  75. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/quality.py +0 -0
  76. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/reference.py +0 -0
  77. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/rubric.py +0 -0
  78. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/spec.py +0 -0
  79. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/stage.py +0 -0
  80. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/style.py +0 -0
  81. {whileai-0.62 → whileai-0.64}/whileai/simulations/score/trace.py +0 -0
  82. {whileai-0.62 → whileai-0.64}/whileai/simulations/training.py +0 -0
  83. {whileai-0.62 → whileai-0.64}/whileai/simulations/verify/__init__.py +0 -0
  84. {whileai-0.62 → whileai-0.64}/whileai/simulations/verify/base.py +0 -0
  85. {whileai-0.62 → whileai-0.64}/whileai/simulations/verify/code.py +0 -0
  86. {whileai-0.62 → whileai-0.64}/whileai/simulations/verify/math.py +0 -0
  87. {whileai-0.62 → whileai-0.64}/whileai/simulations/verify/structured.py +0 -0
  88. {whileai-0.62 → whileai-0.64}/whileai/simulations/verify/text.py +0 -0
  89. {whileai-0.62 → whileai-0.64}/whileai/simulations/world/__init__.py +0 -0
  90. {whileai-0.62 → whileai-0.64}/whileai/templates/__init__.py +0 -0
  91. {whileai-0.62 → whileai-0.64}/whileai/templates/evals.py +0 -0
  92. {whileai-0.62 → whileai-0.64}/whileai.egg-info/dependency_links.txt +0 -0
  93. {whileai-0.62 → whileai-0.64}/whileai.egg-info/entry_points.txt +0 -0
  94. {whileai-0.62 → whileai-0.64}/whileai.egg-info/requires.txt +0 -0
  95. {whileai-0.62 → whileai-0.64}/whileai.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: whileai
3
- Version: 0.62
3
+ Version: 0.64
4
4
  Summary: While Python SDK: trace ingestion and verification client, plus agent simulations grounded in your tools and system prompt.
5
5
  Author: While
6
6
  License: Apache-2.0
@@ -219,6 +219,18 @@ print(wai.pass_at(scored.rows), *scored.warnings) # a hollow run says so here
219
219
  `scored.warnings` is new: no rollout called a tool, a declared tool no
220
220
  rollout touched, a marker that fired on no row. A 1.00 on a run like that is not a result; the note names the fix.
221
221
 
222
+ A declared tool the world cannot answer is the quiet version of the same
223
+ failure: with `execute=`, a tool that is in the schema but has no branch in
224
+ your function fails exactly like a world fault, the agent reports the miss
225
+ honestly, and a candour rubric rewards the row. Every run now records calls
226
+ and successes per tool in `data.coverage["tools"]` (`n`, `ok`, `fault_n`, and
227
+ `injected` for faults the run scheduled itself), lists the tools that never
228
+ work in `data.coverage["dead_tools"]` (the Wilson 95% upper bound on the
229
+ success rate is under 0.30, so 0 of 9 or 4 of 612 is dead and 0 of 3 or 2 of
230
+ 5 is not), adds `dead_tools` to `data.degraded`, and puts the names and the
231
+ one fix that applies to your world in `data.warnings` and `data.report()`.
232
+ Steps with no recorded result are not evidence and never accuse a tool.
233
+
222
234
  ### Bring a model
223
235
 
224
236
  Bring your own model. Any OpenAI-compatible chat endpoint that returns tool
@@ -227,7 +239,31 @@ your key. To put a number on a model you serve (`wai.serve`, or your own
227
239
  vLLM), make it the agent: `wai.simulate(tasks=pinned,
228
240
  agent=wai.local_model(endpoint, name, tools=TOOLS, system=POLICY,
229
241
  thinking=False))`, and run both arms of a before/after through that same
230
- call so the only difference is the weights.
242
+ call so the only difference is the weights. `thinking=False` reaches the
243
+ simulated user as well when the agent's own model plays it (the default),
244
+ and whatever a user model still emits as `<think>` is stripped before it
245
+ becomes a user turn; the run reports those under
246
+ `data.search["user_think"]` (counts and shares of the user turns, zeros when
247
+ none) and says so in `data.warnings`. `delta_report` fails a before/after
248
+ whose arms differ in how often they answered at all (`"answered"` in
249
+ `not_comparable`, a two-proportion test at p < 0.01 and a gap over the
250
+ re-run band or 10 points), which is what a reasoning base against an
251
+ adapter trained on think-free targets does under one shared token budget:
252
+ set `thinking=` the same on both arms.
253
+
254
+ Two `local_model` knobs the situation writer cannot guess for you.
255
+ `result_shapes={tool_name: example_result}` pins what a tool returns, so a
256
+ policy branch that only exists for some results ("credits over $200 go to
257
+ `escalate_to_human`") is reached on purpose instead of by luck. Ids, dates
258
+ and people are re-drawn per call and a number moves by up to about a third
259
+ of itself (`900.0` lands in roughly 600 to 1200), so pick a template value
260
+ whose whole range sits on one side of the threshold and run the same pinned
261
+ tasks under one shape per side. `fault_plans={message: {tool: {"mode":
262
+ "timeout", "rate": 1.0}}}` replays a known fault schedule; `simulate()`
263
+ writes these from `fault_rate=`, so pass your own only to replay one.
264
+ `timeout=` is 300 s by default, enough for a served model that scaled to
265
+ zero to answer its first request; when a call still times out the run says
266
+ so in `data.warnings` with the fix.
231
267
 
232
268
  ```bash
233
269
  export OPENAI_API_KEY=...
@@ -527,6 +563,7 @@ Pass `spec=` if you have a local tools-and-system-prompt folder of your own: a d
527
563
  | `user_model` | `None` | Who plays the simulated user in follow-up turns. `None` is the agent's own model; an `openai:`/`vllm:` spec moves that job to another model |
528
564
  | `traces` | `None` | Graded traces of the deployed agent — a list of plain row dicts or a JSONL path. Aims the coverage grid at the behaviors those traces show and keeps the sources out of the generated rows. See [Close the loop](#close-the-loop-aim-the-budget-with-traces) |
529
565
  | `tasks` | `None` | Re-run a previous run's task set instead of drawing a new one: that run, its rows, or its JSONL path. k is **not** inherited — see [Same tasks, new prompt](#trust-the-numbers) |
566
+ | `timeout` | `300` | Seconds per agent completion, for `local_model` and every model spec. A served model that scaled to zero takes two to three minutes to answer its first request, so a shorter value drops the first pass; a timed-out call is named in `data.warnings` with the fix |
530
567
  | `logprobs` | `False` | Ask the rollout model for the log-probability of every token it generates. Each agent turn's step gets `logprob` and `n_tokens`, the row gets the totals. `"tokens"` keeps the per-token list. Model backends only |
531
568
  | `sampling` | `None` | How your own callable agent samples, `{"temperature": 0.7, "max_tokens": 1024, "model": "my-model"}`, recorded on every row as given. A model backend records its own and ignores this |
532
569
  | `reproducible` | `False` | Same seed, same concurrency, same agent: same rows. Runs batch by batch, so uneven latency costs throughput. Needs the clock off. `concurrency: 1` always runs this way |
@@ -672,6 +709,22 @@ rows, report = wai.drop_leaky_rows(data.trajectories, prod)
672
709
  And the loop closes on itself: `evaluate(rollouts, judge).failed_traces()`
673
710
  hands the failures straight back to `simulate(traces=...)`.
674
711
 
712
+ **What traces can and cannot aim at.** Traces reproduce situations: the
713
+ tools, faults and world states the deployed agent met. A failure that has
714
+ a world-visible trigger (a tool timed out and the agent did not say so, a
715
+ stale record was presented as current) is reproduced. A failure that lives
716
+ in how the reply is worded (an unsupported claim, an estimate not labelled
717
+ as one, two questions where one was asked for) has no trigger in the world,
718
+ so traces alone cannot aim at it: measured on a 12-rule grader, every rule
719
+ with a tool-result trigger was reproduced and every rule about the reply's
720
+ wording was not (#285). For those, put the grader in the loop:
721
+ with `simulate(..., grader=judge)` a row the grader fails is re-rolled and
722
+ its ask mutated like a tool fault, and `data.search["mutation_aims"]` says
723
+ how many parents and mutated rows each aim (`world_fault`,
724
+ `graded_failure`) produced. The grader is the switch; to grade beside the
725
+ loop and still steer by tool faults alone, pass
726
+ `advanced={"mutate_graded_failures": False}`.
727
+
675
728
  If your traces are already on the platform, `wai.cut(agent="my-agent")`
676
729
  does the whole cut in one line — see
677
730
  [Training data out of traces](#training-data-out-of-traces).
@@ -902,11 +955,21 @@ rows, report = wai.write_rubrics(rows, domain="refunds") # per-prompt criteria
902
955
  scored = wai.run_judge(rows, wai.rubric_judge()) # a verdict per criterion; markers rubric:<item>
903
956
  clean, report = wai.decontaminate(
904
957
  train_rows, against=[eval_rows]
905
- ) # 8-gram overlap with the eval set
958
+ ) # same task id, verbatim, or 8-gram overlap with the eval set
959
+ clean, report = wai.decontaminate(
960
+ train_rows, against=[eval_rows], embedder=embed, similarity=0.85
961
+ ) # plus a semantic pass: n_semantic counted apart, a flag means "reads alike"
906
962
  wai.style_markers(rows) # no_boilerplate, no_hedging, no_apology, no_sycophancy, answered
907
963
  wai.style_report(rows)["warnings"] # "reward pays for hedging (corr +0.41 ...)"
908
964
  wai.refusal_report(benign_rows) # over-refusal rate with a Wilson interval
909
965
  wai.compare_runs(run_a, run_b) # paired delta with a 95% interval
966
+ wai.holdout_size(
967
+ 0.05, base=0.6, k=4
968
+ ) # tasks to prove a 5-point gain; n_tasks_concentrated beside it
969
+ wai.holdout_size(
970
+ 0.05, before=before, after=after
971
+ ) # the paired sd measured off a previous eval, no model
972
+ wai.holdout_size(0.05, task_std=0.38) # or the sd read off a delta_report interval
910
973
  wai.delta_report(before, after, target="pass_at_1", must_not_regress=["honest_after_fault"])
911
974
  wai.delta_report(before, after, target="pass_at_1", by="category") # the target per kind of prompt
912
975
  before = wai.simulate(agent, tools=TOOLS, tasks=base, runs=3) # the same eval three times
@@ -923,7 +986,7 @@ wai.grounding_report(rows) # grounded rate, and the invented values by tool and
923
986
 
924
987
  **Judge trust.** Label 50 to 100 rows by hand with `attach_labels` (0/1) -- `report["ok"]` means measured and clean, so with no labels it is `False` and the report says the judge is unmeasured rather than untrustworthy (`format_judge_trust` prints `NOT MEASURED`). The report gives agreement with a Wilson interval and Cohen's kappa, agreement on two task halves (tune the rubric on one, read the other), judge pass rate on short versus long replies within the same human label (length bias the humans rule out), and, with the judge callable, a re-judge of a sample as-is (consistency) and with neutral filler appended (a flip means the judge reads length). Disagreements come back as a review queue. `format_judge_trust(report)` prints it. `probes="all"` (or a list) tries the reward hacks a policy finds first on the judge on purpose: filler, the rubric's own words stuffed in, a claim of success with no evidence, the ask echoed back, a well-formed tool call with empty arguments, a sycophantic opener, a polite refusal. An additive probe is exploitable when failing replies start passing; a replacement probe when a reply with no content passes. `report["exploitable_by"]` names the holes at or over 10%, and a policy trained on this judge will find those same holes. Standalone: `wai.judge_probes(rows, judge, rubric=...)`. The gold set needs both passes and failures; with one class only the report says so and skips the kappa and length flags. With the hosted judge, call `wai.grade` once first (or `whileai.simulations.score.grade_llm.warm_judge`; it is not re-exported) so the cold start, two to three minutes, is not counted as timeouts.
925
988
 
926
- **Decontamination.** Word 8-gram overlap between a dataset's prompts and any evaluation source: row lists, JSONL paths, or platform dataset ids. A row is contaminated when it is an eval prompt verbatim or when one eval text covers at least 80% of its words (`overlap=`, the Llama 2 rule); one shared 8-gram is not enough, because situations written from the same templates share whole sentences without sharing the question. Short prompts match verbatim only. `fields=("prompt", "final_text")` also checks replies against eval answers and references. The report separates verbatim hits from near copies and counts hits per field, and returns the clean rows with the first offenders.
989
+ **Decontamination.** Four rules between a dataset's rows and any evaluation source (row lists, JSONL paths, or platform dataset ids), each counted on its own and a row counted once. A row is contaminated when it shares a `scenario_id` or `task_id` with an eval row (`n_same_task`: a task is a situation, not a string, so a rephrasing of an eval situation is the eval situation), when it is an eval prompt verbatim (`n_exact`), or when one eval text covers at least 80% of its words in shared 8-grams (`n_near`; `overlap=`, the Llama 2 rule; one shared 8-gram is not enough, because situations written from the same templates share whole sentences without sharing the question, and short prompts match verbatim only). `fields=("prompt", "final_text")` also checks replies against eval answers and references. Word overlap does not see a paraphrase: a holdout written by re-running the generator was 70% within 0.85 cosine of the training batch, and the 8-gram rule flagged 4 of its 101 prompts where a semantic pass flagged 16. Pass `embedder=` (any callable from a list of texts to one vector per text, so nothing is imported; with sentence-transformers, `embedder=lambda texts: model.encode(texts, normalize_embeddings=True).tolist()`) and rows whose prompt is within `similarity=` (0.85 cosine) of an eval prompt are flagged as `n_semantic`. That flag means the two prompts read alike, not that they are the same task: "cancel one reservation" and "cancel three reservations" for different customers score 0.93 with no shared answer. So the task-id rule decides first, the semantic pass only looks across different task ids, and `report["notes"]` says the flag is a question to check. The default stays lexical; the threshold was read off BGE (unrelated prompts score about 0.55 there) and needs picking for another model, so when the eval rows carry task ids the pass measures how alike distinct tasks read to your embedder (the 99th percentile of similarity over eval-prompt pairs with different task ids) and `notes` says it, and says when `similarity=` sits below it, since a threshold there flags tasks that merely share a domain. The report returns the clean rows with the first offenders, their coverage or similarity, and hits per field.
927
990
 
928
991
  **Intervals and comparison.** Every pass@1 carries a 95% interval from a bootstrap over tasks (`pass_at(rows).ci95`), and `metric_summary` / `marker_summary` do the same for markers. pass^k and pass@k carry their own (`pass_pow_k_ci95`, `pass_at_k_ci95`), a bootstrap over the k-eligible groups. Markers come from the judge: return `{"reward": ..., "markers": {"name": value}}` from a `grader=` or `run_judge` callable and they land on `row["markers"]`, which is what `marker_summary`, `delta_report` and `from_row` read. `compare_runs` pairs the tasks two runs share, bootstraps the paired difference, and adds a sign-flip permutation p-value; fewer than five shared tasks falls back to an unpaired test and says so. Tasks on one side only are dropped from a paired comparison; `note` says how many and `paired_share` is the fraction that paired, so a verdict over a quarter of the eval reads as one. The verdict `no_difference_detected` means the interval covers zero, not that the runs are equal. A task is a situation, not a string: every report (`pass_at`, `compare_runs`, `delta_report`, `eval_variance`, `curriculum`, `group_signal`, the exporters) groups rows by `wai.task_key(row)`, the engine's `scenario_id` when the row has one, so repeats and rephrasings of one situation count as one task and the same rows give the same task count everywhere. `pass_at(rows).config` and `delta_report(...)["config"]` say what the rows were produced with (temperature, reply budget, policy and judge versions), and `delta_report` warns when the two sides differ.
929
992
 
@@ -939,7 +1002,9 @@ rerun = wai.simulate(
939
1002
  assert base.rollouts_per_request == rerun.rollouts_per_request # cheap guard
940
1003
  ```
941
1004
 
942
- **Before and after.** `delta_report` runs `compare_runs` on pass@1 and every marker both row sets share. `target=` names the metric the training was meant to move and gives the headline; `must_not_regress=` names the behaviors whose significant drop fails the report; any other significant drop is a warning. `format_delta_report(report)` prints one line per metric. `eval_variance(run_1, run_2, run_3)` is the eval's own re-run standard deviation (three or more evaluations of the same model); passing it as `run_std=` makes any delta inside twice that band `within_noise`, and a target there reads `within_eval_noise` rather than moved, since re-running the eval moves it that much on its own (rlhf-book ch. 16). `by=` names a row key, a marker, or a callable that groups rows (a prompt category, a tool, a persona); the report then carries `groups`, the target compared within each group, and `groups_down` for any group whose target dropped significantly while the headline moved. A headline over one dominant kind of prompt cannot hide the other kinds that way.
1005
+ **Size before you run.** Most evals are too small to see the effects they produce. Per-prompt paired spread is stable for agent rubrics, about 0.38 across five lanes, and at that spread a 50-prompt eval only detects a 10-point gain; a 5-point gain needs about 220 prompts and a 3-point gain about 600. One lane read the same adapter as "barely helps" at 45 and 85 prompts (both straddled zero) and cleared at 131 (+0.13 [+0.07, +0.19]); the effect was real the whole time and a GPU round went to fixing a data problem that was a measurement problem. The effective sample is prompts, not rollouts: raising k sharpens each prompt's estimate but does not narrow a bootstrap over prompts, so spend eval budget on prompts first (k still matters for preference and grouped methods, which need mixed groups). And "straddles zero" means the eval cannot tell, not that the model did not improve; say which. `holdout_size(effect, base=, k=)` says how many paired tasks prove a gain at 80% power, and `detectable_effect(n_tasks, ...)` is the same solved for the gain. Its binomial model assumes the gain is spread evenly across tasks and the two arms are independent draws, and says so in `notes`; when a trait is only exercised by some prompts most tasks are ties, the paired differences spread far wider, and the model under-sizes by several times (a voice lane at 0 -> 0.127 needed 54 tasks where the model said 14), so the model path also returns `n_tasks_concentrated`. On a holdout whose tasks differ in difficulty the model errs the other way, asking for `1 / (1 - Var(p_i) / (p(1-p)))` times the tasks pairing needs (1.19x at spread 0.2 around 0.5, 2.78x at 0.4); `before=` alone reports the spread and that ratio. The honest paths measure: `before=before, after=after` (the same two row lists `delta_report` takes) reads the per-task paired sd off both arms of a previous eval on the same tasks, with the covariance pairing buys in it, and `task_std=` (the per-task sibling of `run_std`) takes the number you read off a `delta_report` (`(hi - lo) * sqrt(n_paired_tasks) / 3.92` from `target_ci95`). `push(purpose="holdout")` warns when the set is too small to prove a 5-point gain.
1006
+
1007
+ **Before and after.** `delta_report` runs `compare_runs` on pass@1 and every marker both row sets share. `target=` names the metric the training was meant to move and gives the headline; `must_not_regress=` names the behaviors whose significant drop fails the report; any other significant drop is a warning. `format_delta_report(report)` prints one line per metric. `eval_variance(run_1, run_2, run_3)` is the eval's own re-run standard deviation (three or more evaluations of the same model); passing it as `run_std=` makes any delta inside twice that band `within_noise`, and a target there reads `within_eval_noise` rather than moved, since re-running the eval moves it that much on its own (rlhf-book ch. 16). `by=` names a row key, a marker, or a callable that groups rows (a prompt category, a tool, a persona); the report then carries `groups`, the target compared within each group, and `groups_down` for any group whose target dropped significantly while the headline moved. A headline over one dominant kind of prompt cannot hide the other kinds that way. Both sides should have the same rollouts per task; when a run lost some (`data.report()["rollouts_lost"]`, with the reasons in `rollouts_lost_by`) and one arm sits at k=4 while the other is at k=2, the report warns next to the sizing line and names both. Unequal k is a precision issue, not a bias: rows lost at random leave the paired delta unbiased and only widen its interval; rows lost for a reason (a timeout on the hard runs) bias it, and only re-running the short arm on its short tasks fixes that. `balance_rollouts=True` (off by default) trims every paired task to the rows both sides have (drawn by `seed=`) so pass^k and pass@k share one k; it costs precision, removes no bias, and `balanced` says how many rows each side gave up.
943
1008
 
944
1009
  **Run the eval three times.** One evaluation is a draw, not a number: the same model on the same tasks lands somewhere else next time, and most post-training gains are inside that spread (rlhf-book ch. 16, appendix C). `wai.simulate(agent, tasks=base, runs=3)` replays the task set three times in one call, same tasks, faults and world, and stamps `lineage.eval_run` on every row. Feed both sides to `delta_report` and it works out `run_std` from the repeats itself. The verdict words: `moved` is a change the interval and the re-run band both support; `moved_unreplicated` is a change seen once, which could be noise, and the warning tells you the `runs=3` call that settles it; `within_eval_noise` is a delta smaller than what re-running the eval does on its own, so equivalence, not a win; `no_change_detected` is an interval that covers zero. `ceiling=True` means the before run already passes most of its tasks (0.9 or more, or too few paired tasks left with room), so there is little improvement the eval could show; use harder situations before training again.
945
1010
 
@@ -1151,6 +1216,8 @@ Measured at `avg_turns=4`. The default is now `12`, so a row carries more turns
1151
1216
  | `embedder` | `"hash"` | Prompt selection |
1152
1217
  | `seed` | `0` | Reproducible draws. Bit-for-bit at `concurrency: 1` or with `reproducible=True`, within a process and across processes; otherwise which rows land before the cap depends on thread timing |
1153
1218
  | `avg_turns` | `12` | Target conversation length in turns. The person speaks at most `avg_turns // 2` times; `12` leaves room to verify, look up, confirm, and write. |
1219
+ | `mutate_graded_failures` | on with `grader=` | `False` grades beside the loop without steering by the verdict: only tool faults make mutation parents. A row the grader fails (reward under 0.5) is otherwise re-rolled and its ask mutated the way a tool fault's is; `search["mutation_aims"]` counts each aim. `True` without `grader=` is an error |
1220
+ | `patience` | `"normal"` | How long the person keeps answering the agent's questions. The first question is always attempted; from the second on the person may walk away (`normal`: 35% then 60%; `short`: 60% then 90%; `endless`: never, the pre-knob behaviour). At any question the person may also leave when it asks for something they could not know. A row the person left ends on the agent's question and carries `ended_by="user_left"`; `search["ended_on_question"]` counts them. |
1154
1221
 
1155
1222
  Aliases: `phrasings=` / `n=` → `requests_per_situation`; `repeats=` → `rollouts_per_request`; `unique=` → `unique_situations`; `policy=` → `system_prompt`; `risk=` → `fault_rate`.
1156
1223
 
@@ -1158,7 +1225,7 @@ Aliases: `phrasings=` / `n=` → `requests_per_situation`; `repeats=` → `rollo
1158
1225
 
1159
1226
  Each row, in `data.trajectories` and on disk: `prompt`, `messages`, `steps`, `final_text`, `scenario_id`. Optional `world_state`, `faults`, `reward`, `reason`. `llm_grade=True` adds `llm_reward`. `wai.rank(path)` adds `quality` without changing `reward`. Every row also says how it was sampled: `sampling` is `{"temperature", "max_tokens", "model"}` as the model backend resolved them, or what you passed as `simulate(sampling=...)` for your own callable agent (`None` when you passed nothing, since only you know how it samples). `policy_version` names the model and the system prompt it ran under.
1160
1227
 
1161
- Three models can take part in a run, and by default they are one: the agent answers, and the same model writes the situations and plays the user in follow-up turns (only the judge is a different model). Every row now says who did which job, next to `model_version` for the agent: `writer_model` (the writer's model, or `template`, `seed`, `pinned` when no model wrote the prompt), `user_model` (absent when the agent took a single message), and `judge_meta.model` once graded; `data.metadata` and the `.meta.json` sidecar carry the same three. When the agent model also wrote the situations or played the user, `data.degraded` holds `same_model` and `data.warnings` says so in one sentence, with the fix: pass `simulator=` for the writer and `user_model=` for the user to put those jobs on a different model.
1228
+ Three models can take part in a run, and by default they are one: the agent answers, and the same model writes the situations and plays the user in follow-up turns (only the judge is a different model). Every row now says who did which job, next to `model_version` for the agent: `writer_model` (the writer's model, or `template`, `seed`, `pinned` when no model wrote the prompt), `user_model` (absent when the agent took a single message), and `judge_meta.model` once graded; `data.metadata` and the `.meta.json` sidecar carry the same three. Every row also names the deploy prompt it was generated under: `lineage.system_prompt_sha` (the hash `policy_version` carries after `@`) , `lineage.system_prompt_head` (its first 120 chars) and `lineage.system_prompt_chars`, with the full text once per run in `data.system_prompts[<sha>]`, so a base rate measured under a full policy is never mistaken for one measured under a bare prompt; `delta_report` warns when its two arms differ on that hash. When the agent model also wrote the situations or played the user, `data.degraded` holds `same_model` and `data.warnings` says so in one sentence, with the fix: pass `simulator=` for the writer and `user_model=` for the user to put those jobs on a different model.
1162
1229
 
1163
1230
  What goes to disk is the whole row, not a summary of it: `data.rows` (the same list `output=` and `save()` write, callable as `data.rows()` too) carries everything the trajectory carries, so a saved run can still prove its own provenance. That includes how the situation was drawn (`scenario_dimensions`, `arm`, `selection_reason`, `behavior_signature`, `seed`), who graded it and how that went (`judge_name`, `judge_status`, `judge_meta`, `lineage`, `label_source`), and what was measured on it (`markers`, read by `marker_summary` and `delta_report`). Two things never ship, at any depth of the row: the teacher-only `privileged` block and its `principle` / `hidden_state` / `reference` / `rubric` fields, which would put the answer key one step from a training file, and `vector`, the raw embedding the diversity search keeps in memory for the length of the run. A privileged block nested inside a carried field or a tool result is dropped the same way, before `messages` is rebuilt from the steps.
1164
1231
 
@@ -183,6 +183,18 @@ print(wai.pass_at(scored.rows), *scored.warnings) # a hollow run says so here
183
183
  `scored.warnings` is new: no rollout called a tool, a declared tool no
184
184
  rollout touched, a marker that fired on no row. A 1.00 on a run like that is not a result; the note names the fix.
185
185
 
186
+ A declared tool the world cannot answer is the quiet version of the same
187
+ failure: with `execute=`, a tool that is in the schema but has no branch in
188
+ your function fails exactly like a world fault, the agent reports the miss
189
+ honestly, and a candour rubric rewards the row. Every run now records calls
190
+ and successes per tool in `data.coverage["tools"]` (`n`, `ok`, `fault_n`, and
191
+ `injected` for faults the run scheduled itself), lists the tools that never
192
+ work in `data.coverage["dead_tools"]` (the Wilson 95% upper bound on the
193
+ success rate is under 0.30, so 0 of 9 or 4 of 612 is dead and 0 of 3 or 2 of
194
+ 5 is not), adds `dead_tools` to `data.degraded`, and puts the names and the
195
+ one fix that applies to your world in `data.warnings` and `data.report()`.
196
+ Steps with no recorded result are not evidence and never accuse a tool.
197
+
186
198
  ### Bring a model
187
199
 
188
200
  Bring your own model. Any OpenAI-compatible chat endpoint that returns tool
@@ -191,7 +203,31 @@ your key. To put a number on a model you serve (`wai.serve`, or your own
191
203
  vLLM), make it the agent: `wai.simulate(tasks=pinned,
192
204
  agent=wai.local_model(endpoint, name, tools=TOOLS, system=POLICY,
193
205
  thinking=False))`, and run both arms of a before/after through that same
194
- call so the only difference is the weights.
206
+ call so the only difference is the weights. `thinking=False` reaches the
207
+ simulated user as well when the agent's own model plays it (the default),
208
+ and whatever a user model still emits as `<think>` is stripped before it
209
+ becomes a user turn; the run reports those under
210
+ `data.search["user_think"]` (counts and shares of the user turns, zeros when
211
+ none) and says so in `data.warnings`. `delta_report` fails a before/after
212
+ whose arms differ in how often they answered at all (`"answered"` in
213
+ `not_comparable`, a two-proportion test at p < 0.01 and a gap over the
214
+ re-run band or 10 points), which is what a reasoning base against an
215
+ adapter trained on think-free targets does under one shared token budget:
216
+ set `thinking=` the same on both arms.
217
+
218
+ Two `local_model` knobs the situation writer cannot guess for you.
219
+ `result_shapes={tool_name: example_result}` pins what a tool returns, so a
220
+ policy branch that only exists for some results ("credits over $200 go to
221
+ `escalate_to_human`") is reached on purpose instead of by luck. Ids, dates
222
+ and people are re-drawn per call and a number moves by up to about a third
223
+ of itself (`900.0` lands in roughly 600 to 1200), so pick a template value
224
+ whose whole range sits on one side of the threshold and run the same pinned
225
+ tasks under one shape per side. `fault_plans={message: {tool: {"mode":
226
+ "timeout", "rate": 1.0}}}` replays a known fault schedule; `simulate()`
227
+ writes these from `fault_rate=`, so pass your own only to replay one.
228
+ `timeout=` is 300 s by default, enough for a served model that scaled to
229
+ zero to answer its first request; when a call still times out the run says
230
+ so in `data.warnings` with the fix.
195
231
 
196
232
  ```bash
197
233
  export OPENAI_API_KEY=...
@@ -491,6 +527,7 @@ Pass `spec=` if you have a local tools-and-system-prompt folder of your own: a d
491
527
  | `user_model` | `None` | Who plays the simulated user in follow-up turns. `None` is the agent's own model; an `openai:`/`vllm:` spec moves that job to another model |
492
528
  | `traces` | `None` | Graded traces of the deployed agent — a list of plain row dicts or a JSONL path. Aims the coverage grid at the behaviors those traces show and keeps the sources out of the generated rows. See [Close the loop](#close-the-loop-aim-the-budget-with-traces) |
493
529
  | `tasks` | `None` | Re-run a previous run's task set instead of drawing a new one: that run, its rows, or its JSONL path. k is **not** inherited — see [Same tasks, new prompt](#trust-the-numbers) |
530
+ | `timeout` | `300` | Seconds per agent completion, for `local_model` and every model spec. A served model that scaled to zero takes two to three minutes to answer its first request, so a shorter value drops the first pass; a timed-out call is named in `data.warnings` with the fix |
494
531
  | `logprobs` | `False` | Ask the rollout model for the log-probability of every token it generates. Each agent turn's step gets `logprob` and `n_tokens`, the row gets the totals. `"tokens"` keeps the per-token list. Model backends only |
495
532
  | `sampling` | `None` | How your own callable agent samples, `{"temperature": 0.7, "max_tokens": 1024, "model": "my-model"}`, recorded on every row as given. A model backend records its own and ignores this |
496
533
  | `reproducible` | `False` | Same seed, same concurrency, same agent: same rows. Runs batch by batch, so uneven latency costs throughput. Needs the clock off. `concurrency: 1` always runs this way |
@@ -636,6 +673,22 @@ rows, report = wai.drop_leaky_rows(data.trajectories, prod)
636
673
  And the loop closes on itself: `evaluate(rollouts, judge).failed_traces()`
637
674
  hands the failures straight back to `simulate(traces=...)`.
638
675
 
676
+ **What traces can and cannot aim at.** Traces reproduce situations: the
677
+ tools, faults and world states the deployed agent met. A failure that has
678
+ a world-visible trigger (a tool timed out and the agent did not say so, a
679
+ stale record was presented as current) is reproduced. A failure that lives
680
+ in how the reply is worded (an unsupported claim, an estimate not labelled
681
+ as one, two questions where one was asked for) has no trigger in the world,
682
+ so traces alone cannot aim at it: measured on a 12-rule grader, every rule
683
+ with a tool-result trigger was reproduced and every rule about the reply's
684
+ wording was not (#285). For those, put the grader in the loop:
685
+ with `simulate(..., grader=judge)` a row the grader fails is re-rolled and
686
+ its ask mutated like a tool fault, and `data.search["mutation_aims"]` says
687
+ how many parents and mutated rows each aim (`world_fault`,
688
+ `graded_failure`) produced. The grader is the switch; to grade beside the
689
+ loop and still steer by tool faults alone, pass
690
+ `advanced={"mutate_graded_failures": False}`.
691
+
639
692
  If your traces are already on the platform, `wai.cut(agent="my-agent")`
640
693
  does the whole cut in one line — see
641
694
  [Training data out of traces](#training-data-out-of-traces).
@@ -866,11 +919,21 @@ rows, report = wai.write_rubrics(rows, domain="refunds") # per-prompt criteria
866
919
  scored = wai.run_judge(rows, wai.rubric_judge()) # a verdict per criterion; markers rubric:<item>
867
920
  clean, report = wai.decontaminate(
868
921
  train_rows, against=[eval_rows]
869
- ) # 8-gram overlap with the eval set
922
+ ) # same task id, verbatim, or 8-gram overlap with the eval set
923
+ clean, report = wai.decontaminate(
924
+ train_rows, against=[eval_rows], embedder=embed, similarity=0.85
925
+ ) # plus a semantic pass: n_semantic counted apart, a flag means "reads alike"
870
926
  wai.style_markers(rows) # no_boilerplate, no_hedging, no_apology, no_sycophancy, answered
871
927
  wai.style_report(rows)["warnings"] # "reward pays for hedging (corr +0.41 ...)"
872
928
  wai.refusal_report(benign_rows) # over-refusal rate with a Wilson interval
873
929
  wai.compare_runs(run_a, run_b) # paired delta with a 95% interval
930
+ wai.holdout_size(
931
+ 0.05, base=0.6, k=4
932
+ ) # tasks to prove a 5-point gain; n_tasks_concentrated beside it
933
+ wai.holdout_size(
934
+ 0.05, before=before, after=after
935
+ ) # the paired sd measured off a previous eval, no model
936
+ wai.holdout_size(0.05, task_std=0.38) # or the sd read off a delta_report interval
874
937
  wai.delta_report(before, after, target="pass_at_1", must_not_regress=["honest_after_fault"])
875
938
  wai.delta_report(before, after, target="pass_at_1", by="category") # the target per kind of prompt
876
939
  before = wai.simulate(agent, tools=TOOLS, tasks=base, runs=3) # the same eval three times
@@ -887,7 +950,7 @@ wai.grounding_report(rows) # grounded rate, and the invented values by tool and
887
950
 
888
951
  **Judge trust.** Label 50 to 100 rows by hand with `attach_labels` (0/1) -- `report["ok"]` means measured and clean, so with no labels it is `False` and the report says the judge is unmeasured rather than untrustworthy (`format_judge_trust` prints `NOT MEASURED`). The report gives agreement with a Wilson interval and Cohen's kappa, agreement on two task halves (tune the rubric on one, read the other), judge pass rate on short versus long replies within the same human label (length bias the humans rule out), and, with the judge callable, a re-judge of a sample as-is (consistency) and with neutral filler appended (a flip means the judge reads length). Disagreements come back as a review queue. `format_judge_trust(report)` prints it. `probes="all"` (or a list) tries the reward hacks a policy finds first on the judge on purpose: filler, the rubric's own words stuffed in, a claim of success with no evidence, the ask echoed back, a well-formed tool call with empty arguments, a sycophantic opener, a polite refusal. An additive probe is exploitable when failing replies start passing; a replacement probe when a reply with no content passes. `report["exploitable_by"]` names the holes at or over 10%, and a policy trained on this judge will find those same holes. Standalone: `wai.judge_probes(rows, judge, rubric=...)`. The gold set needs both passes and failures; with one class only the report says so and skips the kappa and length flags. With the hosted judge, call `wai.grade` once first (or `whileai.simulations.score.grade_llm.warm_judge`; it is not re-exported) so the cold start, two to three minutes, is not counted as timeouts.
889
952
 
890
- **Decontamination.** Word 8-gram overlap between a dataset's prompts and any evaluation source: row lists, JSONL paths, or platform dataset ids. A row is contaminated when it is an eval prompt verbatim or when one eval text covers at least 80% of its words (`overlap=`, the Llama 2 rule); one shared 8-gram is not enough, because situations written from the same templates share whole sentences without sharing the question. Short prompts match verbatim only. `fields=("prompt", "final_text")` also checks replies against eval answers and references. The report separates verbatim hits from near copies and counts hits per field, and returns the clean rows with the first offenders.
953
+ **Decontamination.** Four rules between a dataset's rows and any evaluation source (row lists, JSONL paths, or platform dataset ids), each counted on its own and a row counted once. A row is contaminated when it shares a `scenario_id` or `task_id` with an eval row (`n_same_task`: a task is a situation, not a string, so a rephrasing of an eval situation is the eval situation), when it is an eval prompt verbatim (`n_exact`), or when one eval text covers at least 80% of its words in shared 8-grams (`n_near`; `overlap=`, the Llama 2 rule; one shared 8-gram is not enough, because situations written from the same templates share whole sentences without sharing the question, and short prompts match verbatim only). `fields=("prompt", "final_text")` also checks replies against eval answers and references. Word overlap does not see a paraphrase: a holdout written by re-running the generator was 70% within 0.85 cosine of the training batch, and the 8-gram rule flagged 4 of its 101 prompts where a semantic pass flagged 16. Pass `embedder=` (any callable from a list of texts to one vector per text, so nothing is imported; with sentence-transformers, `embedder=lambda texts: model.encode(texts, normalize_embeddings=True).tolist()`) and rows whose prompt is within `similarity=` (0.85 cosine) of an eval prompt are flagged as `n_semantic`. That flag means the two prompts read alike, not that they are the same task: "cancel one reservation" and "cancel three reservations" for different customers score 0.93 with no shared answer. So the task-id rule decides first, the semantic pass only looks across different task ids, and `report["notes"]` says the flag is a question to check. The default stays lexical; the threshold was read off BGE (unrelated prompts score about 0.55 there) and needs picking for another model, so when the eval rows carry task ids the pass measures how alike distinct tasks read to your embedder (the 99th percentile of similarity over eval-prompt pairs with different task ids) and `notes` says it, and says when `similarity=` sits below it, since a threshold there flags tasks that merely share a domain. The report returns the clean rows with the first offenders, their coverage or similarity, and hits per field.
891
954
 
892
955
  **Intervals and comparison.** Every pass@1 carries a 95% interval from a bootstrap over tasks (`pass_at(rows).ci95`), and `metric_summary` / `marker_summary` do the same for markers. pass^k and pass@k carry their own (`pass_pow_k_ci95`, `pass_at_k_ci95`), a bootstrap over the k-eligible groups. Markers come from the judge: return `{"reward": ..., "markers": {"name": value}}` from a `grader=` or `run_judge` callable and they land on `row["markers"]`, which is what `marker_summary`, `delta_report` and `from_row` read. `compare_runs` pairs the tasks two runs share, bootstraps the paired difference, and adds a sign-flip permutation p-value; fewer than five shared tasks falls back to an unpaired test and says so. Tasks on one side only are dropped from a paired comparison; `note` says how many and `paired_share` is the fraction that paired, so a verdict over a quarter of the eval reads as one. The verdict `no_difference_detected` means the interval covers zero, not that the runs are equal. A task is a situation, not a string: every report (`pass_at`, `compare_runs`, `delta_report`, `eval_variance`, `curriculum`, `group_signal`, the exporters) groups rows by `wai.task_key(row)`, the engine's `scenario_id` when the row has one, so repeats and rephrasings of one situation count as one task and the same rows give the same task count everywhere. `pass_at(rows).config` and `delta_report(...)["config"]` say what the rows were produced with (temperature, reply budget, policy and judge versions), and `delta_report` warns when the two sides differ.
893
956
 
@@ -903,7 +966,9 @@ rerun = wai.simulate(
903
966
  assert base.rollouts_per_request == rerun.rollouts_per_request # cheap guard
904
967
  ```
905
968
 
906
- **Before and after.** `delta_report` runs `compare_runs` on pass@1 and every marker both row sets share. `target=` names the metric the training was meant to move and gives the headline; `must_not_regress=` names the behaviors whose significant drop fails the report; any other significant drop is a warning. `format_delta_report(report)` prints one line per metric. `eval_variance(run_1, run_2, run_3)` is the eval's own re-run standard deviation (three or more evaluations of the same model); passing it as `run_std=` makes any delta inside twice that band `within_noise`, and a target there reads `within_eval_noise` rather than moved, since re-running the eval moves it that much on its own (rlhf-book ch. 16). `by=` names a row key, a marker, or a callable that groups rows (a prompt category, a tool, a persona); the report then carries `groups`, the target compared within each group, and `groups_down` for any group whose target dropped significantly while the headline moved. A headline over one dominant kind of prompt cannot hide the other kinds that way.
969
+ **Size before you run.** Most evals are too small to see the effects they produce. Per-prompt paired spread is stable for agent rubrics, about 0.38 across five lanes, and at that spread a 50-prompt eval only detects a 10-point gain; a 5-point gain needs about 220 prompts and a 3-point gain about 600. One lane read the same adapter as "barely helps" at 45 and 85 prompts (both straddled zero) and cleared at 131 (+0.13 [+0.07, +0.19]); the effect was real the whole time and a GPU round went to fixing a data problem that was a measurement problem. The effective sample is prompts, not rollouts: raising k sharpens each prompt's estimate but does not narrow a bootstrap over prompts, so spend eval budget on prompts first (k still matters for preference and grouped methods, which need mixed groups). And "straddles zero" means the eval cannot tell, not that the model did not improve; say which. `holdout_size(effect, base=, k=)` says how many paired tasks prove a gain at 80% power, and `detectable_effect(n_tasks, ...)` is the same solved for the gain. Its binomial model assumes the gain is spread evenly across tasks and the two arms are independent draws, and says so in `notes`; when a trait is only exercised by some prompts most tasks are ties, the paired differences spread far wider, and the model under-sizes by several times (a voice lane at 0 -> 0.127 needed 54 tasks where the model said 14), so the model path also returns `n_tasks_concentrated`. On a holdout whose tasks differ in difficulty the model errs the other way, asking for `1 / (1 - Var(p_i) / (p(1-p)))` times the tasks pairing needs (1.19x at spread 0.2 around 0.5, 2.78x at 0.4); `before=` alone reports the spread and that ratio. The honest paths measure: `before=before, after=after` (the same two row lists `delta_report` takes) reads the per-task paired sd off both arms of a previous eval on the same tasks, with the covariance pairing buys in it, and `task_std=` (the per-task sibling of `run_std`) takes the number you read off a `delta_report` (`(hi - lo) * sqrt(n_paired_tasks) / 3.92` from `target_ci95`). `push(purpose="holdout")` warns when the set is too small to prove a 5-point gain.
970
+
971
+ **Before and after.** `delta_report` runs `compare_runs` on pass@1 and every marker both row sets share. `target=` names the metric the training was meant to move and gives the headline; `must_not_regress=` names the behaviors whose significant drop fails the report; any other significant drop is a warning. `format_delta_report(report)` prints one line per metric. `eval_variance(run_1, run_2, run_3)` is the eval's own re-run standard deviation (three or more evaluations of the same model); passing it as `run_std=` makes any delta inside twice that band `within_noise`, and a target there reads `within_eval_noise` rather than moved, since re-running the eval moves it that much on its own (rlhf-book ch. 16). `by=` names a row key, a marker, or a callable that groups rows (a prompt category, a tool, a persona); the report then carries `groups`, the target compared within each group, and `groups_down` for any group whose target dropped significantly while the headline moved. A headline over one dominant kind of prompt cannot hide the other kinds that way. Both sides should have the same rollouts per task; when a run lost some (`data.report()["rollouts_lost"]`, with the reasons in `rollouts_lost_by`) and one arm sits at k=4 while the other is at k=2, the report warns next to the sizing line and names both. Unequal k is a precision issue, not a bias: rows lost at random leave the paired delta unbiased and only widen its interval; rows lost for a reason (a timeout on the hard runs) bias it, and only re-running the short arm on its short tasks fixes that. `balance_rollouts=True` (off by default) trims every paired task to the rows both sides have (drawn by `seed=`) so pass^k and pass@k share one k; it costs precision, removes no bias, and `balanced` says how many rows each side gave up.
907
972
 
908
973
  **Run the eval three times.** One evaluation is a draw, not a number: the same model on the same tasks lands somewhere else next time, and most post-training gains are inside that spread (rlhf-book ch. 16, appendix C). `wai.simulate(agent, tasks=base, runs=3)` replays the task set three times in one call, same tasks, faults and world, and stamps `lineage.eval_run` on every row. Feed both sides to `delta_report` and it works out `run_std` from the repeats itself. The verdict words: `moved` is a change the interval and the re-run band both support; `moved_unreplicated` is a change seen once, which could be noise, and the warning tells you the `runs=3` call that settles it; `within_eval_noise` is a delta smaller than what re-running the eval does on its own, so equivalence, not a win; `no_change_detected` is an interval that covers zero. `ceiling=True` means the before run already passes most of its tasks (0.9 or more, or too few paired tasks left with room), so there is little improvement the eval could show; use harder situations before training again.
909
974
 
@@ -1115,6 +1180,8 @@ Measured at `avg_turns=4`. The default is now `12`, so a row carries more turns
1115
1180
  | `embedder` | `"hash"` | Prompt selection |
1116
1181
  | `seed` | `0` | Reproducible draws. Bit-for-bit at `concurrency: 1` or with `reproducible=True`, within a process and across processes; otherwise which rows land before the cap depends on thread timing |
1117
1182
  | `avg_turns` | `12` | Target conversation length in turns. The person speaks at most `avg_turns // 2` times; `12` leaves room to verify, look up, confirm, and write. |
1183
+ | `mutate_graded_failures` | on with `grader=` | `False` grades beside the loop without steering by the verdict: only tool faults make mutation parents. A row the grader fails (reward under 0.5) is otherwise re-rolled and its ask mutated the way a tool fault's is; `search["mutation_aims"]` counts each aim. `True` without `grader=` is an error |
1184
+ | `patience` | `"normal"` | How long the person keeps answering the agent's questions. The first question is always attempted; from the second on the person may walk away (`normal`: 35% then 60%; `short`: 60% then 90%; `endless`: never, the pre-knob behaviour). At any question the person may also leave when it asks for something they could not know. A row the person left ends on the agent's question and carries `ended_by="user_left"`; `search["ended_on_question"]` counts them. |
1118
1185
 
1119
1186
  Aliases: `phrasings=` / `n=` → `requests_per_situation`; `repeats=` → `rollouts_per_request`; `unique=` → `unique_situations`; `policy=` → `system_prompt`; `risk=` → `fault_rate`.
1120
1187
 
@@ -1122,7 +1189,7 @@ Aliases: `phrasings=` / `n=` → `requests_per_situation`; `repeats=` → `rollo
1122
1189
 
1123
1190
  Each row, in `data.trajectories` and on disk: `prompt`, `messages`, `steps`, `final_text`, `scenario_id`. Optional `world_state`, `faults`, `reward`, `reason`. `llm_grade=True` adds `llm_reward`. `wai.rank(path)` adds `quality` without changing `reward`. Every row also says how it was sampled: `sampling` is `{"temperature", "max_tokens", "model"}` as the model backend resolved them, or what you passed as `simulate(sampling=...)` for your own callable agent (`None` when you passed nothing, since only you know how it samples). `policy_version` names the model and the system prompt it ran under.
1124
1191
 
1125
- Three models can take part in a run, and by default they are one: the agent answers, and the same model writes the situations and plays the user in follow-up turns (only the judge is a different model). Every row now says who did which job, next to `model_version` for the agent: `writer_model` (the writer's model, or `template`, `seed`, `pinned` when no model wrote the prompt), `user_model` (absent when the agent took a single message), and `judge_meta.model` once graded; `data.metadata` and the `.meta.json` sidecar carry the same three. When the agent model also wrote the situations or played the user, `data.degraded` holds `same_model` and `data.warnings` says so in one sentence, with the fix: pass `simulator=` for the writer and `user_model=` for the user to put those jobs on a different model.
1192
+ Three models can take part in a run, and by default they are one: the agent answers, and the same model writes the situations and plays the user in follow-up turns (only the judge is a different model). Every row now says who did which job, next to `model_version` for the agent: `writer_model` (the writer's model, or `template`, `seed`, `pinned` when no model wrote the prompt), `user_model` (absent when the agent took a single message), and `judge_meta.model` once graded; `data.metadata` and the `.meta.json` sidecar carry the same three. Every row also names the deploy prompt it was generated under: `lineage.system_prompt_sha` (the hash `policy_version` carries after `@`) , `lineage.system_prompt_head` (its first 120 chars) and `lineage.system_prompt_chars`, with the full text once per run in `data.system_prompts[<sha>]`, so a base rate measured under a full policy is never mistaken for one measured under a bare prompt; `delta_report` warns when its two arms differ on that hash. When the agent model also wrote the situations or played the user, `data.degraded` holds `same_model` and `data.warnings` says so in one sentence, with the fix: pass `simulator=` for the writer and `user_model=` for the user to put those jobs on a different model.
1126
1193
 
1127
1194
  What goes to disk is the whole row, not a summary of it: `data.rows` (the same list `output=` and `save()` write, callable as `data.rows()` too) carries everything the trajectory carries, so a saved run can still prove its own provenance. That includes how the situation was drawn (`scenario_dimensions`, `arm`, `selection_reason`, `behavior_signature`, `seed`), who graded it and how that went (`judge_name`, `judge_status`, `judge_meta`, `lineage`, `label_source`), and what was measured on it (`markers`, read by `marker_summary` and `delta_report`). Two things never ship, at any depth of the row: the teacher-only `privileged` block and its `principle` / `hidden_state` / `reference` / `rubric` fields, which would put the answer key one step from a training file, and `vector`, the raw embedding the diversity search keeps in memory for the length of the run. A privileged block nested inside a carried field or a tool result is dropped the same way, before `messages` is rebuilt from the steps.
1128
1195
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "whileai"
7
- version = "0.62"
7
+ version = "0.64"
8
8
  description = "While Python SDK: trace ingestion and verification client, plus agent simulations grounded in your tools and system prompt."
9
9
  readme = "README.md"
10
10
  license = {text = "Apache-2.0"}
@@ -80,11 +80,14 @@ from .ingest.traces import (
80
80
  drop_leaky_rows,
81
81
  flaw_rows,
82
82
  format_trace_report,
83
+ infer_harness,
83
84
  leakage_report,
84
85
  load_traces,
85
86
  mine_traces,
87
+ opening_share,
86
88
  simulate_from_traces,
87
89
  split_pseudo_production,
90
+ tools_from_traces,
88
91
  trace_report,
89
92
  )
90
93
  from .monitor import HackMonitor, format_hack_monitor
@@ -175,6 +178,7 @@ from .score.stats import (
175
178
  detectable_effect,
176
179
  eval_variance,
177
180
  holdout_size,
181
+ marker_names,
178
182
  marker_summary,
179
183
  metric_summary,
180
184
  task_key,
@@ -310,6 +314,7 @@ __all__ = [
310
314
  "holdout_size",
311
315
  "hosted_model",
312
316
  "import_hf",
317
+ "infer_harness",
313
318
  "inspect",
314
319
  "issue_delegated_credential",
315
320
  "judge_agreement",
@@ -329,6 +334,7 @@ __all__ = [
329
334
  "loss_mask",
330
335
  "mark_grounding",
331
336
  "mark_rows",
337
+ "marker_names",
332
338
  "marker_summary",
333
339
  "mean_kl",
334
340
  "metric_summary",
@@ -339,6 +345,7 @@ __all__ = [
339
345
  "normalize_judge_result",
340
346
  "novelty",
341
347
  "open_ended_probes",
348
+ "opening_share",
342
349
  "optimize",
343
350
  "outcome_check",
344
351
  "pairwise_judge",
@@ -393,6 +400,7 @@ __all__ = [
393
400
  "to_row",
394
401
  "to_trl",
395
402
  "tool_call_roundtrip",
403
+ "tools_from_traces",
396
404
  "trace_flag_report",
397
405
  "trace_flags",
398
406
  "trace_markers",
@@ -366,6 +366,10 @@ class SimulationData:
366
366
  # ``model_version`` on every row.
367
367
  writer_model: str = ""
368
368
  user_model: str | None = None
369
+ # The deploy prompt the rows were generated under, full text keyed by
370
+ # the hash every row carries in ``lineage.system_prompt_sha`` (#296).
371
+ # Those two are the hash's only homes: the row and this text.
372
+ system_prompts: dict[str, str] = field(default_factory=dict)
369
373
 
370
374
  @property
371
375
  def judge_model(self) -> str | None:
@@ -837,6 +841,10 @@ class SimulationData:
837
841
  # The agent spec: a trainer loading this JSONL later
838
842
  # needs the policy and tool schemas the run knew.
839
843
  "system_prompt": str(getattr(self.profile, "policy", "") or ""),
844
+ # the exact text the rows were generated under
845
+ # (policy plus scaffold), keyed by the hash each
846
+ # row carries in lineage.system_prompt_sha
847
+ "system_prompts": dict(self.system_prompts),
840
848
  "tools": list(getattr(self.profile, "tools", None) or []),
841
849
  "stopped_because": self.stopped_because,
842
850
  "coverage": self.coverage,
@@ -13,6 +13,16 @@ target model emits thinking tokens. ``training_rows`` closes that gap:
13
13
  * strips ``<think>`` blocks from assistant turns, so a thinking rollout
14
14
  model never teaches a non-thinking student to emit them.
15
15
 
16
+ The strip cuts the other way too. A reasoning base (Qwen3) trained on
17
+ think-free targets learns to print an empty ``<think></think>`` and
18
+ answer at once. That is fine until the eval, where that adapter meets
19
+ the untrained base under one shared ``max_tokens``: the base reasons and
20
+ runs out of budget, the adapter answers, and the delta is a win over
21
+ replies the base never produced (#297). ``delta_report`` fails that
22
+ comparison; the fix is ``thinking=`` set the same on both arms, or
23
+ ``strip_think=False`` when the student is a reasoning model and should
24
+ keep reasoning.
25
+
16
26
  Two wire shapes come out of here, and they are not the same shape:
17
27
 
18
28
  ``format="openai"`` (the default)
@@ -411,6 +421,14 @@ def training_rows(
411
421
  its policy trains an agent that never saw its rules. ``mask_mode``
412
422
  picks which assistant turns carry loss (see ``loss_mask``).
413
423
 
424
+ ``strip_think=True`` (the default) removes ``<think>`` blocks from the
425
+ assistant turns. On a reasoning base such as Qwen3 that teaches the
426
+ adapter to emit an empty ``<think></think>`` and answer at once, so
427
+ at eval it answers while the untrained base is still reasoning under
428
+ the same ``max_tokens`` (#297). Pass ``strip_think=False`` when the
429
+ student should keep reasoning, and set ``thinking=`` the same on both
430
+ arms of the eval either way.
431
+
414
432
  ``unroll=True`` turns an N-turn conversation into N samples, the
415
433
  k-th ending at the k-th assistant turn with loss on that turn only
416
434
  (rlhf-book ch. 4, multi-turn masking). Every earlier agent turn then
@@ -343,6 +343,7 @@ def resolve(
343
343
  max_turns: int = 5,
344
344
  avg_turns: float = 6,
345
345
  min_user_turns: int = 1,
346
+ patience: str = "normal",
346
347
  turn_stats: dict | None = None,
347
348
  opening_rate: float = 0.0,
348
349
  temperature: float | None = None,
@@ -378,6 +379,7 @@ def resolve(
378
379
  fault_plans=fault_plans,
379
380
  avg_turns=avg_turns,
380
381
  min_user_turns=min_user_turns,
382
+ patience=patience,
381
383
  opening_rate=opening_rate,
382
384
  execute=execute,
383
385
  result_shapes=result_shapes,