whileai 0.63__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 (94) hide show
  1. {whileai-0.63/whileai.egg-info → whileai-0.64}/PKG-INFO +30 -3
  2. {whileai-0.63 → whileai-0.64}/README.md +29 -2
  3. {whileai-0.63 → whileai-0.64}/pyproject.toml +1 -1
  4. {whileai-0.63 → whileai-0.64}/whileai/simulations/data.py +8 -0
  5. {whileai-0.63 → whileai-0.64}/whileai/simulations/generate/adapters.py +2 -0
  6. {whileai-0.63 → whileai-0.64}/whileai/simulations/generate/agents.py +187 -13
  7. {whileai-0.63 → whileai-0.64}/whileai/simulations/generate/generator.py +9 -1
  8. {whileai-0.63 → whileai-0.64}/whileai/simulations/ingest/traces.py +67 -6
  9. {whileai-0.63 → whileai-0.64}/whileai/simulations/run/config.py +25 -1
  10. {whileai-0.63 → whileai-0.64}/whileai/simulations/run/engine.py +175 -29
  11. {whileai-0.63 → whileai-0.64}/whileai/simulations/run/rows.py +38 -5
  12. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/delta.py +388 -37
  13. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/grade_llm.py +49 -0
  14. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/passat.py +61 -6
  15. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/stats.py +294 -41
  16. {whileai-0.63 → whileai-0.64}/whileai/simulations/simulation.py +41 -1
  17. {whileai-0.63 → whileai-0.64}/whileai/simulations/world/sandbox.py +24 -1
  18. {whileai-0.63 → whileai-0.64/whileai.egg-info}/PKG-INFO +30 -3
  19. {whileai-0.63 → whileai-0.64}/LICENSE +0 -0
  20. {whileai-0.63 → whileai-0.64}/MANIFEST.in +0 -0
  21. {whileai-0.63 → whileai-0.64}/setup.cfg +0 -0
  22. {whileai-0.63 → whileai-0.64}/whileai/__init__.py +0 -0
  23. {whileai-0.63 → whileai-0.64}/whileai/_env.py +0 -0
  24. {whileai-0.63 → whileai-0.64}/whileai/auth.py +0 -0
  25. {whileai-0.63 → whileai-0.64}/whileai/cli.py +0 -0
  26. {whileai-0.63 → whileai-0.64}/whileai/ingest.py +0 -0
  27. {whileai-0.63 → whileai-0.64}/whileai/init_evals.py +0 -0
  28. {whileai-0.63 → whileai-0.64}/whileai/py.typed +0 -0
  29. {whileai-0.63 → whileai-0.64}/whileai/simulations/__init__.py +0 -0
  30. {whileai-0.63 → whileai-0.64}/whileai/simulations/__main__.py +0 -0
  31. {whileai-0.63 → whileai-0.64}/whileai/simulations/environment.py +0 -0
  32. {whileai-0.63 → whileai-0.64}/whileai/simulations/export.py +0 -0
  33. {whileai-0.63 → whileai-0.64}/whileai/simulations/generate/__init__.py +0 -0
  34. {whileai-0.63 → whileai-0.64}/whileai/simulations/generate/actionspace.py +0 -0
  35. {whileai-0.63 → whileai-0.64}/whileai/simulations/generate/anthropic_backend.py +0 -0
  36. {whileai-0.63 → whileai-0.64}/whileai/simulations/generate/coverage.py +0 -0
  37. {whileai-0.63 → whileai-0.64}/whileai/simulations/generate/diversity.py +0 -0
  38. {whileai-0.63 → whileai-0.64}/whileai/simulations/generate/embeddings.py +0 -0
  39. {whileai-0.63 → whileai-0.64}/whileai/simulations/generate/explore.py +0 -0
  40. {whileai-0.63 → whileai-0.64}/whileai/simulations/generate/offline_agent.py +0 -0
  41. {whileai-0.63 → whileai-0.64}/whileai/simulations/generate/scenarios.py +0 -0
  42. {whileai-0.63 → whileai-0.64}/whileai/simulations/generate/usage_meter.py +0 -0
  43. {whileai-0.63 → whileai-0.64}/whileai/simulations/ingest/__init__.py +0 -0
  44. {whileai-0.63 → whileai-0.64}/whileai/simulations/ingest/otel.py +0 -0
  45. {whileai-0.63 → whileai-0.64}/whileai/simulations/ingest/platform.py +0 -0
  46. {whileai-0.63 → whileai-0.64}/whileai/simulations/monitor.py +0 -0
  47. {whileai-0.63 → whileai-0.64}/whileai/simulations/py.typed +0 -0
  48. {whileai-0.63 → whileai-0.64}/whileai/simulations/run/__init__.py +0 -0
  49. {whileai-0.63 → whileai-0.64}/whileai/simulations/run/spec.py +0 -0
  50. {whileai-0.63 → whileai-0.64}/whileai/simulations/schema.py +0 -0
  51. {whileai-0.63 → whileai-0.64}/whileai/simulations/schemas/row-v1.json +0 -0
  52. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/__init__.py +0 -0
  53. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/agreement.py +0 -0
  54. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/audit.py +0 -0
  55. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/checklist.py +0 -0
  56. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/curriculum.py +0 -0
  57. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/grading.py +0 -0
  58. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/grounding.py +0 -0
  59. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/hack_scan.py +0 -0
  60. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/hygiene.py +0 -0
  61. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/judge_trust.py +0 -0
  62. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/judging.py +0 -0
  63. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/labels.py +0 -0
  64. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/llm_judge.py +0 -0
  65. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/logprobs.py +0 -0
  66. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/markers.py +0 -0
  67. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/optimize.py +0 -0
  68. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/pairwise.py +0 -0
  69. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/preflight.py +0 -0
  70. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/privileged.py +0 -0
  71. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/publish_gate.py +0 -0
  72. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/quality.py +0 -0
  73. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/reference.py +0 -0
  74. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/rubric.py +0 -0
  75. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/spec.py +0 -0
  76. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/stage.py +0 -0
  77. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/style.py +0 -0
  78. {whileai-0.63 → whileai-0.64}/whileai/simulations/score/trace.py +0 -0
  79. {whileai-0.63 → whileai-0.64}/whileai/simulations/text.py +0 -0
  80. {whileai-0.63 → whileai-0.64}/whileai/simulations/training.py +0 -0
  81. {whileai-0.63 → whileai-0.64}/whileai/simulations/verify/__init__.py +0 -0
  82. {whileai-0.63 → whileai-0.64}/whileai/simulations/verify/base.py +0 -0
  83. {whileai-0.63 → whileai-0.64}/whileai/simulations/verify/code.py +0 -0
  84. {whileai-0.63 → whileai-0.64}/whileai/simulations/verify/math.py +0 -0
  85. {whileai-0.63 → whileai-0.64}/whileai/simulations/verify/structured.py +0 -0
  86. {whileai-0.63 → whileai-0.64}/whileai/simulations/verify/text.py +0 -0
  87. {whileai-0.63 → whileai-0.64}/whileai/simulations/world/__init__.py +0 -0
  88. {whileai-0.63 → whileai-0.64}/whileai/templates/__init__.py +0 -0
  89. {whileai-0.63 → whileai-0.64}/whileai/templates/evals.py +0 -0
  90. {whileai-0.63 → whileai-0.64}/whileai.egg-info/SOURCES.txt +0 -0
  91. {whileai-0.63 → whileai-0.64}/whileai.egg-info/dependency_links.txt +0 -0
  92. {whileai-0.63 → whileai-0.64}/whileai.egg-info/entry_points.txt +0 -0
  93. {whileai-0.63 → whileai-0.64}/whileai.egg-info/requires.txt +0 -0
  94. {whileai-0.63 → 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.63
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
@@ -709,6 +709,22 @@ rows, report = wai.drop_leaky_rows(data.trajectories, prod)
709
709
  And the loop closes on itself: `evaluate(rollouts, judge).failed_traces()`
710
710
  hands the failures straight back to `simulate(traces=...)`.
711
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
+
712
728
  If your traces are already on the platform, `wai.cut(agent="my-agent")`
713
729
  does the whole cut in one line — see
714
730
  [Training data out of traces](#training-data-out-of-traces).
@@ -947,6 +963,13 @@ wai.style_markers(rows) # no_boilerplate, no_hedging, no_apology, no_sycophancy
947
963
  wai.style_report(rows)["warnings"] # "reward pays for hedging (corr +0.41 ...)"
948
964
  wai.refusal_report(benign_rows) # over-refusal rate with a Wilson interval
949
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
950
973
  wai.delta_report(before, after, target="pass_at_1", must_not_regress=["honest_after_fault"])
951
974
  wai.delta_report(before, after, target="pass_at_1", by="category") # the target per kind of prompt
952
975
  before = wai.simulate(agent, tools=TOOLS, tasks=base, runs=3) # the same eval three times
@@ -979,7 +1002,9 @@ rerun = wai.simulate(
979
1002
  assert base.rollouts_per_request == rerun.rollouts_per_request # cheap guard
980
1003
  ```
981
1004
 
982
- **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.
983
1008
 
984
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.
985
1010
 
@@ -1191,6 +1216,8 @@ Measured at `avg_turns=4`. The default is now `12`, so a row carries more turns
1191
1216
  | `embedder` | `"hash"` | Prompt selection |
1192
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 |
1193
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. |
1194
1221
 
1195
1222
  Aliases: `phrasings=` / `n=` → `requests_per_situation`; `repeats=` → `rollouts_per_request`; `unique=` → `unique_situations`; `policy=` → `system_prompt`; `risk=` → `fault_rate`.
1196
1223
 
@@ -1198,7 +1225,7 @@ Aliases: `phrasings=` / `n=` → `requests_per_situation`; `repeats=` → `rollo
1198
1225
 
1199
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.
1200
1227
 
1201
- 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.
1202
1229
 
1203
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.
1204
1231
 
@@ -673,6 +673,22 @@ rows, report = wai.drop_leaky_rows(data.trajectories, prod)
673
673
  And the loop closes on itself: `evaluate(rollouts, judge).failed_traces()`
674
674
  hands the failures straight back to `simulate(traces=...)`.
675
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
+
676
692
  If your traces are already on the platform, `wai.cut(agent="my-agent")`
677
693
  does the whole cut in one line — see
678
694
  [Training data out of traces](#training-data-out-of-traces).
@@ -911,6 +927,13 @@ wai.style_markers(rows) # no_boilerplate, no_hedging, no_apology, no_sycophancy
911
927
  wai.style_report(rows)["warnings"] # "reward pays for hedging (corr +0.41 ...)"
912
928
  wai.refusal_report(benign_rows) # over-refusal rate with a Wilson interval
913
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
914
937
  wai.delta_report(before, after, target="pass_at_1", must_not_regress=["honest_after_fault"])
915
938
  wai.delta_report(before, after, target="pass_at_1", by="category") # the target per kind of prompt
916
939
  before = wai.simulate(agent, tools=TOOLS, tasks=base, runs=3) # the same eval three times
@@ -943,7 +966,9 @@ rerun = wai.simulate(
943
966
  assert base.rollouts_per_request == rerun.rollouts_per_request # cheap guard
944
967
  ```
945
968
 
946
- **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.
947
972
 
948
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.
949
974
 
@@ -1155,6 +1180,8 @@ Measured at `avg_turns=4`. The default is now `12`, so a row carries more turns
1155
1180
  | `embedder` | `"hash"` | Prompt selection |
1156
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 |
1157
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. |
1158
1185
 
1159
1186
  Aliases: `phrasings=` / `n=` → `requests_per_situation`; `repeats=` → `rollouts_per_request`; `unique=` → `unique_situations`; `policy=` → `system_prompt`; `risk=` → `fault_rate`.
1160
1187
 
@@ -1162,7 +1189,7 @@ Aliases: `phrasings=` / `n=` → `requests_per_situation`; `repeats=` → `rollo
1162
1189
 
1163
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.
1164
1191
 
1165
- 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.
1166
1193
 
1167
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.
1168
1195
 
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "whileai"
7
- version = "0.63"
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"}
@@ -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,
@@ -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,
@@ -911,8 +911,122 @@ def _agent_asked(text: str) -> bool:
911
911
  return bool(t) and ("?" in t)
912
912
 
913
913
 
914
+ #: ``simulate(patience=)``: how long the simulated person keeps answering
915
+ #: the agent's questions. The first question is always attempted; from the
916
+ #: second on the person may walk away at these odds, per level. ``endless``
917
+ #: is what every run did before the knob existed: every question answered
918
+ #: until the depth cap, so whether a thread ended was decided by the turn
919
+ #: budget and never by what the agent said, and no rubric criterion about
920
+ #: asking could fail (#289). The odds are a default, not a measurement: to
921
+ #: ground them, fit a Kaplan-Meier hazard per question index on source
922
+ #: traces (of the threads still answering at question k, the share that
923
+ #: leave at k) and set the levels from it. The 63% of asked source threads
924
+ #: that ended with the person walking away says the shape is common, not
925
+ #: what the per-question hazard is.
926
+ PATIENCE_LEVELS = ("short", "normal", "endless")
927
+ _WALK_AWAY_ODDS = {
928
+ "short": (0.6, 0.9),
929
+ "normal": (0.35, 0.6),
930
+ "endless": (0.0, 0.0),
931
+ }
932
+ #: What ``_user_followup`` returns when the person gives up on the question.
933
+ USER_LEFT = "[leaves]"
934
+ _LEAVES = re.compile(r"\W*leaves\W*", re.I)
935
+
936
+
937
+ def _draw(message: str, turn_i: int, salt: str) -> float:
938
+ """A uniform draw in [0, 1), fixed by the message and turn so a seeded
939
+ run reproduces exactly."""
940
+ digest = hashlib.sha256(f"{message}:{turn_i}:{salt}".encode()).hexdigest()
941
+ return int(digest[:8], 16) / float(1 << 32)
942
+
943
+
944
+ def _walk_away_hazard(patience: str, questions: int) -> float:
945
+ """Chance the person leaves instead of answering, given how many
946
+ questions the agent already asked on this thread (not counting this
947
+ one). Zero on the first question: the person always tries once."""
948
+ second, later = _WALK_AWAY_ODDS.get(
949
+ str(patience or "normal").lower(), _WALK_AWAY_ODDS["normal"]
950
+ )
951
+ if questions <= 0:
952
+ return 0.0
953
+ return second if questions == 1 else later
954
+
955
+
956
+ def _user_turn_cap(budget: int) -> int:
957
+ """How many times the person speaks at most on a thread of ``budget``
958
+ turns. The budget's rule, kept apart from the person's patience: a
959
+ thread the cap ends is not one the person left."""
960
+ return max(2, int(budget) // 2)
961
+
962
+
963
+ def _user_walks_away(
964
+ message: str,
965
+ turn_i: int,
966
+ *,
967
+ questions: int = 0,
968
+ patience: str = "normal",
969
+ ) -> bool:
970
+ """True when the person gives up on the agent's question rather than
971
+ answer it (#289): the patience hazard at this question index, and
972
+ nothing about the budget. Deterministic in the message and turn, so a
973
+ seeded run reproduces."""
974
+ hazard = _walk_away_hazard(patience, questions)
975
+ if hazard <= 0:
976
+ return False
977
+ return _draw(message, turn_i, "abandon") < hazard
978
+
979
+
980
+ def _user_left(text: str) -> bool:
981
+ """The user-sim's answer was the leave mark and nothing else."""
982
+ body = re.sub(r"<think>.*?</think>", "", str(text or ""), flags=re.S).strip()
983
+ return bool(body) and bool(_LEAVES.fullmatch(body))
984
+
985
+
986
+ def ended_on_question(rows) -> dict:
987
+ """``{"share", "n", "user_left"}``: of ``n`` rows, the share that ended
988
+ on the agent's question, and how many of those the person left
989
+ (``ended_by="user_left"``; the rest hit the turn budget).
990
+
991
+ Report this next to any criterion about how the agent asks. Before #289
992
+ it was structurally zero, so a lane could not tell "the agent never asked
993
+ badly" from "the loop cannot produce that ending". The engine writes it
994
+ to ``search["ended_on_question"]`` on every run.
995
+ """
996
+ total = 0
997
+ ended = 0
998
+ left = 0
999
+ for row in rows or []:
1000
+ if not isinstance(row, dict):
1001
+ continue
1002
+ total += 1
1003
+ turns = row.get("messages") or row.get("trajectory") or []
1004
+ last_agent = ""
1005
+ for turn in turns:
1006
+ if isinstance(turn, dict) and turn.get("role") == "assistant":
1007
+ last_agent = str(turn.get("content") or "")
1008
+ if not turns:
1009
+ last_agent = str(row.get("final_text") or "")
1010
+ if last_agent and _agent_asked(last_agent):
1011
+ ended += 1
1012
+ if row.get("ended_by") == "user_left":
1013
+ left += 1
1014
+ return {
1015
+ "share": (ended / total if total else 0.0),
1016
+ "n": total,
1017
+ "user_left": left,
1018
+ }
1019
+
1020
+
914
1021
  def _want_followup(
915
- message: str, turn_i: int, *, user_turns: int = 1, budget: int = 6, agent_text: str = ""
1022
+ message: str,
1023
+ turn_i: int,
1024
+ *,
1025
+ user_turns: int = 1,
1026
+ budget: int = 6,
1027
+ agent_text: str = "",
1028
+ questions: int = 0,
1029
+ patience: str = "normal",
916
1030
  ) -> bool:
917
1031
  """True when the human would naturally speak again.
918
1032
 
@@ -933,13 +1047,28 @@ def _want_followup(
933
1047
  and asks, the user says yes, the agent acts. At 1.5 user turns most
934
1048
  rollouts never reach the write, so the rule is never exercised and the
935
1049
  training set cannot demonstrate it.
1050
+
1051
+ A question no longer earns an answer unconditionally. ``questions`` is
1052
+ how many the agent already asked on this thread; from the second one
1053
+ the person may walk away at the odds ``patience`` sets (#289). Before
1054
+ this, whether a thread ended was decided by the turn budget and never
1055
+ by what the agent said, so no rubric criterion about asking could
1056
+ fail: in source traces 63% of asked threads ended with the person
1057
+ walking away, in generated data 19-22%, all of those depth-cap cuts.
936
1058
  """
937
- cap = max(2, int(budget) // 2)
1059
+ cap = _user_turn_cap(budget)
938
1060
  if int(user_turns) >= cap:
939
1061
  return False
940
1062
  text = str(agent_text or "")
941
1063
  if _agent_asked(text):
942
- return True
1064
+ # Asking is free in simulation and costly in reality. This used to be
1065
+ # an unconditional True (#289); now the person always tries the first
1066
+ # question and from the second on may walk away at the odds
1067
+ # ``patience`` sets. Short threads (budget under 4) never reach a
1068
+ # second question, so the old contract holds there: with room for
1069
+ # one exchange, abandoning would leave the question as the whole
1070
+ # rollout and fill the set with stubs.
1071
+ return not _user_walks_away(message, turn_i, questions=questions, patience=patience)
943
1072
  if _AGENT_REFUSAL.search(text):
944
1073
  return True
945
1074
  if int(budget) < 4:
@@ -948,9 +1077,7 @@ def _want_followup(
948
1077
  return False
949
1078
  # Geometric with p = 1 - 1/cap: a thread of cap turns in expectation,
950
1079
  # deterministic in the message and turn so a seeded run reproduces.
951
- digest = hashlib.sha256(f"{message}:{turn_i}:react".encode()).hexdigest()
952
- draw = int(digest[:8], 16) / float(1 << 32)
953
- return draw < (1.0 - 1.0 / float(cap))
1080
+ return _draw(message, turn_i, "react") < (1.0 - 1.0 / float(cap))
954
1081
 
955
1082
 
956
1083
  # Follow-up user turns only. Opener temperature lives on the writer (0.45–1.05).
@@ -962,6 +1089,7 @@ _USER_SIM_SYSTEM = (
962
1089
  "Stay in the same world as the opening line and the tools on this thread. "
963
1090
  "If the agent asked a question, answer it with a concrete detail a person "
964
1091
  "here would know ({hints}whatever this thread is actually about). "
1092
+ "{leave_note}"
965
1093
  "{code_note}"
966
1094
  "If they already acted, react: push back, correct them, or ask for the next thing. "
967
1095
  "Do not acknowledge. Do not repeat their question. Do not describe a persona."
@@ -988,8 +1116,17 @@ def _detail_hints(tools: list | None) -> list[str]:
988
1116
  return out[:8]
989
1117
 
990
1118
 
991
- def user_sim_system(tools: list | None = None) -> str:
992
- """The user simulator's instructions for this agent's world."""
1119
+ _LEAVE_NOTE = (
1120
+ "Only when their question asks for something this person could not know, "
1121
+ "or asks again what was already answered on this thread, the person may "
1122
+ "give up instead: then write exactly [leaves] and nothing else. "
1123
+ )
1124
+
1125
+
1126
+ def user_sim_system(tools: list | None = None, may_leave: bool = False) -> str:
1127
+ """The user simulator's instructions for this agent's world.
1128
+ ``may_leave`` adds the one way out: a question this person cannot or
1129
+ would not answer may be met with the leave mark (#289)."""
993
1130
  hints = _detail_hints(tools)
994
1131
  names = " ".join(_tool_world(tools).split(",")) + " " + " ".join(hints)
995
1132
  code_note = (
@@ -999,7 +1136,9 @@ def user_sim_system(tools: list | None = None) -> str:
999
1136
  else ""
1000
1137
  )
1001
1138
  return _USER_SIM_SYSTEM.format(
1002
- hints=(", ".join(hints) + ", ") if hints else "", code_note=code_note
1139
+ hints=(", ".join(hints) + ", ") if hints else "",
1140
+ leave_note=_LEAVE_NOTE if may_leave else "",
1141
+ code_note=code_note,
1003
1142
  )
1004
1143
 
1005
1144
 
@@ -1255,6 +1394,7 @@ def _user_followup(
1255
1394
  persona_tags: dict | None = None,
1256
1395
  extra: Mapping[str, Any] | None = None,
1257
1396
  turn_stats: dict | None = None,
1397
+ may_leave: bool = False,
1258
1398
  ) -> str:
1259
1399
  """The simulated user's next line, or ``""`` when the writer produced
1260
1400
  none worth keeping. ``extra`` carries the agent's request fields
@@ -1330,7 +1470,7 @@ def _user_followup(
1330
1470
  base_url,
1331
1471
  model,
1332
1472
  [
1333
- {"role": "system", "content": user_sim_system(tools)},
1473
+ {"role": "system", "content": user_sim_system(tools, may_leave=may_leave)},
1334
1474
  {"role": "user", "content": content},
1335
1475
  ],
1336
1476
  tools=None,
@@ -1344,6 +1484,11 @@ def _user_followup(
1344
1484
  continue
1345
1485
  spoken, closed, unclosed = split_reasoning(reply.get("content") or "")
1346
1486
  _note_user_turn(turn_stats, closed, unclosed)
1487
+ # A leave mark is an answer of its own kind: read it before the
1488
+ # reasoning-only retry rule, or a person who thinks then leaves is
1489
+ # retried instead of gone (#289).
1490
+ if may_leave and _user_left(reply.get("content") or ""):
1491
+ return USER_LEFT
1347
1492
  if (closed or unclosed) and len(spoken.strip()) < _MIN_SPOKEN:
1348
1493
  # Reasoning and no spoken line. One more try with the short
1349
1494
  # prompt; never a fragment, never empty user speech.
@@ -1528,6 +1673,7 @@ def local_model(
1528
1673
  max_tokens: int | None = None,
1529
1674
  user_model: str | None = None,
1530
1675
  thinking: bool | None = None,
1676
+ patience: str = "normal",
1531
1677
  ) -> Callable:
1532
1678
  """An agent that talks to an OpenAI-compatible endpoint (a served
1533
1679
  adapter, a local vLLM, any chat server) for ``simulate(agent=...)``.
@@ -1641,11 +1787,13 @@ def local_model(
1641
1787
  len(messages) - 1, {"role": "assistant", "content": opener_text}
1642
1788
  )
1643
1789
 
1644
- def _done(done_steps: list, final: str) -> dict:
1790
+ def _done(done_steps: list, final: str, ended_by: str = "") -> dict:
1645
1791
  out = _finish_on_agent(done_steps, final)
1646
1792
  if opener_text:
1647
1793
  out["opener"] = opener_text
1648
1794
  out["opening"] = "agent"
1795
+ if ended_by:
1796
+ out["ended_by"] = ended_by
1649
1797
  return out
1650
1798
 
1651
1799
  steps: list[dict] = []
@@ -1783,10 +1931,21 @@ def local_model(
1783
1931
  continue
1784
1932
  need_first = n_user < 2
1785
1933
  force_followup = n_user < min_users
1934
+ # Questions the agent already asked on this thread, not counting
1935
+ # this one: the person's patience runs out with them (#289).
1936
+ prior_questions = sum(
1937
+ 1 for s in steps if isinstance(s, dict) and _agent_asked(str(s.get("text") or ""))
1938
+ ) - (1 if _agent_asked(spoken) else 0)
1786
1939
  if (room or need_first) and (
1787
1940
  force_followup
1788
1941
  or _want_followup(
1789
- message, turn_i, user_turns=n_user, budget=budget, agent_text=spoken
1942
+ message,
1943
+ turn_i,
1944
+ user_turns=n_user,
1945
+ budget=budget,
1946
+ agent_text=spoken,
1947
+ questions=prior_questions,
1948
+ patience=patience,
1790
1949
  )
1791
1950
  ):
1792
1951
  follow = _user_followup(
@@ -1804,7 +1963,11 @@ def local_model(
1804
1963
  force=force_followup,
1805
1964
  extra=user_extras,
1806
1965
  turn_stats=turn_stats,
1966
+ may_leave=patience != "endless",
1807
1967
  )
1968
+ if follow == USER_LEFT:
1969
+ # The person could not or would not answer this one.
1970
+ return _done(steps, spoken or final_text, ended_by="user_left")
1808
1971
  if follow:
1809
1972
  last_user = follow
1810
1973
  n_user += 1
@@ -1818,7 +1981,18 @@ def local_model(
1818
1981
  # these means the dataset is going single-turn.
1819
1982
  with turn_stats["lock"]:
1820
1983
  turn_stats["followup_misses"] = turn_stats.get("followup_misses", 0) + 1
1821
- return _done(steps, spoken or final_text)
1984
+ return _done(steps, spoken or final_text)
1985
+ # No follow-up. Under the cap and asked, that was the person's
1986
+ # patience; at the cap it was the budget, and the row says so
1987
+ # by carrying no ended_by.
1988
+ left = (
1989
+ (room or need_first)
1990
+ and not force_followup
1991
+ and n_user < _user_turn_cap(budget)
1992
+ and _agent_asked(spoken)
1993
+ and _user_walks_away(message, turn_i, questions=prior_questions, patience=patience)
1994
+ )
1995
+ return _done(steps, spoken or final_text, ended_by="user_left" if left else "")
1822
1996
  return _done(steps, final_text)
1823
1997
 
1824
1998
  agent.__name__ = f"local_model[{model}]"
@@ -1603,10 +1603,16 @@ class ModelSimulator:
1603
1603
  if isinstance(parent, str):
1604
1604
  return {"request": parent}
1605
1605
  if isinstance(parent, dict):
1606
- return {
1606
+ detail = {
1607
1607
  "id": parent.get("scenario_id") or parent.get("parent_failure_id"),
1608
1608
  "request": str(parent.get("prompt") or parent.get("request") or "")[:1000],
1609
1609
  }
1610
+ # What the grader said went wrong, so the retry card can name
1611
+ # the broken rule instead of only that something broke (#285).
1612
+ reason = parent.get("grader_reason") or parent.get("reason")
1613
+ if reason and parent.get("reward") is not None:
1614
+ detail["failed"] = str(reason)[:200]
1615
+ return detail
1610
1616
  return {"request": str(parent)[:1000]}
1611
1617
 
1612
1618
  def _system_prompt(self) -> str:
@@ -1761,6 +1767,8 @@ region_id exactly and placing the human's words in message."""
1761
1767
  )
1762
1768
  if prior:
1763
1769
  retry += f" Note: (earlier you said something like: {prior})"
1770
+ if parent.get("failed"):
1771
+ retry += f" What went wrong last time: {parent['failed']}"
1764
1772
  fail_card["instruction"] = retry
1765
1773
  targets.append(fail_card)
1766
1774
  parents = []