whileai 0.52__tar.gz → 0.54__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 (88) hide show
  1. {whileai-0.52/whileai.egg-info → whileai-0.54}/PKG-INFO +8 -8
  2. {whileai-0.52 → whileai-0.54}/README.md +7 -7
  3. {whileai-0.52 → whileai-0.54}/pyproject.toml +1 -1
  4. {whileai-0.52 → whileai-0.54}/whileai/simulations/__init__.py +4 -3
  5. {whileai-0.52 → whileai-0.54}/whileai/simulations/data.py +8 -0
  6. {whileai-0.52 → whileai-0.54}/whileai/simulations/environment.py +4 -4
  7. {whileai-0.52 → whileai-0.54}/whileai/simulations/export.py +39 -3
  8. {whileai-0.52 → whileai-0.54}/whileai/simulations/run/engine.py +27 -0
  9. {whileai-0.52 → whileai-0.54}/whileai/simulations/run/spec.py +2 -2
  10. {whileai-0.52 → whileai-0.54}/whileai/simulations/schema.py +26 -2
  11. {whileai-0.52 → whileai-0.54}/whileai/simulations/schemas/row-v1.json +1 -0
  12. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/delta.py +7 -0
  13. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/judging.py +10 -2
  14. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/passat.py +31 -1
  15. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/privileged.py +23 -2
  16. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/rubric.py +28 -2
  17. {whileai-0.52 → whileai-0.54}/whileai/simulations/training.py +19 -2
  18. {whileai-0.52 → whileai-0.54/whileai.egg-info}/PKG-INFO +8 -8
  19. {whileai-0.52 → whileai-0.54}/LICENSE +0 -0
  20. {whileai-0.52 → whileai-0.54}/MANIFEST.in +0 -0
  21. {whileai-0.52 → whileai-0.54}/setup.cfg +0 -0
  22. {whileai-0.52 → whileai-0.54}/whileai/__init__.py +0 -0
  23. {whileai-0.52 → whileai-0.54}/whileai/_env.py +0 -0
  24. {whileai-0.52 → whileai-0.54}/whileai/auth.py +0 -0
  25. {whileai-0.52 → whileai-0.54}/whileai/cli.py +0 -0
  26. {whileai-0.52 → whileai-0.54}/whileai/ingest.py +0 -0
  27. {whileai-0.52 → whileai-0.54}/whileai/py.typed +0 -0
  28. {whileai-0.52 → whileai-0.54}/whileai/simulations/__main__.py +0 -0
  29. {whileai-0.52 → whileai-0.54}/whileai/simulations/generate/__init__.py +0 -0
  30. {whileai-0.52 → whileai-0.54}/whileai/simulations/generate/actionspace.py +0 -0
  31. {whileai-0.52 → whileai-0.54}/whileai/simulations/generate/adapters.py +0 -0
  32. {whileai-0.52 → whileai-0.54}/whileai/simulations/generate/agents.py +0 -0
  33. {whileai-0.52 → whileai-0.54}/whileai/simulations/generate/coverage.py +0 -0
  34. {whileai-0.52 → whileai-0.54}/whileai/simulations/generate/diversity.py +0 -0
  35. {whileai-0.52 → whileai-0.54}/whileai/simulations/generate/embeddings.py +0 -0
  36. {whileai-0.52 → whileai-0.54}/whileai/simulations/generate/explore.py +0 -0
  37. {whileai-0.52 → whileai-0.54}/whileai/simulations/generate/generator.py +0 -0
  38. {whileai-0.52 → whileai-0.54}/whileai/simulations/generate/offline_agent.py +0 -0
  39. {whileai-0.52 → whileai-0.54}/whileai/simulations/generate/scenarios.py +0 -0
  40. {whileai-0.52 → whileai-0.54}/whileai/simulations/generate/usage_meter.py +0 -0
  41. {whileai-0.52 → whileai-0.54}/whileai/simulations/ingest/__init__.py +0 -0
  42. {whileai-0.52 → whileai-0.54}/whileai/simulations/ingest/otel.py +0 -0
  43. {whileai-0.52 → whileai-0.54}/whileai/simulations/ingest/platform.py +0 -0
  44. {whileai-0.52 → whileai-0.54}/whileai/simulations/ingest/traces.py +0 -0
  45. {whileai-0.52 → whileai-0.54}/whileai/simulations/monitor.py +0 -0
  46. {whileai-0.52 → whileai-0.54}/whileai/simulations/py.typed +0 -0
  47. {whileai-0.52 → whileai-0.54}/whileai/simulations/run/__init__.py +0 -0
  48. {whileai-0.52 → whileai-0.54}/whileai/simulations/run/config.py +0 -0
  49. {whileai-0.52 → whileai-0.54}/whileai/simulations/run/rows.py +0 -0
  50. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/__init__.py +0 -0
  51. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/agreement.py +0 -0
  52. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/checklist.py +0 -0
  53. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/curriculum.py +0 -0
  54. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/grade_llm.py +0 -0
  55. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/grading.py +0 -0
  56. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/grounding.py +0 -0
  57. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/hack_scan.py +0 -0
  58. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/hygiene.py +0 -0
  59. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/judge_trust.py +0 -0
  60. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/labels.py +0 -0
  61. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/llm_judge.py +0 -0
  62. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/logprobs.py +0 -0
  63. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/markers.py +0 -0
  64. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/optimize.py +0 -0
  65. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/pairwise.py +0 -0
  66. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/preflight.py +0 -0
  67. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/publish_gate.py +0 -0
  68. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/quality.py +0 -0
  69. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/reference.py +0 -0
  70. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/spec.py +0 -0
  71. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/stage.py +0 -0
  72. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/stats.py +0 -0
  73. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/style.py +0 -0
  74. {whileai-0.52 → whileai-0.54}/whileai/simulations/score/trace.py +0 -0
  75. {whileai-0.52 → whileai-0.54}/whileai/simulations/simulation.py +0 -0
  76. {whileai-0.52 → whileai-0.54}/whileai/simulations/verify/__init__.py +0 -0
  77. {whileai-0.52 → whileai-0.54}/whileai/simulations/verify/base.py +0 -0
  78. {whileai-0.52 → whileai-0.54}/whileai/simulations/verify/code.py +0 -0
  79. {whileai-0.52 → whileai-0.54}/whileai/simulations/verify/math.py +0 -0
  80. {whileai-0.52 → whileai-0.54}/whileai/simulations/verify/structured.py +0 -0
  81. {whileai-0.52 → whileai-0.54}/whileai/simulations/verify/text.py +0 -0
  82. {whileai-0.52 → whileai-0.54}/whileai/simulations/world/__init__.py +0 -0
  83. {whileai-0.52 → whileai-0.54}/whileai/simulations/world/sandbox.py +0 -0
  84. {whileai-0.52 → whileai-0.54}/whileai.egg-info/SOURCES.txt +0 -0
  85. {whileai-0.52 → whileai-0.54}/whileai.egg-info/dependency_links.txt +0 -0
  86. {whileai-0.52 → whileai-0.54}/whileai.egg-info/entry_points.txt +0 -0
  87. {whileai-0.52 → whileai-0.54}/whileai.egg-info/requires.txt +0 -0
  88. {whileai-0.52 → whileai-0.54}/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.52
