whileai 0.63__tar.gz → 0.65__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.63/whileai.egg-info → whileai-0.65}/PKG-INFO +69 -4
  2. {whileai-0.63 → whileai-0.65}/README.md +67 -3
  3. {whileai-0.63 → whileai-0.65}/pyproject.toml +2 -2
  4. {whileai-0.63 → whileai-0.65}/whileai/ingest.py +13 -6
  5. whileai-0.65/whileai/platform.py +916 -0
  6. {whileai-0.63 → whileai-0.65}/whileai/simulations/__init__.py +2 -0
  7. {whileai-0.63 → whileai-0.65}/whileai/simulations/data.py +19 -11
  8. {whileai-0.63 → whileai-0.65}/whileai/simulations/generate/adapters.py +2 -0
  9. {whileai-0.63 → whileai-0.65}/whileai/simulations/generate/agents.py +187 -13
  10. {whileai-0.63 → whileai-0.65}/whileai/simulations/generate/generator.py +9 -1
  11. {whileai-0.63 → whileai-0.65}/whileai/simulations/ingest/platform.py +58 -1
  12. {whileai-0.63 → whileai-0.65}/whileai/simulations/ingest/traces.py +67 -6
  13. {whileai-0.63 → whileai-0.65}/whileai/simulations/run/config.py +25 -1
  14. {whileai-0.63 → whileai-0.65}/whileai/simulations/run/engine.py +175 -29
  15. {whileai-0.63 → whileai-0.65}/whileai/simulations/run/rows.py +38 -5
  16. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/delta.py +388 -37
  17. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/grade_llm.py +49 -0
  18. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/judging.py +16 -6
  19. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/passat.py +61 -6
  20. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/stats.py +294 -41
  21. {whileai-0.63 → whileai-0.65}/whileai/simulations/simulation.py +41 -1
  22. {whileai-0.63 → whileai-0.65}/whileai/simulations/world/sandbox.py +24 -1
  23. {whileai-0.63 → whileai-0.65/whileai.egg-info}/PKG-INFO +69 -4
  24. {whileai-0.63 → whileai-0.65}/whileai.egg-info/SOURCES.txt +1 -0
  25. {whileai-0.63 → whileai-0.65}/whileai.egg-info/requires.txt +1 -0
  26. {whileai-0.63 → whileai-0.65}/LICENSE +0 -0
  27. {whileai-0.63 → whileai-0.65}/MANIFEST.in +0 -0
  28. {whileai-0.63 → whileai-0.65}/setup.cfg +0 -0
  29. {whileai-0.63 → whileai-0.65}/whileai/__init__.py +0 -0
  30. {whileai-0.63 → whileai-0.65}/whileai/_env.py +0 -0
  31. {whileai-0.63 → whileai-0.65}/whileai/auth.py +0 -0
  32. {whileai-0.63 → whileai-0.65}/whileai/cli.py +0 -0
  33. {whileai-0.63 → whileai-0.65}/whileai/init_evals.py +0 -0
  34. {whileai-0.63 → whileai-0.65}/whileai/py.typed +0 -0
  35. {whileai-0.63 → whileai-0.65}/whileai/simulations/__main__.py +0 -0
  36. {whileai-0.63 → whileai-0.65}/whileai/simulations/environment.py +0 -0
  37. {whileai-0.63 → whileai-0.65}/whileai/simulations/export.py +0 -0
  38. {whileai-0.63 → whileai-0.65}/whileai/simulations/generate/__init__.py +0 -0
  39. {whileai-0.63 → whileai-0.65}/whileai/simulations/generate/actionspace.py +0 -0
  40. {whileai-0.63 → whileai-0.65}/whileai/simulations/generate/anthropic_backend.py +0 -0
  41. {whileai-0.63 → whileai-0.65}/whileai/simulations/generate/coverage.py +0 -0
  42. {whileai-0.63 → whileai-0.65}/whileai/simulations/generate/diversity.py +0 -0
  43. {whileai-0.63 → whileai-0.65}/whileai/simulations/generate/embeddings.py +0 -0
  44. {whileai-0.63 → whileai-0.65}/whileai/simulations/generate/explore.py +0 -0
  45. {whileai-0.63 → whileai-0.65}/whileai/simulations/generate/offline_agent.py +0 -0
  46. {whileai-0.63 → whileai-0.65}/whileai/simulations/generate/scenarios.py +0 -0
  47. {whileai-0.63 → whileai-0.65}/whileai/simulations/generate/usage_meter.py +0 -0
  48. {whileai-0.63 → whileai-0.65}/whileai/simulations/ingest/__init__.py +0 -0
  49. {whileai-0.63 → whileai-0.65}/whileai/simulations/ingest/otel.py +0 -0
  50. {whileai-0.63 → whileai-0.65}/whileai/simulations/monitor.py +0 -0
  51. {whileai-0.63 → whileai-0.65}/whileai/simulations/py.typed +0 -0
  52. {whileai-0.63 → whileai-0.65}/whileai/simulations/run/__init__.py +0 -0
  53. {whileai-0.63 → whileai-0.65}/whileai/simulations/run/spec.py +0 -0
  54. {whileai-0.63 → whileai-0.65}/whileai/simulations/schema.py +0 -0
  55. {whileai-0.63 → whileai-0.65}/whileai/simulations/schemas/row-v1.json +0 -0
  56. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/__init__.py +0 -0
  57. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/agreement.py +0 -0
  58. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/audit.py +0 -0
  59. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/checklist.py +0 -0
  60. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/curriculum.py +0 -0
  61. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/grading.py +0 -0
  62. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/grounding.py +0 -0
  63. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/hack_scan.py +0 -0
  64. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/hygiene.py +0 -0
  65. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/judge_trust.py +0 -0
  66. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/labels.py +0 -0
  67. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/llm_judge.py +0 -0
  68. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/logprobs.py +0 -0
  69. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/markers.py +0 -0
  70. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/optimize.py +0 -0
  71. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/pairwise.py +0 -0
  72. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/preflight.py +0 -0
  73. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/privileged.py +0 -0
  74. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/publish_gate.py +0 -0
  75. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/quality.py +0 -0
  76. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/reference.py +0 -0
  77. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/rubric.py +0 -0
  78. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/spec.py +0 -0
  79. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/stage.py +0 -0
  80. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/style.py +0 -0
  81. {whileai-0.63 → whileai-0.65}/whileai/simulations/score/trace.py +0 -0
  82. {whileai-0.63 → whileai-0.65}/whileai/simulations/text.py +0 -0
  83. {whileai-0.63 → whileai-0.65}/whileai/simulations/training.py +0 -0
  84. {whileai-0.63 → whileai-0.65}/whileai/simulations/verify/__init__.py +0 -0
  85. {whileai-0.63 → whileai-0.65}/whileai/simulations/verify/base.py +0 -0
  86. {whileai-0.63 → whileai-0.65}/whileai/simulations/verify/code.py +0 -0
  87. {whileai-0.63 → whileai-0.65}/whileai/simulations/verify/math.py +0 -0
  88. {whileai-0.63 → whileai-0.65}/whileai/simulations/verify/structured.py +0 -0
  89. {whileai-0.63 → whileai-0.65}/whileai/simulations/verify/text.py +0 -0
  90. {whileai-0.63 → whileai-0.65}/whileai/simulations/world/__init__.py +0 -0
  91. {whileai-0.63 → whileai-0.65}/whileai/templates/__init__.py +0 -0
  92. {whileai-0.63 → whileai-0.65}/whileai/templates/evals.py +0 -0
  93. {whileai-0.63 → whileai-0.65}/whileai.egg-info/dependency_links.txt +0 -0
  94. {whileai-0.63 → whileai-0.65}/whileai.egg-info/entry_points.txt +0 -0
  95. {whileai-0.63 → whileai-0.65}/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.65
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
@@ -24,6 +24,7 @@ Requires-Python: >=3.10
24
24
  Description-Content-Type: text/markdown
25
25
  License-File: LICENSE
26
26
  Requires-Dist: requests>=2.25.0
27
+ Requires-Dist: pydantic>=2.0
27
28
  Provides-Extra: dev
28
29
  Requires-Dist: pytest>=8.0; extra == "dev"
29
30
  Requires-Dist: pytest-cov>=5.0; extra == "dev"
@@ -709,6 +710,22 @@ rows, report = wai.drop_leaky_rows(data.trajectories, prod)
709
710
  And the loop closes on itself: `evaluate(rollouts, judge).failed_traces()`
710
711
  hands the failures straight back to `simulate(traces=...)`.
711
712
 
713
+ **What traces can and cannot aim at.** Traces reproduce situations: the
714
+ tools, faults and world states the deployed agent met. A failure that has
715
+ a world-visible trigger (a tool timed out and the agent did not say so, a
716
+ stale record was presented as current) is reproduced. A failure that lives
717
+ in how the reply is worded (an unsupported claim, an estimate not labelled
718
+ as one, two questions where one was asked for) has no trigger in the world,
719
+ so traces alone cannot aim at it: measured on a 12-rule grader, every rule
720
+ with a tool-result trigger was reproduced and every rule about the reply's
721
+ wording was not (#285). For those, put the grader in the loop:
722
+ with `simulate(..., grader=judge)` a row the grader fails is re-rolled and
723
+ its ask mutated like a tool fault, and `data.search["mutation_aims"]` says
724
+ how many parents and mutated rows each aim (`world_fault`,
725
+ `graded_failure`) produced. The grader is the switch; to grade beside the
726
+ loop and still steer by tool faults alone, pass
727
+ `advanced={"mutate_graded_failures": False}`.
728
+
712
729
  If your traces are already on the platform, `wai.cut(agent="my-agent")`
713
730
  does the whole cut in one line — see
714
731
  [Training data out of traces](#training-data-out-of-traces).
@@ -903,7 +920,8 @@ The platform's "Make training data" button, as one line:
903
920
 
904
921
  ```python
905
922
  wai.send_score("4bf92f3577b34da6", 1.0) # this run passed
906
- wai.cuts(agent="my-agent") # what a cut would hold
923
+ summary = wai.cuts(agent="my-agent") # what a cut would hold
924
+ print(wai.format_cuts(summary, agent="my-agent")) # the traces page's sentence
907
925
  made = wai.cut(agent="my-agent", kind="rl") # make it
908
926
  wai.pull(made["train"]["datasetId"], "train.jsonl")
909
927
  made["holdout"]["datasetId"] # measure on this, never train on it
@@ -947,6 +965,13 @@ wai.style_markers(rows) # no_boilerplate, no_hedging, no_apology, no_sycophancy
947
965
  wai.style_report(rows)["warnings"] # "reward pays for hedging (corr +0.41 ...)"
948
966
  wai.refusal_report(benign_rows) # over-refusal rate with a Wilson interval
949
967
  wai.compare_runs(run_a, run_b) # paired delta with a 95% interval
968
+ wai.holdout_size(
969
+ 0.05, base=0.6, k=4
970
+ ) # tasks to prove a 5-point gain; n_tasks_concentrated beside it
971
+ wai.holdout_size(
972
+ 0.05, before=before, after=after
973
+ ) # the paired sd measured off a previous eval, no model
974
+ wai.holdout_size(0.05, task_std=0.38) # or the sd read off a delta_report interval
950
975
  wai.delta_report(before, after, target="pass_at_1", must_not_regress=["honest_after_fault"])
951
976
  wai.delta_report(before, after, target="pass_at_1", by="category") # the target per kind of prompt
952
977
  before = wai.simulate(agent, tools=TOOLS, tasks=base, runs=3) # the same eval three times
@@ -979,7 +1004,9 @@ rerun = wai.simulate(
979
1004
  assert base.rollouts_per_request == rerun.rollouts_per_request # cheap guard
980
1005
  ```
981
1006
 
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.
1007
+ **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.
1008
+
1009
+ **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
1010
 
984
1011
  **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
1012
 
@@ -1092,6 +1119,42 @@ run.holdout(before=0.42, after=0.58) # did it work? the run page opens with thi
1092
1119
 
1093
1120
  A run's page opens with one word — **Better**, **Worse**, **About the same** — over the held-out pass rate before and after. The platform's trainer measures it; a run on your own hardware says it with `run.holdout(before, after)`, or `wai.attach_holdout(run_id, before=..., after=...)` once the run has finished. Pass rates are 0 to 1, so 58% is `0.58`; `metric="loss"` sends held-out loss instead (SFT), where lower is better. `run.delta(...)` and `wai.attach_delta(...)` already measure both sides, so they fill the two numbers in themselves, and add `summary["holdout"]` (also `run.holdout_summary`): each side's pass rate with `n_tasks`, `k` and a `ci95`, plus the delta report's verdict word (`moved`, `moved_unreplicated`, `within_eval_noise`, `no_change_detected`). A hosted run read back with `run.refresh()` has the same block with the interval fields `None` and a note that the platform only returned two numbers.
1094
1121
 
1122
+ ### Report a run so a person can decide
1123
+
1124
+ The platform draws one screen per tracked agent at [while.ai/platform/runs](https://while.ai/platform/runs): the held-out score by version with the frontier model as the line to beat, the training curve, what moved on the behaviors you did not train, the judge checks, live traffic on the served version, and cost. A coding agent fills it with `whileai.platform`; the person reads it and presses Promote. Your agent framework stays yours: `track` takes the agent object you already have (OpenAI Agents SDK, Pydantic AI, LangGraph, Claude Agent SDK) and reads the model, the instructions and the tools off it, or you describe it by hand.
1125
+
1126
+ ```python
1127
+ from whileai.platform import Behavior, Frontier, Harness, Judge, track
1128
+
1129
+ tracked = track(
1130
+ "refund-bot", # or track(my_agent): name, model, prompt and tools come from the object
1131
+ model="Qwen/Qwen3-4B",
1132
+ harness=Harness(instructions=SYSTEM_PROMPT, tools=["lookup_order", "issue_refund"]),
1133
+ frontier=Frontier(name="Sonnet 5", score=81, cost_per_1k=18.0),
1134
+ )
1135
+ tracked.behavior(
1136
+ Behavior(
1137
+ name="refunds",
1138
+ test_version="v2",
1139
+ n=240,
1140
+ judge=Judge(agreement=0.86, human_n=60, length_bias=0.08),
1141
+ noise_floor=2.4,
1142
+ contamination=0,
1143
+ reward_is_judge=False,
1144
+ )
1145
+ )
1146
+
1147
+ run = tracked.run("v4", method="GRPO", targets=["refunds"], trained_on=["refunds-grpo"])
1148
+ run.log(10, reward=0.41, kl=0.01) # or trainer.add_callback(wai.TrainerCallback(run))
1149
+ run.score("refunds", 83, ci=2.7, n=240) # every behavior, not only the targets
1150
+ run.score("length", 76, ci=2.8, n=120)
1151
+ run.finish(hours=2.1, gpu="1xH100", cost_usd=31)
1152
+
1153
+ str(tracked.verdict()) # "refunds: v4 beats v3 by 5 (interval excludes zero); 1 regression"
1154
+ ```
1155
+
1156
+ Every object is a pydantic model that validates before it leaves the process, and each one's docstring names the rlhfbook.com chapter it comes from. A *harness* is the instructions, tools and model name around the weights; its fingerprint is its version, so a prompt edit shows up as a new version without anyone naming it (Evaluation: a score is only comparable with its setup held constant). A *behavior* has its own frozen held-out test (`test_version`), a `noise_floor` measured by scoring the same model twice, and a judge checked against people (`agreement` over `human_n`) and for `length_bias`. A *run* is scored on every behavior: `targets` are the claim, the rest are the check (Over-Optimization: verbosity, sycophancy and refusals are what moves when the reward is gamed). `ci` is the half-width of the 95% interval; the verdict is whether the candidate's and the served version's intervals overlap. `tracked.live(day, version=, replies=, flagged=)` reports a day of traffic when you serve the model yourself. Logging buffers and never raises into the training loop. Worked example: `recipes/04-train/report-run/`.
1157
+
1095
1158
  ### Is it hacking the reward right now?
1096
1159
 
1097
1160
  ```python
@@ -1191,6 +1254,8 @@ Measured at `avg_turns=4`. The default is now `12`, so a row carries more turns
1191
1254
  | `embedder` | `"hash"` | Prompt selection |
1192
1255
  | `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
1256
  | `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. |
1257
+ | `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 |
1258
+ | `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
1259
 
1195
1260
  Aliases: `phrasings=` / `n=` → `requests_per_situation`; `repeats=` → `rollouts_per_request`; `unique=` → `unique_situations`; `policy=` → `system_prompt`; `risk=` → `fault_rate`.
1196
1261
 
@@ -1198,7 +1263,7 @@ Aliases: `phrasings=` / `n=` → `requests_per_situation`; `repeats=` → `rollo
1198
1263
 
1199
1264
  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
1265
 
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.
1266
+ 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
1267
 
1203
1268
  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
1269
 
@@ -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).
@@ -867,7 +883,8 @@ The platform's "Make training data" button, as one line:
867
883
 
868
884
  ```python
869
885
  wai.send_score("4bf92f3577b34da6", 1.0) # this run passed
870
- wai.cuts(agent="my-agent") # what a cut would hold
886
+ summary = wai.cuts(agent="my-agent") # what a cut would hold
887
+ print(wai.format_cuts(summary, agent="my-agent")) # the traces page's sentence
871
888
  made = wai.cut(agent="my-agent", kind="rl") # make it
872
889
  wai.pull(made["train"]["datasetId"], "train.jsonl")
873
890
  made["holdout"]["datasetId"] # measure on this, never train on it
@@ -911,6 +928,13 @@ wai.style_markers(rows) # no_boilerplate, no_hedging, no_apology, no_sycophancy
911
928
  wai.style_report(rows)["warnings"] # "reward pays for hedging (corr +0.41 ...)"
912
929
  wai.refusal_report(benign_rows) # over-refusal rate with a Wilson interval
913
930
  wai.compare_runs(run_a, run_b) # paired delta with a 95% interval
931
+ wai.holdout_size(
932
+ 0.05, base=0.6, k=4
933
+ ) # tasks to prove a 5-point gain; n_tasks_concentrated beside it
934
+ wai.holdout_size(
935
+ 0.05, before=before, after=after
936
+ ) # the paired sd measured off a previous eval, no model
937
+ wai.holdout_size(0.05, task_std=0.38) # or the sd read off a delta_report interval
914
938
  wai.delta_report(before, after, target="pass_at_1", must_not_regress=["honest_after_fault"])
915
939
  wai.delta_report(before, after, target="pass_at_1", by="category") # the target per kind of prompt
916
940
  before = wai.simulate(agent, tools=TOOLS, tasks=base, runs=3) # the same eval three times
@@ -943,7 +967,9 @@ rerun = wai.simulate(
943
967
  assert base.rollouts_per_request == rerun.rollouts_per_request # cheap guard
944
968
  ```
945
969
 
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.
970
+ **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.
971
+
972
+ **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
973
 
948
974
  **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
975
 
@@ -1056,6 +1082,42 @@ run.holdout(before=0.42, after=0.58) # did it work? the run page opens with thi
1056
1082
 
1057
1083
  A run's page opens with one word — **Better**, **Worse**, **About the same** — over the held-out pass rate before and after. The platform's trainer measures it; a run on your own hardware says it with `run.holdout(before, after)`, or `wai.attach_holdout(run_id, before=..., after=...)` once the run has finished. Pass rates are 0 to 1, so 58% is `0.58`; `metric="loss"` sends held-out loss instead (SFT), where lower is better. `run.delta(...)` and `wai.attach_delta(...)` already measure both sides, so they fill the two numbers in themselves, and add `summary["holdout"]` (also `run.holdout_summary`): each side's pass rate with `n_tasks`, `k` and a `ci95`, plus the delta report's verdict word (`moved`, `moved_unreplicated`, `within_eval_noise`, `no_change_detected`). A hosted run read back with `run.refresh()` has the same block with the interval fields `None` and a note that the platform only returned two numbers.
1058
1084
 
1085
+ ### Report a run so a person can decide
1086
+
1087
+ The platform draws one screen per tracked agent at [while.ai/platform/runs](https://while.ai/platform/runs): the held-out score by version with the frontier model as the line to beat, the training curve, what moved on the behaviors you did not train, the judge checks, live traffic on the served version, and cost. A coding agent fills it with `whileai.platform`; the person reads it and presses Promote. Your agent framework stays yours: `track` takes the agent object you already have (OpenAI Agents SDK, Pydantic AI, LangGraph, Claude Agent SDK) and reads the model, the instructions and the tools off it, or you describe it by hand.
1088
+
1089
+ ```python
1090
+ from whileai.platform import Behavior, Frontier, Harness, Judge, track
1091
+
1092
+ tracked = track(
1093
+ "refund-bot", # or track(my_agent): name, model, prompt and tools come from the object
1094
+ model="Qwen/Qwen3-4B",
1095
+ harness=Harness(instructions=SYSTEM_PROMPT, tools=["lookup_order", "issue_refund"]),
1096
+ frontier=Frontier(name="Sonnet 5", score=81, cost_per_1k=18.0),
1097
+ )
1098
+ tracked.behavior(
1099
+ Behavior(
1100
+ name="refunds",
1101
+ test_version="v2",
1102
+ n=240,
1103
+ judge=Judge(agreement=0.86, human_n=60, length_bias=0.08),
1104
+ noise_floor=2.4,
1105
+ contamination=0,
1106
+ reward_is_judge=False,
1107
+ )
1108
+ )
1109
+
1110
+ run = tracked.run("v4", method="GRPO", targets=["refunds"], trained_on=["refunds-grpo"])
1111
+ run.log(10, reward=0.41, kl=0.01) # or trainer.add_callback(wai.TrainerCallback(run))
1112
+ run.score("refunds", 83, ci=2.7, n=240) # every behavior, not only the targets
1113
+ run.score("length", 76, ci=2.8, n=120)
1114
+ run.finish(hours=2.1, gpu="1xH100", cost_usd=31)
1115
+
1116
+ str(tracked.verdict()) # "refunds: v4 beats v3 by 5 (interval excludes zero); 1 regression"
1117
+ ```
1118
+
1119
+ Every object is a pydantic model that validates before it leaves the process, and each one's docstring names the rlhfbook.com chapter it comes from. A *harness* is the instructions, tools and model name around the weights; its fingerprint is its version, so a prompt edit shows up as a new version without anyone naming it (Evaluation: a score is only comparable with its setup held constant). A *behavior* has its own frozen held-out test (`test_version`), a `noise_floor` measured by scoring the same model twice, and a judge checked against people (`agreement` over `human_n`) and for `length_bias`. A *run* is scored on every behavior: `targets` are the claim, the rest are the check (Over-Optimization: verbosity, sycophancy and refusals are what moves when the reward is gamed). `ci` is the half-width of the 95% interval; the verdict is whether the candidate's and the served version's intervals overlap. `tracked.live(day, version=, replies=, flagged=)` reports a day of traffic when you serve the model yourself. Logging buffers and never raises into the training loop. Worked example: `recipes/04-train/report-run/`.
1120
+
1059
1121
  ### Is it hacking the reward right now?
1060
1122
 
1061
1123
  ```python
@@ -1155,6 +1217,8 @@ Measured at `avg_turns=4`. The default is now `12`, so a row carries more turns
1155
1217
  | `embedder` | `"hash"` | Prompt selection |
1156
1218
  | `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
1219
  | `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. |
1220
+ | `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 |
1221
+ | `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
1222
 
1159
1223
  Aliases: `phrasings=` / `n=` → `requests_per_situation`; `repeats=` → `rollouts_per_request`; `unique=` → `unique_situations`; `policy=` → `system_prompt`; `risk=` → `fault_rate`.
1160
1224
 
@@ -1162,7 +1226,7 @@ Aliases: `phrasings=` / `n=` → `requests_per_situation`; `repeats=` → `rollo
1162
1226
 
1163
1227
  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
1228
 
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.
1229
+ 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
1230
 
1167
1231
  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
1232
 
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "whileai"
7
- version = "0.63"
7
+ version = "0.65"
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"}
11
11
  requires-python = ">=3.10"
12
- dependencies = ["requests>=2.25.0"]
12
+ dependencies = ["requests>=2.25.0", "pydantic>=2.0"]
13
13
  authors = [{name = "While"}]
14
14
  keywords = [
15
15
  "zp", "zeroproof", "while", "evals", "agent-evals",
@@ -39,6 +39,13 @@ from whileai._env import getenv
39
39
  _DEFAULT_TRACE_URL = "https://api.zeroproofai.com"
40
40
  _GZIP_MAGIC = b"\x1f\x8b"
41
41
 
42
+ # The resource attribute that names the dataset. The gate reads
43
+ # `zeroproof.dataset` and nothing else, so sending only the whileai spelling
44
+ # lands every batch in a dataset called `traces` whatever you asked for, with
45
+ # a 202 that says so too late to notice. Both are written: the second costs
46
+ # one attribute and means the rename needs no release.
47
+ _DATASET_KEYS = ("zeroproof.dataset", "whileai.dataset")
48
+
42
49
 
43
50
  class WhileIngestError(Exception):
44
51
  """Raised when the gate rejects a trace batch."""
@@ -67,7 +74,7 @@ def otel_env(api_key: str, dataset: str = "traces", base_url: str | None = None)
67
74
  "OTEL_EXPORTER_OTLP_HEADERS": "x-api-key=" + api_key,
68
75
  "OTEL_EXPORTER_OTLP_PROTOCOL": "http/json",
69
76
  # Resource attribute the gate reads to name the dataset.
70
- "OTEL_RESOURCE_ATTRIBUTES": "whileai.dataset=" + dataset,
77
+ "OTEL_RESOURCE_ATTRIBUTES": ",".join(k + "=" + dataset for k in _DATASET_KEYS),
71
78
  }
72
79
 
73
80
 
@@ -133,9 +140,9 @@ def ingest_traces(
133
140
  Push a local OTLP batch file end to end and return ``{datasetId, dataset,
134
141
  rows}``.
135
142
 
136
- ``dataset`` overrides the dataset name by setting the
137
- ``whileai.dataset`` resource attribute on every resourceSpan, which
138
- requires reading the batch; leave it unset to send the bytes untouched.
143
+ ``dataset`` overrides the dataset name by setting the dataset resource
144
+ attribute on every resourceSpan, which requires reading the batch; leave
145
+ it unset to send the bytes untouched.
139
146
  """
140
147
  with open(file, "rb") as fh:
141
148
  body = fh.read()
@@ -148,9 +155,9 @@ def ingest_traces(
148
155
  for resource_span in batch.get("resourceSpans", []):
149
156
  resource = resource_span.setdefault("resource", {})
150
157
  attributes = [
151
- a for a in resource.get("attributes", []) if a.get("key") != "whileai.dataset"
158
+ a for a in resource.get("attributes", []) if a.get("key") not in _DATASET_KEYS
152
159
  ]
153
- attributes.append({"key": "whileai.dataset", "value": {"stringValue": dataset}})
160
+ attributes += [{"key": k, "value": {"stringValue": dataset}} for k in _DATASET_KEYS]
154
161
  resource["attributes"] = attributes
155
162
  body = json.dumps(batch).encode("utf-8")
156
163