3
+ Version: 0.54
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
@@ -51,7 +51,7 @@ Two ways in, one engine. Give it the agent's tools and system prompt and it samp
51
51
 
52
52
  ![How a row gets made: the draw, the coverage grid, the search arms, the rollout, the split](docs/how-a-row-gets-made.svg)
53
53
 
54
- A situation is drawn across the world axes (from the agent's tools) and the human axes (from a separate writer). It fills a cell in the coverage grid, nudges the five search arms, and the agent plays it against a world that breaks on schedule. The row that comes out splits into `Task`, `Rollout`, `Judgment`, and `Marker`, and every training target is a projection of some of those four. The interactive version, running on real rows, is at [zeroproofai.com/docs/engine](https://zeroproofai.com/docs/engine).
54
+ A situation is drawn across the world axes (from the agent's tools) and the human axes (from a separate writer). It fills a cell in the coverage grid, nudges the five search arms, and the agent plays it against a world that breaks on schedule. The row that comes out splits into `Task`, `Rollout`, `Judgment`, and `Marker`, and every training target is a projection of some of those four. The engine on one page, with references: [docs/engine.md](docs/engine.md), also at [while.ai/docs/engine](https://while.ai/docs/engine).
55
55
 
56
56
  ## Overview
57
57
 
@@ -230,7 +230,7 @@ data.grade(rubric=RUBRIC) # 2 grade against the task rubric: reward 0/1 on ever
230
230
  print(data.pass_at)
231
231
  wai.judge_trust(data.trajectories) # 3 trust the numbers
232
232
  rows, report = wai.optimize(data, mode="rl") # 4 prune to what carries gradient
233
- entry = wai.push_rows(rows, "github-rl-v1", gate=True, mode="rl") # 5 publish, gated
233
+ entry = wai.push_rows(rows, "my-agent-rl-v1", gate=True, mode="rl") # 5 publish, gated
234
234
  ```
235
235
 
236
236
  `situations=200, repeats=8` is a guess. `wai.recommend(tools=TOOLS, system_prompt=POLICY, mode="rl")` replaces it with numbers from this agent's own grid: [How much to run](#how-much-to-run).
@@ -391,9 +391,9 @@ installable `verifiers` package, the shape Prime Intellect and TRL read.
391
391
  data = wai.simulate(my_agent, tools=TOOLS, system_prompt=POLICY, mode="rl", repeats=8)
392
392
  data.grade()
393
393
  # reward and world must import by name in the trainer: a module-level function or "module:attr"
394
- wai.export_environment(data, "envs/github-agent", reward=my_verifier)
395
- # pip install -e envs/github-agent
396
- # vf-eval github_agent -a '{"split": "holdout"}' -m <policy> -b <base url> -k <key var>
394
+ wai.export_environment(data, "envs/my-agent", reward=my_verifier)
395
+ # pip install -e envs/my-agent
396
+ # vf-eval my_agent -a '{"split": "holdout"}' -m <policy> -b <base url> -k <key var>
397
397
  ```
398
398
 
399
399
  The package holds `spec.json` (system prompt, the tool schemas verbatim,
@@ -681,10 +681,10 @@ login`.
681
681
  # export WHILEAI_DELEGATED_CREDENTIAL=credential["credential"]
682
682
 
683
683
  data = wai.simulate(my_agent, tools=TOOLS, system_prompt=POLICY)
684
- v1 = data.push("github-explore-v1") # -> {"datasetId": "ds_...", ...}
684
+ v1 = data.push("my-agent-explore-v1") # -> {"datasetId": "ds_...", ...}
685
685
 
686
686
  # iterate, then push the next version with lineage
687
- v2 = data.push("github-explore-v2", parent=v1["datasetId"])
687
+ v2 = data.push("my-agent-explore-v2", parent=v1["datasetId"])
688
688
 
689
689
  wai.datasets() # list yours + storage used
690
690
  rows = wai.pull(v1["datasetId"]) # rows, or pass path= for a file
@@ -15,7 +15,7 @@ Two ways in, one engine. Give it the agent's tools and system prompt and it samp
15
15
 
16
16
  ![How a row gets made: the draw, the coverage grid, the search arms, the rollout, the split](docs/how-a-row-gets-made.svg)
17
17
 
18
- A situation is drawn across the world axes (from the agent's tools) and the human axes (from a separate writer). It fills a cell in the coverage grid, nudges the five search arms, and the agent plays it against a world that breaks on schedule. The row that comes out splits into `Task`, `Rollout`, `Judgment`, and `Marker`, and every training target is a projection of some of those four. The interactive version, running on real rows, is at [zeroproofai.com/docs/engine](https://zeroproofai.com/docs/engine).
18
+ A situation is drawn across the world axes (from the agent's tools) and the human axes (from a separate writer). It fills a cell in the coverage grid, nudges the five search arms, and the agent plays it against a world that breaks on schedule. The row that comes out splits into `Task`, `Rollout`, `Judgment`, and `Marker`, and every training target is a projection of some of those four. The engine on one page, with references: [docs/engine.md](docs/engine.md), also at [while.ai/docs/engine](https://while.ai/docs/engine).
19
19
 
20
20
  ## Overview
21
21
 
@@ -194,7 +194,7 @@ data.grade(rubric=RUBRIC) # 2 grade against the task rubric: reward 0/1 on ever
194
194
  print(data.pass_at)
195
195
  wai.judge_trust(data.trajectories) # 3 trust the numbers
196
196
  rows, report = wai.optimize(data, mode="rl") # 4 prune to what carries gradient
197
- entry = wai.push_rows(rows, "github-rl-v1", gate=True, mode="rl") # 5 publish, gated
197
+ entry = wai.push_rows(rows, "my-agent-rl-v1", gate=True, mode="rl") # 5 publish, gated
198
198
  ```
199
199
 
200
200
  `situations=200, repeats=8` is a guess. `wai.recommend(tools=TOOLS, system_prompt=POLICY, mode="rl")` replaces it with numbers from this agent's own grid: [How much to run](#how-much-to-run).
@@ -355,9 +355,9 @@ installable `verifiers` package, the shape Prime Intellect and TRL read.
355
355
  data = wai.simulate(my_agent, tools=TOOLS, system_prompt=POLICY, mode="rl", repeats=8)
356
356
  data.grade()
357
357
  # reward and world must import by name in the trainer: a module-level function or "module:attr"
358
- wai.export_environment(data, "envs/github-agent", reward=my_verifier)
359
- # pip install -e envs/github-agent
360
- # vf-eval github_agent -a '{"split": "holdout"}' -m <policy> -b <base url> -k <key var>
358
+ wai.export_environment(data, "envs/my-agent", reward=my_verifier)
359
+ # pip install -e envs/my-agent
360
+ # vf-eval my_agent -a '{"split": "holdout"}' -m <policy> -b <base url> -k <key var>
361
361
  ```
362
362
 
363
363
  The package holds `spec.json` (system prompt, the tool schemas verbatim,
@@ -645,10 +645,10 @@ login`.
645
645
  # export WHILEAI_DELEGATED_CREDENTIAL=credential["credential"]
646
646
 
647
647
  data = wai.simulate(my_agent, tools=TOOLS, system_prompt=POLICY)
648
- v1 = data.push("github-explore-v1") # -> {"datasetId": "ds_...", ...}
648
+ v1 = data.push("my-agent-explore-v1") # -> {"datasetId": "ds_...", ...}
649
649
 
650
650
  # iterate, then push the next version with lineage
651
- v2 = data.push("github-explore-v2", parent=v1["datasetId"])
651
+ v2 = data.push("my-agent-explore-v2", parent=v1["datasetId"])
652
652
 
653
653
  wai.datasets() # list yours + storage used
654
654
  rows = wai.pull(v1["datasetId"]) # rows, or pass path= for a file
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "whileai"
7
- version = "0.52"
7
+ version = "0.54"
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"}
@@ -7,12 +7,13 @@ malicious), and rolls the agent. Grade 0/1 later. Optimize for post-training.
7
7
  Five calls, spec to gated dataset:
8
8
 
9
9
  import whileai.simulations as wai
10
- data = wai.simulate(agent="openai:gpt-4.1-mini", spec="specs/github",
11
- mode="rl", situations=200, repeats=8) # generate
10
+ data = wai.simulate(agent="openai:gpt-4.1-mini", tools=my_tools,
11
+ system_prompt=my_policy, mode="rl",
12
+ situations=200, repeats=8) # generate
12
13
  scored = data.grade(judge=my_judge) # grade 0/1
13
14
  print(scored.pass_at); wai.judge_trust(scored.rows, judge=my_judge) # trust
14
15
  rows, report = wai.optimize(scored, mode="rl") # prune
15
- wai.push_rows(rows, "github-rl-v1", gate=True, mode="rl") # publish, gated
16
+ wai.push_rows(rows, "my-agent-rl-v1", gate=True, mode="rl") # publish, gated
16
17
 
17
18
  Everything else exported here is one layer down from those five.
18
19
  """
@@ -652,6 +652,14 @@ class SimulationData:
652
652
  report["selection"] = self.search.get("selection")
653
653
  return report
654
654
 
655
+ def leak_report(self, *, min_len: int = 12) -> dict[str, Any]:
656
+ """Did any reply quote its own ``privileged`` block? Reads the
657
+ trajectories, which still carry the block; ``rows()`` is scrubbed
658
+ and would check nothing. Same report as ``leak_report``."""
659
+ from .score.privileged import leak_report
660
+
661
+ return leak_report(self.trajectories, min_len=min_len)
662
+
655
663
  @property
656
664
  def rows(self) -> RowList:
657
665
  """The exported rows: exactly what ``save()`` and ``output=`` write.
@@ -10,13 +10,13 @@ reward). ``export_environment`` writes those three as an installable
10
10
  ``verifiers`` package, the shape Prime Intellect and TRL consume::
11
11
 
12
12
  import whileai.simulations as wai
13
- data = wai.simulate(spec="specs/github", mode="rl", repeats=8)
13
+ data = wai.simulate(tools=my_tools, system_prompt=my_policy, mode="rl", repeats=8)
14
14
  scored = data.grade()
15
- wai.export_environment(scored, "envs/github-agent", reward=my_verifier)
15
+ wai.export_environment(scored, "envs/my-agent", reward=my_verifier)
16
16
 
17
17
  # then, with verifiers installed:
18
- # pip install -e envs/github-agent
19
- # vf-eval github_agent -a '{"split": "holdout"}' -m <policy> ...
18
+ # pip install -e envs/my-agent
19
+ # vf-eval my_agent -a '{"split": "holdout"}' -m <policy> ...
20
20
 
21
21
  What goes in the package:
22
22
 
@@ -50,6 +50,7 @@ from pathlib import Path
50
50
  from typing import Any
51
51
 
52
52
  from .schema import check, stamp
53
+ from .score.privileged import leak_report
53
54
  from .score.quality import load_jsonl, write_jsonl
54
55
  from .score.stats import task_key
55
56
 
@@ -601,8 +602,14 @@ def export_training(
601
602
 
602
603
  With a path source and no ``output``, writes ``<name>.train.jsonl``
603
604
  next to it. ``validate=True`` refuses to write a dataset whose tool
604
- calls do not round-trip to structured arguments; pass ``validate=False``
605
- to export anyway and read the report instead.
605
+ calls do not round-trip to structured arguments, or whose assistant
606
+ turns quote the row's own ``privileged`` block (the export scrubs the
607
+ key, not the reply that recited it); pass ``validate=False`` to export
608
+ anyway and read the report instead. The leak check reads the source
609
+ before the scrub, so pass the ``SimulationData`` or its
610
+ ``trajectories``; rows that already came through ``rows()``, ``save()``
611
+ or a file carry nothing to check, and ``report["privileged_leaks"]``
612
+ says so.
606
613
 
607
614
  ``format="openai"`` (the default) writes the OpenAI chat-completions
608
615
  wire row: the full ``messages`` list, ``function.arguments`` as a JSON
@@ -638,7 +645,20 @@ def export_training(
638
645
  "them teaches string-wrapped arguments. Fix the rows or pass "
639
646
  "validate=False."
640
647
  )
641
- _, _, _, src = _resolve(source)
648
+ raw, _, _, src = _resolve(source)
649
+ # The scrub drops the ``privileged`` key at any depth and copies the
650
+ # assistant's reply through verbatim, so a reply that recited the block
651
+ # still recites it in the training file. Check the unscrubbed side,
652
+ # which is the only place the needles still exist (#249).
653
+ leaks = leak_report(raw)
654
+ if validate and leaks["n_leaked"]:
655
+ raise ValueError(
656
+ f"privileged_leak: {leaks['n_leaked']} of {leaks['n_checked']} rows quote "
657
+ "their own privileged context (reference, principle or hidden state) in "
658
+ "an assistant turn; the export scrubs the key, not the reply, so training "
659
+ "on them teaches the model to say what only the grader was told. Drop "
660
+ "those rows (leak_report(...)['leaked'] names them) or pass validate=False."
661
+ )
642
662
  dest = output
643
663
  if not dest and src:
644
664
  path = Path(src)
@@ -659,6 +679,9 @@ def export_training(
659
679
  "tool_output_chars_cut": sum(int(r.get("tool_output_chars_cut") or 0) for r in rows),
660
680
  "trained_messages": sum(sum(r["loss_mask"]) for r in rows),
661
681
  "masked_messages": sum(len(r["loss_mask"]) - sum(r["loss_mask"]) for r in rows),
682
+ "privileged_leaks": {
683
+ k: leaks[k] for k in ("checked", "n_checked", "n_leaked", "leaked", "summary")
684
+ },
662
685
  }
663
686
  # SFT clones every row it is given. A failed rollout in the file
664
687
  # teaches the failure, so say how many there are instead of leaving
@@ -673,6 +696,19 @@ def export_training(
673
696
  "n_ungraded": len(rows) - n_pass - n_fail,
674
697
  }
675
698
  warnings: list[str] = []
699
+ if leaks["n_leaked"]:
700
+ warnings.append(
701
+ f"{leaks['n_leaked']} of {leaks['n_checked']} rows quote their own privileged "
702
+ "context in an assistant turn and are exported anyway (validate=False); "
703
+ "report['privileged_leaks']['leaked'] names them."
704
+ )
705
+ n_cut = sum(1 for r in raw if isinstance(r, dict) and r.get("finish_reason") == "length")
706
+ if n_cut:
707
+ warnings.append(
708
+ f"{n_cut} of {len(raw)} rows were cut by the reply token cap (finish_reason "
709
+ "'length') and are exported as SFT targets; a model trained on them learns to "
710
+ "stop mid-thought. Filter on finish_reason == 'stop' or raise agent_max_tokens=."
711
+ )
676
712
  if n_fail:
677
713
  warnings.append(
678
714
  f"{n_fail} of {len(rows)} rows have reward below 0.5 and are exported as "
@@ -160,6 +160,32 @@ def _agent_error_text(exc: BaseException) -> str:
160
160
  return f"<agent error: {type(exc).__name__}: {public_llm_error(exc)}>"
161
161
 
162
162
 
163
+ FINISH_REASONS = ("stop", "length", "tool", "error")
164
+
165
+
166
+ def _finish_reason(raw: dict, steps: list, final_text: str) -> str:
167
+ """Why the rollout ended, on the row where a trainer can read it.
168
+
169
+ ``length``: a turn was cut by the reply token cap (the backend said
170
+ so). ``error``: the agent raised. ``tool``: the last thing the agent
171
+ did was call a tool and no final reply followed, so the turn budget
172
+ ran out. ``stop``: the agent finished on its own. A callable agent may
173
+ say it outright with ``finish_reason`` in what it returns. A length
174
+ cut scored 0 teaches the cheapest fix, shorter thinking, before it
175
+ teaches the task (#253), so the trainer masks these by default.
176
+ """
177
+ told = raw.get("finish_reason")
178
+ if isinstance(told, str) and told in FINISH_REASONS:
179
+ return told
180
+ if final_text.startswith("<agent error:"):
181
+ return "error"
182
+ if any(isinstance(s, dict) and s.get("truncated") for s in steps):
183
+ return "length"
184
+ if not final_text.strip() and steps and isinstance(steps[-1], dict) and steps[-1].get("tool"):
185
+ return "tool"
186
+ return "stop"
187
+
188
+
163
189
  def _hit_length_cap(row: dict) -> bool:
164
190
  """A step the backend flagged as cut by its token cap, or a reply that
165
191
  ends mid-sentence by the hygiene rule."""
@@ -802,6 +828,7 @@ class Run:
802
828
  t["seeded"] = [str(x) for x in seeded]
803
829
  t.update(_row_conversation(meta, prompt, c.seed))
804
830
  t["behavior_signature"] = behavior_signature(t)
831
+ t["finish_reason"] = _finish_reason(raw, t["steps"], t["final_text"])
805
832
  # Sampling facts roll up from the agent turns: the summed logprob
806
833
  # and token count a trainer needs for an importance ratio or a KL.
807
834
  lp_steps = [
@@ -69,8 +69,8 @@ RUBRIC_FILES = ("rubric.md", "rubric.txt")
69
69
  def spec_rubric(spec: Any) -> str | None:
70
70
  """The rubric shipped with a spec: what doing the job means, in prose,
71
71
  for the judge. ``rubric.md`` (or ``.txt``) next to the spec file the
72
- loader resolves ``spec`` to (so ``spec="github"`` reads
73
- ``specs/github/rubric.md``, the same shorthand ``simulate`` accepts),
72
+ loader resolves ``spec`` to (so ``spec="my-agent"`` reads
73
+ ``specs/my-agent/rubric.md``, the same shorthand ``simulate`` accepts),
74
74
  or a ``rubric`` key in the spec dict. None when the spec carries none."""
75
75
  if isinstance(spec, dict):
76
76
  text = str(spec.get("rubric") or "").strip()
@@ -561,6 +561,9 @@ def attach(row: dict, judgment: Judgment) -> dict:
561
561
  meta = dict(judgment.evidence)
562
562
  if judgment.scorer.version:
563
563
  meta["version"] = judgment.scorer.version
564
+ if judgment.scorer.kind != "judge":
565
+ # A named scorer reads back as "judge" unless the row says otherwise.
566
+ meta["scorer_kind"] = judgment.scorer.kind
564
567
  if meta:
565
568
  row["judge_meta"] = meta
566
569
  return row
@@ -576,6 +579,19 @@ def _scorer_version(row: dict) -> str | None:
576
579
  return None
577
580
 
578
581
 
582
+ def _scorer_kind(row: dict) -> str | None:
583
+ """The kind the grading run stamped, if any (``judge_meta.scorer_kind``)."""
584
+ meta = row.get("judge_meta")
585
+ if isinstance(meta, dict) and meta.get("scorer_kind") in (
586
+ "rule",
587
+ "judge",
588
+ "reward_model",
589
+ "human",
590
+ ):
591
+ return str(meta["scorer_kind"])
592
+ return None
593
+
594
+
579
595
  def _judgments(row: dict, rollout_id: str) -> list[Judgment]:
580
596
  out: list[Judgment] = []
581
597
  has_primary = (
@@ -588,7 +604,11 @@ def _judgments(row: dict, rollout_id: str) -> list[Judgment]:
588
604
  judge = row.get("judge_name")
589
605
  label = row.get("label_source")
590
606
  name = judge or label or "unlabeled"
591
- kind: Any = "judge" if judge else "rule"
607
+ # The stamped kind wins; without one, a named judge is a model judge
608
+ # and a bare label is a rule. A Verifier run through ``run_judge``
609
+ # carries ``judge_name`` too, so the inference alone called every
610
+ # verifier a judge (#250).
611
+ kind: Any = _scorer_kind(row) or ("judge" if judge else "rule")
592
612
  reward = _number(row.get("reward"))
593
613
  status: Any = row.get("judge_status") or "ok"
594
614
  evidence: dict = {}
@@ -802,7 +822,11 @@ def to_row(
802
822
  if primary.reason:
803
823
  row["reason"] = primary.reason
804
824
  if primary.scorer.name != "unlabeled":
805
- if primary.scorer.kind == "judge":
825
+ # ``judge_name`` is what a grading run called itself, whatever
826
+ # its kind; a stamped kind proves a run named it. ``label_source``
827
+ # is the engine's own rule label.
828
+ stamped = (primary.evidence.get("judge_meta") or {}).get("scorer_kind")
829
+ if primary.scorer.kind != "rule" or stamped:
806
830
  row["judge_name"] = primary.scorer.name
807
831
  if primary.evidence.get("label_source"):
808
832
  row["label_source"] = primary.evidence["label_source"]
@@ -44,6 +44,7 @@
44
44
  "token_logprobs": {"type": "array", "items": {"type": "number"}, "$comment": "per-token log-probabilities in generation order across the agent's turns; simulate(logprobs=\"tokens\")"},
45
45
  "sampling": {"type": ["object", "null"], "properties": {"temperature": {"type": "number"}, "max_tokens": {"type": "integer"}, "model": {"type": "string"}}, "$comment": "how the row was sampled: a model backend records its resolved temperature, reply budget and model; a callable agent records simulate(sampling=) verbatim, else null"},
46
46
  "policy_version": {"type": "string", "$comment": "Rollout.policy.version: <model_version>@<sha256 of the system policy>[:16]"},
47
+ "finish_reason": {"type": "string", "enum": ["stop", "length", "tool", "error"], "$comment": "why the rollout ended: on its own, cut by the reply token cap, turn budget ran out on a tool call, or the agent raised"},
47
48
  "writer_model": {"type": "string", "$comment": "who wrote the prompt: the situation writer's model tag, or template / seed / pinned when no model wrote it"},
48
49
  "user_model": {"type": "string", "$comment": "who played the simulated user; absent when the agent took a single message"},
49
50
  "calibration": {
@@ -370,6 +370,13 @@ def delta_report(
370
370
  f"Before allowed {cfg_a['max_tokens']} reply tokens and after {cfg_b['max_tokens']}; "
371
371
  "re-run one side so both use the same agent_max_tokens=."
372
372
  )
373
+ if _both("truncated_share") and abs(cfg_a["truncated_share"] - cfg_b["truncated_share"]) > 0.05:
374
+ warnings.append(
375
+ f"The token cap cut {cfg_a['truncated_share']:.0%} of before rows and "
376
+ f"{cfg_b['truncated_share']:.0%} of after rows; a side that is cut more often is "
377
+ "not the same eval. Raise agent_max_tokens= on both sides or read the delta with "
378
+ "that in mind."
379
+ )
373
380
  if _both("policy_version") and cfg_a["policy_version"] == cfg_b["policy_version"]:
374
381
  warnings.append(
375
382
  "Before and after are the same policy version; this compares a model to itself."
@@ -402,6 +402,11 @@ def run_judge(
402
402
  name = judge_name or getattr(judge, "__name__", "") or _instance_name(judge) or "judge"
403
403
  if name == "<lambda>":
404
404
  name = "lambda_judge"
405
+ # A Verifier says what it is (``kind="rule"``); a function judge does
406
+ # not, and the schema then infers "judge" from the name. Stamp the
407
+ # declared kind so a verifier does not read back as a model judge (#250).
408
+ kind = getattr(judge, "kind", None)
409
+ scorer_kind = kind if kind in ("rule", "reward_model", "human") else None
405
410
  verdicts: list[dict[str, Any]]
406
411
  if concurrency > 1 and len(src_rows) > 1:
407
412
  with concurrent.futures.ThreadPoolExecutor(max_workers=concurrency) as pool:
@@ -429,8 +434,11 @@ def run_judge(
429
434
  out["reason"] = verdict["reason"]
430
435
  out["judge_status"] = verdict["judge_status"]
431
436
  out["judge_name"] = name
432
- if verdict["judge_meta"]:
433
- out["judge_meta"] = verdict["judge_meta"]
437
+ meta = dict(verdict["judge_meta"] or {})
438
+ if scorer_kind:
439
+ meta["scorer_kind"] = scorer_kind
440
+ if meta:
441
+ out["judge_meta"] = meta
434
442
  fc = (verdict["judge_meta"] or {}).get("failure_class")
435
443
  if fc:
436
444
  out["failure_class"] = str(fc)
@@ -114,6 +114,14 @@ def run_config(
114
114
  mixed.append(key)
115
115
  out[key] = next(iter(values)) if len(values) == 1 else None
116
116
  out["mixed"] = mixed
117
+ # The share of rows the token cap cut. A side that was cut more often
118
+ # is not the same eval; ``delta_report`` warns when the two differ.
119
+ # ``None`` when no row says how it finished (rows from before 0.54).
120
+ reasons = [r.get("finish_reason") for r in rows if isinstance(r, dict)]
121
+ known = [x for x in reasons if isinstance(x, str)]
122
+ out["truncated_share"] = (
123
+ round(sum(1 for x in known if x == "length") / len(known), 4) if known else None
124
+ )
117
125
  return out
118
126
 
119
127
 
@@ -190,11 +198,33 @@ class PassAt:
190
198
  f"headroom {fmt(self.headroom)}"
191
199
  )
192
200
  tail = f"({self.n_groups} groups, k={self.k}"
201
+ cut = self.config.get("truncated_share") if self.config else None
202
+ if cut:
203
+ tail += f"; {cut:.0%} of rows cut by the token cap"
193
204
  if self.note:
194
205
  tail += f"; {self.note}"
195
206
  return f"{head} {tail})"
196
207
 
197
208
 
209
+ def _nothing_to_score(rows: Sequence[dict]) -> str:
210
+ """Why no row carried a binary reward.
211
+
212
+ "grade first" is right when nothing has been judged, and wrong -- it
213
+ sends the user back to the step that already ran -- when grading did
214
+ happen and every row failed. A cold hosted judge does exactly that: all
215
+ the concurrent calls time out together and the whole set reads as
216
+ ungraded.
217
+ """
218
+ judged = [r for r in rows if isinstance(r, dict) and r.get("judge_status")]
219
+ failed = [r for r in judged if str(r.get("judge_status")) != "ok"]
220
+ if not judged or len(failed) != len(judged):
221
+ return "no binary rewards; grade first"
222
+ statuses = "/".join(sorted({str(r.get("judge_status")) for r in failed}))
223
+ reason = next((str(r.get("reason") or "").strip() for r in failed if r.get("reason")), "")
224
+ tail = f": {reason[:120]}" if reason else ""
225
+ return f"the judge failed on all {len(failed)} rows ({statuses}){tail}; re-run the judge"
226
+
227
+
198
228
  def pass_at(
199
229
  rows: Sequence[dict] | Any,
200
230
  *,
@@ -249,7 +279,7 @@ def pass_at(
249
279
  pass_at_k=None,
250
280
  n_groups=0,
251
281
  n_rows=0,
252
- note="no binary rewards; grade first",
282
+ note=_nothing_to_score(row_list),
253
283
  config=run_config(row_list, n_tasks=0, k=int(k or 1)),
254
284
  )
255
285
 
@@ -15,7 +15,6 @@ it is vacuous. The report says so instead of passing.
15
15
  from __future__ import annotations
16
16
 
17
17
  import re
18
- from collections.abc import Sequence
19
18
  from typing import Any
20
19
 
21
20
  from .style import assistant_text
@@ -52,9 +51,14 @@ def _needles(privileged: Any, *, min_len: int) -> list[tuple[str, str]]:
52
51
  return out
53
52
 
54
53
 
55
- def leak_report(rows: Sequence[dict], *, min_len: int = 12) -> dict[str, Any]:
54
+ def leak_report(rows: Any, *, min_len: int = 12) -> dict[str, Any]:
56
55
  """Which rows quote their own ``privileged`` block in the agent's text.
57
56
 
57
+ Takes the ``SimulationData`` itself, ``data.trajectories``, or any list
58
+ of rows. Given the data object it reads the trajectories, which still
59
+ carry the block; ``data.rows()`` is the scrubbed export and checks
60
+ nothing (the report says so).
61
+
58
62
  Checks every row that carries ``privileged`` (``reference``,
59
63
  ``principle``, and every string in ``hidden_state`` at least
60
64
  ``min_len`` characters long) against the final reply and every
@@ -63,9 +67,17 @@ def leak_report(rows: Sequence[dict], *, min_len: int = 12) -> dict[str, Any]:
63
67
  the block, so the result is vacuous), ``leaked`` (up to 20 rows:
64
68
  ``scenario_id``, ``rollout_index``, ``field``, ``needle``) and
65
69
  ``summary``. Does not mutate ``rows``.
70
+
71
+ Pass ``data.trajectories``, not ``data.rows()``: the export scrubs
72
+ ``privileged`` at any depth, so exported rows carry nothing to check
73
+ and the report is vacuous. When it can tell the rows came through the
74
+ export, ``summary`` says so and names the accessor to use instead.
66
75
  """
76
+ if hasattr(rows, "trajectories") and not isinstance(rows, (list, tuple)):
77
+ rows = rows.trajectories
67
78
  n_rows = 0
68
79
  n_checked = 0
80
+ exported = False
69
81
  leaked: list[dict[str, Any]] = []
70
82
  for row in rows:
71
83
  if not isinstance(row, dict):
@@ -73,6 +85,10 @@ def leak_report(rows: Sequence[dict], *, min_len: int = 12) -> dict[str, Any]:
73
85
  n_rows += 1
74
86
  needles = _needles(row.get("privileged"), min_len=min_len)
75
87
  if not needles:
88
+ # ``export_row`` always writes ``scenario_id`` (``""`` when the
89
+ # row has none), so a row that has the key but no privileged
90
+ # block was scrubbed on the way out rather than never filled.
91
+ exported = exported or "scenario_id" in row
76
92
  continue
77
93
  n_checked += 1
78
94
  hay = _norm(assistant_text(row))
@@ -94,6 +110,11 @@ def leak_report(rows: Sequence[dict], *, min_len: int = 12) -> dict[str, Any]:
94
110
  f"checked 0 of {n_rows} rows: none carried privileged context, "
95
111
  "so this says nothing about leaks"
96
112
  )
113
+ if exported:
114
+ summary += (
115
+ ". These came through the export (rows(), save(), push()), which scrubs "
116
+ "privileged at any depth -- pass data.trajectories, which keeps it"
117
+ )
97
118
  elif not n_leaked:
98
119
  summary = f"checked {n_checked} of {n_rows} rows: no reply quoted its privileged context"
99
120
  else:
@@ -26,12 +26,19 @@ import concurrent.futures
26
26
  import hashlib
27
27
  import json
28
28
  import re
29
+ import threading
29
30
  from collections.abc import Callable, Mapping, Sequence
30
31
  from dataclasses import dataclass, field
31
32
  from typing import Any, Literal
32
33
 
33
34
  from ..generate.agents import complete, parse_backend_spec
34
- from .grade_llm import JUDGE_TEMPERATURE, _render_payload, judge_spec, judge_version
35
+ from .grade_llm import (
36
+ JUDGE_TEMPERATURE,
37
+ _render_payload,
38
+ judge_spec,
39
+ judge_version,
40
+ warm_judge,
41
+ )
35
42
 
36
43
  Kind = Literal["hard", "principle", "pitfall"]
37
44
  KINDS: tuple[str, ...] = ("hard", "principle", "pitfall")
@@ -375,10 +382,28 @@ def rubric_judge(
375
382
  ``markers`` (``rubric:<slug>`` = 1.0 met / 0.0 not, and for a pitfall
376
383
  1.0 clean / 0.0 exhibited), ``criteria`` (the raw verdicts),
377
384
  ``rubric_version`` and the score breakdown. The judge's name folds the
378
- rubric version in when one is fixed."""
385
+ rubric version in when one is fixed.
386
+
387
+ The hosted judge scales to zero, so the first row through warms it once
388
+ (``warm_judge``, a 600s budget) while the rest of the fan-out waits.
389
+ Without that, ``run_judge``'s eight concurrent calls all raced a
390
+ container that was still loading its weights and every row came back
391
+ ``invalid_result`` with a ``TimeoutError``. Warm-up failure is not
392
+ fatal: the rows are judged anyway and report the real error."""
379
393
  resolved = judge_spec(spec=spec)
380
394
  url, model = parse_backend_spec(resolved)
381
395
  system = str(prompt or "").strip() or RUBRIC_JUDGE_SYSTEM
396
+ warm_lock = threading.Lock()
397
+ warmed: list[dict] = []
398
+
399
+ def ensure_warm() -> None:
400
+ # once per judge, and the other workers block here rather than
401
+ # opening their own request against a cold server
402
+ if warmed:
403
+ return
404
+ with warm_lock:
405
+ if not warmed:
406
+ warmed.append(warm_judge(resolved, api_key=api_key))
382
407
 
383
408
  def judge(row: dict) -> dict[str, Any]:
384
409
  use = rubric or rubric_of(row)
@@ -391,6 +416,7 @@ def rubric_judge(
391
416
  },
392
417
  default=str,
393
418
  )
419
+ ensure_warm()
394
420
  try:
395
421
  reply = complete(
396
422
  url,
@@ -558,6 +558,10 @@ def _measured_temperature(
558
558
  return None
559
559
 
560
560
 
561
+ #: what GRPO does with a sampled reply the token cap cut
562
+ TRUNCATED = ("mask", "zero")
563
+
564
+
561
565
  def train(
562
566
  dataset: str,
563
567
  *,
@@ -573,6 +577,7 @@ def train(
573
577
  max_completion_length: int | None = None,
574
578
  loss_type: str | None = None,
575
579
  temperature: float | None = None,
580
+ truncated: str | None = None,
576
581
  config: Mapping[str, Any] | None = None,
577
582
  wait: bool = False,
578
583
  timeout: float | None = None,
@@ -612,10 +617,15 @@ def train(
612
617
  sampling temperature the trainer rolls out at (GRPO); the dataset's
613
618
  rows say what they were measured at under ``sampling.temperature``,
614
619
  and ``train`` says so when the two differ, since a before/after
615
- comparison across temperatures is not like for like. Each has a
620
+ comparison across temperatures is not like for like. ``truncated``
621
+ says what GRPO does with a sampled reply the token cap cut:
622
+ ``"mask"`` (the default) gives it no gradient, ``"zero"`` scores it 0
623
+ the old way. A cut reply scored 0 teaches shorter thinking before it
624
+ teaches the task, so ``"zero"`` is the knob to reach for only when the
625
+ cap itself is the behavior under training (#253). Each has a
616
626
  trainer default when left ``None``. ``config`` passes further host
617
627
  keys as given
618
- (``epsilonHigh``, ``scaleRewards``, ``maskTruncated``, ``balance``).
628
+ (``epsilonHigh``, ``scaleRewards``, ``balance``).
619
629
  Every knob lands on the run's ``config`` so the run page shows it.
620
630
 
621
631
  A dataset already training answers with that run instead of a second.
@@ -682,6 +692,13 @@ def train(
682
692
  if not 0 < float(temperature) <= 2:
683
693
  raise ValueError("temperature: above 0 and at most 2")
684
694
  body["temperature"] = float(temperature)
695
+ if truncated is not None:
696
+ if method != "grpo":
697
+ raise ValueError("truncated= says what GRPO does with a token-capped reply; grpo only")
698
+ if truncated not in TRUNCATED:
699
+ raise ValueError(f"truncated must be one of {', '.join(TRUNCATED)}; got {truncated!r}")
700
+ if method == "grpo":
701
+ body["maskTruncated"] = (truncated or "mask") == "mask"
685
702
  for key, value in dict(config or {}).items():
686
703
  if key in body:
687
704
  raise ValueError(f"config[{key!r}] collides with a named argument")
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: whileai
3
- Version: 0.52
3
+ Version: 0.54
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
@@ -51,7 +51,7 @@ Two ways in, one engine. Give it the agent's tools and system prompt and it samp
51
51
 
52
52
  ![How a row gets made: the draw, the coverage grid, the search arms, the rollout, the split](docs/how-a-row-gets-made.svg)
53
53
 
54
- A situation is drawn across the world axes (from the agent's tools) and the human axes (from a separate writer). It fills a cell in the coverage grid, nudges the five search arms, and the agent plays it against a world that breaks on schedule. The row that comes out splits into `Task`, `Rollout`, `Judgment`, and `Marker`, and every training target is a projection of some of those four. The interactive version, running on real rows, is at [zeroproofai.com/docs/engine](https://zeroproofai.com/docs/engine).
54
+ A situation is drawn across the world axes (from the agent's tools) and the human axes (from a separate writer). It fills a cell in the coverage grid, nudges the five search arms, and the agent plays it against a world that breaks on schedule. The row that comes out splits into `Task`, `Rollout`, `Judgment`, and `Marker`, and every training target is a projection of some of those four. The engine on one page, with references: [docs/engine.md](docs/engine.md), also at [while.ai/docs/engine](https://while.ai/docs/engine).
55
55
 
56
56
  ## Overview
57
57
 
@@ -230,7 +230,7 @@ data.grade(rubric=RUBRIC) # 2 grade against the task rubric: reward 0/1 on ever
230
230
  print(data.pass_at)
231
231
  wai.judge_trust(data.trajectories) # 3 trust the numbers
232
232
  rows, report = wai.optimize(data, mode="rl") # 4 prune to what carries gradient
233
- entry = wai.push_rows(rows, "github-rl-v1", gate=True, mode="rl") # 5 publish, gated
233
+ entry = wai.push_rows(rows, "my-agent-rl-v1", gate=True, mode="rl") # 5 publish, gated
234
234
  ```
235
235
 
236
236
  `situations=200, repeats=8` is a guess. `wai.recommend(tools=TOOLS, system_prompt=POLICY, mode="rl")` replaces it with numbers from this agent's own grid: [How much to run](#how-much-to-run).
@@ -391,9 +391,9 @@ installable `verifiers` package, the shape Prime Intellect and TRL read.
391
391
  data = wai.simulate(my_agent, tools=TOOLS, system_prompt=POLICY, mode="rl", repeats=8)
392
392
  data.grade()
393
393
  # reward and world must import by name in the trainer: a module-level function or "module:attr"
394
- wai.export_environment(data, "envs/github-agent", reward=my_verifier)
395
- # pip install -e envs/github-agent
396
- # vf-eval github_agent -a '{"split": "holdout"}' -m <policy> -b <base url> -k <key var>
394
+ wai.export_environment(data, "envs/my-agent", reward=my_verifier)
395
+ # pip install -e envs/my-agent
396
+ # vf-eval my_agent -a '{"split": "holdout"}' -m <policy> -b <base url> -k <key var>
397
397
  ```
398
398
 
399
399
  The package holds `spec.json` (system prompt, the tool schemas verbatim,
@@ -681,10 +681,10 @@ login`.
681
681
  # export WHILEAI_DELEGATED_CREDENTIAL=credential["credential"]
682
682
 
683
683
  data = wai.simulate(my_agent, tools=TOOLS, system_prompt=POLICY)
684
- v1 = data.push("github-explore-v1") # -> {"datasetId": "ds_...", ...}
684
+ v1 = data.push("my-agent-explore-v1") # -> {"datasetId": "ds_...", ...}
685
685
 
686
686
  # iterate, then push the next version with lineage
687
- v2 = data.push("github-explore-v2", parent=v1["datasetId"])
687
+ v2 = data.push("my-agent-explore-v2", parent=v1["datasetId"])
688
688
 
689
689
  wai.datasets() # list yours + storage used
690
690
  rows = wai.pull(v1["datasetId"]) # rows, or pass path= for a file
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes