whileai 0.54__tar.gz → 0.56__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.54/whileai.egg-info → whileai-0.56}/PKG-INFO +1 -1
  2. {whileai-0.54 → whileai-0.56}/pyproject.toml +1 -1
  3. {whileai-0.54 → whileai-0.56}/whileai/simulations/__init__.py +10 -0
  4. {whileai-0.54 → whileai-0.56}/whileai/simulations/export.py +7 -0
  5. {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/agents.py +34 -2
  6. {whileai-0.54 → whileai-0.56}/whileai/simulations/ingest/platform.py +33 -0
  7. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/delta.py +37 -1
  8. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/grading.py +11 -2
  9. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/judging.py +42 -0
  10. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/optimize.py +127 -0
  11. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/stats.py +110 -0
  12. {whileai-0.54 → whileai-0.56}/whileai/simulations/training.py +58 -6
  13. {whileai-0.54 → whileai-0.56/whileai.egg-info}/PKG-INFO +1 -1
  14. {whileai-0.54 → whileai-0.56}/LICENSE +0 -0
  15. {whileai-0.54 → whileai-0.56}/MANIFEST.in +0 -0
  16. {whileai-0.54 → whileai-0.56}/README.md +0 -0
  17. {whileai-0.54 → whileai-0.56}/setup.cfg +0 -0
  18. {whileai-0.54 → whileai-0.56}/whileai/__init__.py +0 -0
  19. {whileai-0.54 → whileai-0.56}/whileai/_env.py +0 -0
  20. {whileai-0.54 → whileai-0.56}/whileai/auth.py +0 -0
  21. {whileai-0.54 → whileai-0.56}/whileai/cli.py +0 -0
  22. {whileai-0.54 → whileai-0.56}/whileai/ingest.py +0 -0
  23. {whileai-0.54 → whileai-0.56}/whileai/py.typed +0 -0
  24. {whileai-0.54 → whileai-0.56}/whileai/simulations/__main__.py +0 -0
  25. {whileai-0.54 → whileai-0.56}/whileai/simulations/data.py +0 -0
  26. {whileai-0.54 → whileai-0.56}/whileai/simulations/environment.py +0 -0
  27. {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/__init__.py +0 -0
  28. {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/actionspace.py +0 -0
  29. {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/adapters.py +0 -0
  30. {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/coverage.py +0 -0
  31. {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/diversity.py +0 -0
  32. {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/embeddings.py +0 -0
  33. {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/explore.py +0 -0
  34. {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/generator.py +0 -0
  35. {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/offline_agent.py +0 -0
  36. {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/scenarios.py +0 -0
  37. {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/usage_meter.py +0 -0
  38. {whileai-0.54 → whileai-0.56}/whileai/simulations/ingest/__init__.py +0 -0
  39. {whileai-0.54 → whileai-0.56}/whileai/simulations/ingest/otel.py +0 -0
  40. {whileai-0.54 → whileai-0.56}/whileai/simulations/ingest/traces.py +0 -0
  41. {whileai-0.54 → whileai-0.56}/whileai/simulations/monitor.py +0 -0
  42. {whileai-0.54 → whileai-0.56}/whileai/simulations/py.typed +0 -0
  43. {whileai-0.54 → whileai-0.56}/whileai/simulations/run/__init__.py +0 -0
  44. {whileai-0.54 → whileai-0.56}/whileai/simulations/run/config.py +0 -0
  45. {whileai-0.54 → whileai-0.56}/whileai/simulations/run/engine.py +0 -0
  46. {whileai-0.54 → whileai-0.56}/whileai/simulations/run/rows.py +0 -0
  47. {whileai-0.54 → whileai-0.56}/whileai/simulations/run/spec.py +0 -0
  48. {whileai-0.54 → whileai-0.56}/whileai/simulations/schema.py +0 -0
  49. {whileai-0.54 → whileai-0.56}/whileai/simulations/schemas/row-v1.json +0 -0
  50. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/__init__.py +0 -0
  51. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/agreement.py +0 -0
  52. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/checklist.py +0 -0
  53. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/curriculum.py +0 -0
  54. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/grade_llm.py +0 -0
  55. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/grounding.py +0 -0
  56. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/hack_scan.py +0 -0
  57. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/hygiene.py +0 -0
  58. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/judge_trust.py +0 -0
  59. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/labels.py +0 -0
  60. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/llm_judge.py +0 -0
  61. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/logprobs.py +0 -0
  62. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/markers.py +0 -0
  63. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/pairwise.py +0 -0
  64. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/passat.py +0 -0
  65. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/preflight.py +0 -0
  66. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/privileged.py +0 -0
  67. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/publish_gate.py +0 -0
  68. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/quality.py +0 -0
  69. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/reference.py +0 -0
  70. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/rubric.py +0 -0
  71. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/spec.py +0 -0
  72. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/stage.py +0 -0
  73. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/style.py +0 -0
  74. {whileai-0.54 → whileai-0.56}/whileai/simulations/score/trace.py +0 -0
  75. {whileai-0.54 → whileai-0.56}/whileai/simulations/simulation.py +0 -0
  76. {whileai-0.54 → whileai-0.56}/whileai/simulations/verify/__init__.py +0 -0
  77. {whileai-0.54 → whileai-0.56}/whileai/simulations/verify/base.py +0 -0
  78. {whileai-0.54 → whileai-0.56}/whileai/simulations/verify/code.py +0 -0
  79. {whileai-0.54 → whileai-0.56}/whileai/simulations/verify/math.py +0 -0
  80. {whileai-0.54 → whileai-0.56}/whileai/simulations/verify/structured.py +0 -0
  81. {whileai-0.54 → whileai-0.56}/whileai/simulations/verify/text.py +0 -0
  82. {whileai-0.54 → whileai-0.56}/whileai/simulations/world/__init__.py +0 -0
  83. {whileai-0.54 → whileai-0.56}/whileai/simulations/world/sandbox.py +0 -0
  84. {whileai-0.54 → whileai-0.56}/whileai.egg-info/SOURCES.txt +0 -0
  85. {whileai-0.54 → whileai-0.56}/whileai.egg-info/dependency_links.txt +0 -0
  86. {whileai-0.54 → whileai-0.56}/whileai.egg-info/entry_points.txt +0 -0
  87. {whileai-0.54 → whileai-0.56}/whileai.egg-info/requires.txt +0 -0
  88. {whileai-0.54 → whileai-0.56}/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.54
3
+ Version: 0.56
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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "whileai"
7
- version = "0.54"
7
+ version = "0.56"
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"}
@@ -135,6 +135,7 @@ from .score.optimize import (
135
135
  DEFAULT_BAND,
136
136
  filter_rl_rows,
137
137
  group_signal,
138
+ next_round,
138
139
  optimize,
139
140
  recommend,
140
141
  select_for_rl,
@@ -162,7 +163,9 @@ from .score.stage import STAGES, format_stages, stage_of, stage_report, stamp_st
162
163
  from .score.stats import (
163
164
  compare_runs,
164
165
  decontaminate,
166
+ detectable_effect,
165
167
  eval_variance,
168
+ holdout_size,
166
169
  marker_summary,
167
170
  metric_summary,
168
171
  task_key,
@@ -175,6 +178,7 @@ from .training import (
175
178
  TrainingRun,
176
179
  attach_delta,
177
180
  attach_holdout,
181
+ delete_model,
178
182
  delete_run,
179
183
  get_run,
180
184
  list_runs,
@@ -183,6 +187,7 @@ from .training import (
183
187
  serve,
184
188
  train,
185
189
  training_run,
190
+ unserve,
186
191
  )
187
192
  from .verify import Verifier, verifier
188
193
  from .world.sandbox import MockEnvironment
@@ -253,8 +258,10 @@ __all__ = [
253
258
  "dedupe_groups",
254
259
  "delete_dataset",
255
260
  "delete_empty_datasets",
261
+ "delete_model",
256
262
  "delete_run",
257
263
  "delta_report",
264
+ "detectable_effect",
258
265
  "dimensions_from_traces",
259
266
  "drop_leaky_rows",
260
267
  "eval_variance",
@@ -287,6 +294,7 @@ __all__ = [
287
294
  "hf_publish",
288
295
  "hf_publish_run",
289
296
  "hf_status",
297
+ "holdout_size",
290
298
  "hosted_model",
291
299
  "import_hf",
292
300
  "inspect",
@@ -314,6 +322,7 @@ __all__ = [
314
322
  "mine_traces",
315
323
  "models",
316
324
  "near_duplicate_prompts",
325
+ "next_round",
317
326
  "normalize_judge_result",
318
327
  "novelty",
319
328
  "open_ended_probes",
@@ -382,6 +391,7 @@ __all__ = [
382
391
  "trim_unanimous_groups",
383
392
  "ungrounded_arguments",
384
393
  "unpublish",
394
+ "unserve",
385
395
  "update_dataset",
386
396
  "validate",
387
397
  "verifier",
@@ -734,6 +734,7 @@ export_dataset = export_training
734
734
  _PAIR_KEYS = (
735
735
  "tie",
736
736
  "pairwise",
737
+ "first_turn_differs",
737
738
  "chosen_score",
738
739
  "rejected_score",
739
740
  "margin",
@@ -853,6 +854,12 @@ def export_preference(
853
854
  length_note = length_confound_warning(chosen_longer, len(deltas))
854
855
  if length_note:
855
856
  report["warnings"] = [length_note]
857
+ identical = sum(1 for r in out_rows if r.get("first_turn_differs") is False)
858
+ if identical:
859
+ from .score.judging import first_turn_note
860
+
861
+ report["first_turn_identical"] = identical
862
+ report.setdefault("warnings", []).append(first_turn_note(identical, len(out_rows)))
856
863
  margins = [r["margin"] for r in out_rows if isinstance(r.get("margin"), (int, float))]
857
864
  if margins:
858
865
  report["mean_margin"] = round(sum(margins) / len(margins), 4)
@@ -10,7 +10,7 @@ import os
10
10
  import re
11
11
  import threading
12
12
  import time
13
- from collections.abc import Callable
13
+ from collections.abc import Callable, Mapping
14
14
  from typing import Any
15
15
  from urllib.parse import urlparse
16
16
 
@@ -531,6 +531,7 @@ def complete(
531
531
  timeout: float = 60,
532
532
  n: int = 1,
533
533
  logprobs: bool | str = False,
534
+ extra: Mapping[str, Any] | None = None,
534
535
  ) -> dict:
535
536
  """POST /chat/completions. Reuses a thread-local keep-alive connection.
536
537
 
@@ -577,6 +578,8 @@ def complete(
577
578
  if tools:
578
579
  payload["tools"] = _wire_tools(tools)
579
580
  payload.update(_request_extras(base_url, model))
581
+ if extra:
582
+ payload.update(dict(extra))
580
583
  headers = {"Content-Type": "application/json", "Connection": "keep-alive"}
581
584
  if key:
582
585
  headers["Authorization"] = f"Bearer {key}"
@@ -730,8 +733,21 @@ def _strip_tool_markup(text: str) -> str:
730
733
  return cleaned.strip()
731
734
 
732
735
 
736
+ _THINK_BLOCK = re.compile(r"<think>.*?</think>\s*", re.S | re.I)
737
+ _THINK_OPEN = re.compile(r"<think>.*\Z", re.S | re.I)
738
+
739
+
740
+ def _strip_think(text: str) -> str:
741
+ """Drop a thinking model's reasoning markup. A closed block goes whole;
742
+ an unclosed ``<think>`` (the token cap landed inside it) goes to the
743
+ end. What is left is the reply, which is what a grader, a marker and
744
+ the next turn's history should see (#264)."""
745
+ text = _THINK_BLOCK.sub("", text)
746
+ return _THINK_OPEN.sub("", text)
747
+
748
+
733
749
  def _spoken_text(reply: dict) -> str:
734
- return _strip_tool_markup(str(reply.get("content") or ""))
750
+ return _strip_tool_markup(_strip_think(str(reply.get("content") or "")))
735
751
 
736
752
 
737
753
  def _calls_from_reply(reply: dict) -> tuple[list[dict], dict]:
@@ -1391,9 +1407,23 @@ def local_model(
1391
1407
  timeout: float = 60,
1392
1408
  max_tokens: int | None = None,
1393
1409
  user_model: str | None = None,
1410
+ thinking: bool | None = None,
1394
1411
  ) -> Callable:
1412
+ """An agent that talks to an OpenAI-compatible endpoint (a served
1413
+ adapter, a local vLLM, any chat server) for ``simulate(agent=...)``.
1414
+
1415
+ ``thinking`` is for reasoning bases such as Qwen3: ``False`` sends
1416
+ ``chat_template_kwargs={"enable_thinking": False}`` so the reply is
1417
+ the answer, not the reasoning, the way the hosted Qwen path already
1418
+ does; ``True`` asks for it; ``None`` (the default) sends nothing and
1419
+ leaves the server's default. Either way ``<think>`` markup never
1420
+ reaches ``step["text"]`` or ``final_text``.
1421
+ """
1395
1422
  local = threading.local()
1396
1423
  plans = fault_plans if fault_plans is not None else {}
1424
+ extras: dict[str, Any] | None = (
1425
+ None if thinking is None else {"chat_template_kwargs": {"enable_thinking": bool(thinking)}}
1426
+ )
1397
1427
  # The simulated user's model. None means the agent's own model plays
1398
1428
  # the user (the default); a backend spec moves that role to another
1399
1429
  # model, with the key resolved for that endpoint.
@@ -1438,6 +1468,7 @@ def local_model(
1438
1468
  temperature=temperature,
1439
1469
  timeout=timeout,
1440
1470
  max_tokens=120,
1471
+ extra=extras,
1441
1472
  )
1442
1473
  opener_text = (_spoken_text(greet) or "").strip()
1443
1474
  if opener_text:
@@ -1477,6 +1508,7 @@ def local_model(
1477
1508
  timeout=timeout,
1478
1509
  max_tokens=reply_budget(max_tokens),
1479
1510
  logprobs=logprobs,
1511
+ extra=extras,
1480
1512
  )
1481
1513
  calls, assistant = _calls_from_reply(reply)
1482
1514
  # One agent turn, one set of sampling facts, on its first step.
@@ -22,6 +22,7 @@ import time
22
22
  import urllib.error
23
23
  import urllib.parse
24
24
  import urllib.request
25
+ import warnings as _warnings
25
26
  from collections.abc import Sequence
26
27
  from typing import Any
27
28
 
@@ -242,6 +243,36 @@ def _meta_body(
242
243
  return body
243
244
 
244
245
 
246
+ HOLDOUT_PROVE_EFFECT = 0.05
247
+
248
+
249
+ def _warn_small_holdout(rows: Sequence[dict]) -> None:
250
+ """A holdout too small to prove a 5-point gain reads every round as
251
+ ``no_change_detected``; say so at push time, not after training (#257)."""
252
+ from ..score.stats import holdout_size, task_key
253
+
254
+ groups: dict[str, int] = {}
255
+ for row in rows:
256
+ if isinstance(row, dict):
257
+ key = task_key(row)
258
+ groups[key] = groups.get(key, 0) + 1
259
+ n_tasks = len(groups)
260
+ if not n_tasks:
261
+ return
262
+ try:
263
+ need = holdout_size(HOLDOUT_PROVE_EFFECT, rows=rows)
264
+ except ValueError:
265
+ need = holdout_size(HOLDOUT_PROVE_EFFECT, k=min(groups.values()))
266
+ if n_tasks < need["n_tasks"]:
267
+ _warnings.warn(
268
+ f"holdout has {n_tasks} tasks at k={need['k']}; proving a "
269
+ f"{HOLDOUT_PROVE_EFFECT:.0%} gain at 80% power needs about {need['n_tasks']} "
270
+ "(holdout_size). A smaller holdout reads a real gain that size as "
271
+ "no_change_detected.",
272
+ stacklevel=3,
273
+ )
274
+
275
+
245
276
  def push_rows(
246
277
  rows: list[dict],
247
278
  name: str,
@@ -279,6 +310,8 @@ def push_rows(
279
310
 
280
311
  gate_report = publish_gate(rows, mode=mode, endorsed=endorsed, strict_hacks=strict_hacks)
281
312
  check(rows, where="push_rows")
313
+ if purpose == "holdout":
314
+ _warn_small_holdout(rows)
282
315
  body: dict = {
283
316
  "name": name,
284
317
  **_meta_body(purpose, mode if mode in MODES else None, agent, description),
@@ -26,7 +26,15 @@ from collections.abc import Callable, Sequence
26
26
  from typing import Any
27
27
 
28
28
  from .passat import pass_at
29
- from .stats import DEFAULT_BOOT, compare_runs, eval_variance, marker_names, task_means
29
+ from .stats import (
30
+ DEFAULT_BOOT,
31
+ compare_runs,
32
+ detectable_effect,
33
+ eval_variance,
34
+ holdout_size,
35
+ marker_names,
36
+ task_means,
37
+ )
30
38
 
31
39
  GROUP_KEYS = ("delta", "ci95", "verdict", "mean_a", "mean_b", "n_used", "n_paired", "paired")
32
40
 
@@ -327,6 +335,32 @@ def delta_report(
327
335
  headline_key = target_key if target_result else "pass_at_1"
328
336
  if headline.get("note"):
329
337
  warnings.append(f"{headline_key}: {headline['note']}")
338
+ # Eval size: a no-change verdict is only as strong as the band the
339
+ # task count allows. Say what this holdout can prove and what the
340
+ # delta seen here would have needed (#257).
341
+ n_paired = int(headline.get("n_paired") or 0)
342
+ k_eval = int(pass_at(before).config.get("k") or 1)
343
+ base_rate = float(mean_a) if mean_a is not None else 0.6
344
+ can_prove = detectable_effect(n_paired, base=base_rate, k=k_eval) if n_paired >= 2 else None
345
+ tasks_needed: int | None = None
346
+ delta_seen: float | None = None
347
+ raw_delta = headline.get("delta")
348
+ if isinstance(raw_delta, (int, float)) and 0 < raw_delta < 1:
349
+ delta_seen = float(raw_delta)
350
+ tasks_needed = holdout_size(delta_seen, base=base_rate, k=k_eval)["n_tasks"]
351
+ verdict_word = (
352
+ target_verdict if target_result else _verdict_word(results["pass_at_1"], replicated)
353
+ )
354
+ if verdict_word == "no_change_detected" and can_prove is not None:
355
+ line = (
356
+ f"{n_paired} paired tasks at k={k_eval} can prove a gain of about "
357
+ f"+{can_prove:.2f} at 80% power"
358
+ )
359
+ if tasks_needed is not None and delta_seen is not None:
360
+ line += (
361
+ f"; to prove the {delta_seen:+.3f} seen here you need about {tasks_needed} tasks"
362
+ )
363
+ warnings.append(line + " (holdout_size).")
330
364
  if target_verdict == "target_not_measured":
331
365
  warnings.append(f"target {target!r} is not on both row sets")
332
366
  groups: dict[str, dict[str, Any]] | None = None
@@ -398,6 +432,8 @@ def delta_report(
398
432
  "eval_runs": eval_runs,
399
433
  "replicated": replicated,
400
434
  "ceiling": ceiling,
435
+ "detectable_effect": can_prove,
436
+ "tasks_needed": tasks_needed,
401
437
  "proxy": proxy_key,
402
438
  "proxy_verdict": proxy_verdict,
403
439
  "proxy_delta": proxy_result["delta"] if proxy_result else None,
@@ -40,6 +40,15 @@ _FAULT_ALIASES = {
40
40
  "entity already acted on": "already_done",
41
41
  }
42
42
  _CLEAN_FAULT = {"", "ok", "created", "deleted", "success", "exit_0", "no fault", "clean"}
43
+ #: Statuses a tool result can carry that mean the call failed. A ``status``
44
+ #: key with any other value (``paid``, ``open``, ``completed``) is the
45
+ #: tool's own vocabulary, not a fault (#261).
46
+ _KNOWN_FAULTS = (
47
+ _BAD_STATUS
48
+ | set(_FAULT_ALIASES)
49
+ | set(_FAULT_ALIASES.values())
50
+ | {"stale", "malformed", "already_done", "not_found", "deny", "garbled"}
51
+ )
43
52
  _HTTP_FAIL = re.compile(r"^[45]\d\d$")
44
53
  _INFRA_STUB = re.compile(
45
54
  r"^<agent error:|returned\s+[45]\d\d\b|^(https?://\S+\s+)?[45]\d\d(\s|$)", re.I
@@ -426,10 +435,10 @@ def _fault_from_result(result) -> str:
426
435
  if status in {"already_done", "already_acted_on"} or reason == "already_acted_on":
427
436
  return "already_done"
428
437
  name = normalize_fault_name(status)
429
- if name:
438
+ if name and (name in _KNOWN_FAULTS or status in _KNOWN_FAULTS):
430
439
  return name
431
440
  if _step_faulted(result):
432
- return normalize_fault_name(status) or "error"
441
+ return name or "error"
433
442
  return ""
434
443
 
435
444
 
@@ -563,6 +563,42 @@ def length_confound_warning(chosen_longer: int, n: int) -> str | None:
563
563
  return None
564
564
 
565
565
 
566
+ def _first_turn(row: dict) -> str:
567
+ """What the policy emitted first, read the way the hosted DPO trainer
568
+ reads it: the first tool step as its call, else the first assistant
569
+ text, else ``final_text``."""
570
+ steps = [s for s in (row.get("steps") or []) if isinstance(s, dict)]
571
+ for step in steps:
572
+ if step.get("tool"):
573
+ args = step.get("arguments")
574
+ if args is None:
575
+ args = step.get("args")
576
+ return json.dumps({"name": step["tool"], "arguments": args or {}}, sort_keys=True)
577
+ for step in steps:
578
+ if str(step.get("text") or "").strip():
579
+ return str(step["text"]).strip()
580
+ for message in row.get("messages") or []:
581
+ if isinstance(message, dict) and message.get("role") == "assistant":
582
+ text = str(message.get("content") or "").strip()
583
+ if text:
584
+ return text
585
+ return str(row.get("final_text") or "").strip()
586
+
587
+
588
+ def first_turn_note(identical: int, n: int) -> str:
589
+ """The warning for pairs whose first assistant turns read the same."""
590
+ if not identical:
591
+ return ""
592
+ left = n - identical
593
+ return (
594
+ f"{identical}/{n} pairs have identical first assistant turns (same opening tool call "
595
+ "or line); their contrast is later in the rollout. The hosted DPO trainer compares "
596
+ f"first turns only and will drop them, leaving {left} (it needs at least 8). Keep "
597
+ "[p for p in pairs if p['first_turn_differs']] to see what it will train on, or "
598
+ "export_preference(pairs) for a trainer that reads whole conversations."
599
+ )
600
+
601
+
566
602
  def build_preference_pairs(
567
603
  rows: Sequence[dict],
568
604
  *,
@@ -651,6 +687,7 @@ def build_preference_pairs(
651
687
  "rejected_model": r_model,
652
688
  "same_policy": (c_model == r_model) if c_model and r_model else None,
653
689
  "length_delta": reply_length(chosen) - reply_length(rejected),
690
+ "first_turn_differs": _first_turn(chosen) != _first_turn(rejected),
654
691
  "chosen_reason": str(chosen.get("reason") or ""),
655
692
  "rejected_reason": str(rejected.get("reason") or ""),
656
693
  "rejected_failure_class": rejected.get("failure_class"),
@@ -673,10 +710,13 @@ def build_preference_pairs(
673
710
  for p in pairs
674
711
  if p["chosen_score"] not in (0.0, 1.0) or p["rejected_score"] not in (0.0, 1.0)
675
712
  )
713
+ identical = sum(1 for p in pairs if not p["first_turn_differs"])
676
714
  warnings: list[str] = []
677
715
  length_note = length_confound_warning(chosen_longer, n)
678
716
  if length_note:
679
717
  warnings.append(length_note)
718
+ if identical:
719
+ warnings.append(first_turn_note(identical, n))
680
720
  if mixed_policy:
681
721
  warnings.append(
682
722
  f"{mixed_policy}/{n} pairs mix policies (chosen and rejected from different "
@@ -694,6 +734,8 @@ def build_preference_pairs(
694
734
  "min_margin": min_margin,
695
735
  "mean_margin": round(sum(p["margin"] for p in pairs) / n, 4) if n else None,
696
736
  "partial_score_pairs": partial,
737
+ "first_turn_identical": identical,
738
+ "trainer_pairs": n - identical,
697
739
  "same_policy_pairs": same_policy,
698
740
  "mixed_policy_pairs": mixed_policy,
699
741
  "eval_sourced": eval_pairs,
@@ -614,6 +614,105 @@ def _spread_by(prompts: list[str], key) -> list[str]:
614
614
  return out
615
615
 
616
616
 
617
+ def _task_keys_of(tasks: Sequence[Any]) -> list[str]:
618
+ out: list[str] = []
619
+ for t in tasks:
620
+ if isinstance(t, dict):
621
+ out.append(task_key(t))
622
+ else:
623
+ out.append(task_key({"prompt": str(t)}))
624
+ return out
625
+
626
+
627
+ def next_round(
628
+ prior: Sequence[dict],
629
+ *,
630
+ tasks: Sequence[Any] | None = None,
631
+ lo: float = DEFAULT_BAND[0],
632
+ hi: float = DEFAULT_BAND[1],
633
+ ) -> dict[str, Any]:
634
+ """The prompt set for the next round, from the last round's graded
635
+ rollouts.
636
+
637
+ A round trained on the file it started from keeps paying for groups
638
+ that give no gradient: at a 0.65 training reward about half the
639
+ groups are all-pass or all-fail. The band is the fix the book already
640
+ names (rlhf-book ch. 7: filter to the 20-80% band; ch. 6, DAPO's
641
+ dynamic sampling drops groups with no contrast), applied to what the
642
+ *current* policy does rather than what the base did. ``prior`` is
643
+ round N's graded rollouts (``simulate(tasks=..., repeats=k)`` on the
644
+ round-N policy, or the trainer's own sampled rows); each task's pass
645
+ rate over them decides: inside ``[lo, hi]`` it is kept, above ``hi``
646
+ it is solved and dropped, below ``lo`` it is unsolved and dropped.
647
+ ``tasks`` restricts the candidates (rows, task dicts with a
648
+ ``prompt``, or prompt strings); a task with no prior rollouts is
649
+ ``unknown`` and kept, since nothing says it is flat.
650
+
651
+ Returns ``tasks`` (one representative row per kept task: the prior
652
+ row, with ``calibration.pass_rate`` and the band), the counts
653
+ ``kept``, ``dropped_solved``, ``dropped_unsolved``, ``unknown``,
654
+ ``pass_rates`` per task, ``band``, ``from_policy`` (the policy
655
+ versions the prior rows came from) and ``prompt_set_sha``: the
656
+ identity of the kept set, for lineage on the run. Push the kept rows
657
+ as the next train set with ``parent=`` the last one.
658
+ """
659
+ if not 0 <= lo < hi <= 1:
660
+ raise ValueError("band is 0 <= lo < hi <= 1")
661
+ labels = _group_label_lists(prior)
662
+ rates = {key: sum(v) / len(v) for key, v in labels.items() if v}
663
+ first: dict[str, dict] = {}
664
+ policies: set[str] = set()
665
+ for row in prior:
666
+ if not isinstance(row, dict):
667
+ continue
668
+ first.setdefault(task_key(row), row)
669
+ if row.get("policy_version"):
670
+ policies.add(str(row["policy_version"]))
671
+ if tasks is None:
672
+ candidates = list(rates)
673
+ given: dict[str, Any] = {}
674
+ else:
675
+ given = {}
676
+ for t in tasks:
677
+ key = task_key(t) if isinstance(t, dict) else task_key({"prompt": str(t)})
678
+ given.setdefault(key, t)
679
+ candidates = list(given)
680
+ kept: list[dict] = []
681
+ solved = unsolved = unknown = 0
682
+ for key in candidates:
683
+ rate = rates.get(key)
684
+ if rate is None:
685
+ unknown += 1
686
+ rep = given.get(key)
687
+ rep = dict(rep) if isinstance(rep, dict) else {"prompt": str(rep)}
688
+ kept.append(rep)
689
+ continue
690
+ if rate > hi:
691
+ solved += 1
692
+ continue
693
+ if rate < lo:
694
+ unsolved += 1
695
+ continue
696
+ rep = dict(first.get(key) or given.get(key) or {"prompt": key})
697
+ cal = dict(rep.get("calibration") or {})
698
+ cal.update({"pass_rate": round(rate, 4), "n": len(labels[key]), "band": [lo, hi]})
699
+ rep["calibration"] = cal
700
+ kept.append(rep)
701
+ sha = hashlib.sha256("\n".join(sorted(task_key(r) for r in kept)).encode()).hexdigest()[:16]
702
+ return {
703
+ "tasks": kept,
704
+ "kept": len(kept) - unknown,
705
+ "dropped_solved": solved,
706
+ "dropped_unsolved": unsolved,
707
+ "unknown": unknown,
708
+ "n_prior_tasks": len(rates),
709
+ "pass_rates": {k: round(v, 4) for k, v in rates.items()},
710
+ "band": [lo, hi],
711
+ "from_policy": sorted(policies),
712
+ "prompt_set_sha": sha,
713
+ }
714
+
715
+
617
716
  def select_for_rl(
618
717
  rows: Sequence[dict],
619
718
  *,
@@ -627,9 +726,18 @@ def select_for_rl(
627
726
  endorsed: Sequence[str] = (),
628
727
  truncated: str = "drop",
629
728
  order: str = "spread",
729
+ prior: Sequence[dict] | None = None,
630
730
  ) -> tuple[list[dict], dict[str, Any]]:
631
731
  """Whole mixed groups up to roughly ``target`` rows. Groups never split.
632
732
 
733
+ ``prior`` is the previous round's graded rollouts: tasks the round-N
734
+ policy already solves (pass rate above ``hi`` on ``prior``) or never
735
+ solves (below ``lo``) are dropped before anything else, so round N+1
736
+ trains on what that policy gets right 20-80% of the time rather than
737
+ on the file round 1 started from (``next_round``; rlhf-book ch. 7).
738
+ The report's ``prior`` block counts kept, dropped_solved,
739
+ dropped_unsolved and unknown.
740
+
633
741
  ``truncated`` says what happens to a rollout cut at the token cap
634
742
  (rlhf-book ch. 6, DAPO's overlong handling; ch. 7 overlong filtering):
635
743
  ``"drop"`` removes it (the default; ``drop_truncated=False`` is the old
@@ -691,6 +799,24 @@ def select_for_rl(
691
799
  raise ValueError(f"order must be one of {', '.join(RL_ORDERS)}; got {order!r}")
692
800
  if not drop_truncated and truncated == "drop":
693
801
  truncated = "keep"
802
+ prior_report: dict[str, Any] | None = None
803
+ if prior is not None:
804
+ plan = next_round(prior, lo=lo, hi=hi)
805
+ rates = plan["pass_rates"]
806
+ before_n = len(rows)
807
+ rows = [
808
+ r
809
+ for r in rows
810
+ if not isinstance(r, dict)
811
+ or rates.get(task_key(r)) is None
812
+ or lo <= rates[task_key(r)] <= hi
813
+ ]
814
+ prior_report = {
815
+ k: plan[k]
816
+ for k in ("kept", "dropped_solved", "dropped_unsolved", "unknown", "from_policy")
817
+ }
818
+ prior_report["rows_dropped"] = before_n - len(rows)
819
+ prior_report["prompt_set_sha"] = plan["prompt_set_sha"]
694
820
  penalized = kept_overlong = 0
695
821
  if truncated != "drop":
696
822
  marked: list[dict] = []
@@ -838,6 +964,7 @@ def select_for_rl(
838
964
  "enforce_band": bool(enforce_band),
839
965
  "band_groups_dropped": band_report["n_groups_dropped"],
840
966
  "band_dropped": {"too_easy": band_report["too_easy"], "too_hard": band_report["too_hard"]},
967
+ "prior": prior_report,
841
968
  "duplicates": dup_report,
842
969
  "truncated_dropped": trunc_report["n_dropped"],
843
970
  "truncated_policy": truncated,
@@ -57,6 +57,116 @@ def wilson_interval(successes: int, n: int, *, z: float = 1.96) -> tuple[float,
57
57
  return (max(0.0, center - half), min(1.0, center + half))
58
58
 
59
59
 
60
+ def _z(p: float) -> float:
61
+ from statistics import NormalDist
62
+
63
+ return NormalDist().inv_cdf(p)
64
+
65
+
66
+ def _paired_task_sd(base: float, effect: float, k: int) -> float:
67
+ """Standard deviation of one task's paired difference (after minus
68
+ before pass rate over ``k`` rollouts each side) when the gain lands
69
+ uniformly: before at ``base``, after at ``base + effect``."""
70
+ p = min(1.0, max(0.0, float(base)))
71
+ q = min(1.0, max(0.0, p + float(effect)))
72
+ kk = max(1, int(k))
73
+ return math.sqrt((p * (1 - p) + q * (1 - q)) / kk)
74
+
75
+
76
+ def _rows_base_and_k(rows: Sequence[dict]) -> tuple[float, int]:
77
+ """Mean per-task pass rate and the smallest rollouts-per-task on graded
78
+ rows: what ``delta_report`` would pair on."""
79
+ groups: dict[str, list[float]] = {}
80
+ for row in rows:
81
+ if not isinstance(row, dict):
82
+ continue
83
+ value = _binary(row)
84
+ if value is None:
85
+ continue
86
+ groups.setdefault(task_key(row), []).append(value)
87
+ if not groups:
88
+ raise ValueError("rows carry no 0/1 rewards; grade them first, or pass base= and k=")
89
+ base = _mean([_mean(v) for v in groups.values()])
90
+ k = min(len(v) for v in groups.values())
91
+ return base, k
92
+
93
+
94
+ def holdout_size(
95
+ effect: float,
96
+ *,
97
+ base: float = 0.6,
98
+ k: int = 4,
99
+ power: float = 0.8,
100
+ alpha: float = 0.05,
101
+ rows: Sequence[dict] | None = None,
102
+ ) -> dict[str, Any]:
103
+ """How many paired tasks a holdout needs to prove a gain of ``effect``.
104
+
105
+ Models the test ``delta_report`` runs: each task's pass rate over
106
+ ``k`` rollouts on each side, the delta as the mean of the paired
107
+ differences, the interval from a bootstrap over tasks. A task's
108
+ difference then has standard deviation
109
+ ``sqrt((p(1-p) + q(1-q)) / k)`` with ``p = base`` and ``q = base +
110
+ effect``, and the usual two-sided power calculation gives
111
+ ``n = ((z_{1-alpha/2} + z_power) * sd / effect) ** 2`` (rlhf-book ch. 16,
112
+ appendix C: the eval's own variance decides what a delta can mean).
113
+ It assumes the gain lands uniformly across tasks; a gain concentrated
114
+ on a few tasks needs more.
115
+
116
+ ``rows`` (graded before-side rows) reads ``base`` and ``k`` off the
117
+ data instead. Returns ``n_tasks`` plus the inputs, ``sd_task``, and
118
+ ``half_width``: the 95% band on the delta at that ``n``.
119
+
120
+ The recipe that asked for this had 140 tasks at k=4 around 0.6: a
121
+ band of about +-0.06, so a real 3-point gain reads
122
+ ``no_change_detected`` every round. This says so before training.
123
+ """
124
+ if not 0 < float(effect) < 1:
125
+ raise ValueError(
126
+ "effect is the gain in pass rate to prove, between 0 and 1 (0.05 = 5 points)"
127
+ )
128
+ if not 0 < power < 1 or not 0 < alpha < 1:
129
+ raise ValueError("power and alpha are probabilities strictly between 0 and 1")
130
+ if rows is not None:
131
+ base, k = _rows_base_and_k(rows)
132
+ sd = _paired_task_sd(base, effect, k)
133
+ z = _z(1 - alpha / 2) + _z(power)
134
+ n = math.ceil((z * sd / float(effect)) ** 2) if sd > 0 else 1
135
+ n = max(n, 2)
136
+ return {
137
+ "n_tasks": n,
138
+ "effect": float(effect),
139
+ "base": float(base),
140
+ "k": int(k),
141
+ "power": float(power),
142
+ "alpha": float(alpha),
143
+ "sd_task": round(sd, 4),
144
+ "half_width": round(_z(1 - alpha / 2) * sd / math.sqrt(n), 4),
145
+ }
146
+
147
+
148
+ def detectable_effect(
149
+ n_tasks: int,
150
+ *,
151
+ base: float = 0.6,
152
+ k: int = 4,
153
+ power: float = 0.8,
154
+ alpha: float = 0.05,
155
+ ) -> float | None:
156
+ """The smallest gain ``n_tasks`` paired tasks can prove at ``power``:
157
+ ``holdout_size`` solved for the effect (a few fixed-point steps, since
158
+ the after-side variance depends on it). ``None`` below two tasks."""
159
+ n = int(n_tasks)
160
+ if n < 2:
161
+ return None
162
+ z = _z(1 - alpha / 2) + _z(power)
163
+ effect = 0.0
164
+ for _ in range(12):
165
+ sd = _paired_task_sd(base, effect, k)
166
+ effect = z * sd / math.sqrt(n)
167
+ return round(min(1.0, effect), 4)
168
+
169
+
60
170
  def bootstrap_ci(
61
171
  values: Sequence[float],
62
172
  *,
@@ -474,6 +474,11 @@ class TrainingRun:
474
474
  else:
475
475
  self.finish("done")
476
476
 
477
+ @property
478
+ def id(self) -> str:
479
+ """The run id, the handle ``get_run``, ``serve`` and ``delete_run`` take."""
480
+ return self.run_id
481
+
477
482
  def __repr__(self) -> str:
478
483
  return (
479
484
  f"TrainingRun({self.run_id!r}, {self.name!r}, status={self.status!r}, step={self.step})"
@@ -825,11 +830,40 @@ def models(*, api_key: str | None = None) -> list[dict[str, Any]]:
825
830
  """The account's hosted models: ``name``, ``baseModel``, ``adapter``,
826
831
  ``adapterRunId``, ``version``, ``endpoint`` (an OpenAI-compatible
827
832
  base URL; send the account key as the bearer and ``name`` as the
828
- model)."""
833
+ model).
834
+
835
+ A row here is a registry entry, not a running GPU: the endpoint
836
+ behind it idles to zero on its own and an unused model costs nothing.
837
+ The row stays until ``unserve(name)`` removes it; serving the same
838
+ name again bumps its ``version`` rather than adding a row."""
829
839
  out = _call("GET", "/models", api_key)
830
840
  return list(out.get("models") or []) if isinstance(out, dict) else []
831
841
 
832
842
 
843
+ def unserve(
844
+ name: str,
845
+ *,
846
+ api_key: str | None = None,
847
+ transport: Callable[..., Any] | None = None,
848
+ ) -> dict[str, Any]:
849
+ """Stop hosting ``name``: removes the model row from the account, so
850
+ ``models()`` no longer lists it and its endpoint stops answering for
851
+ that name. The inverse of ``serve``, the way ``delete_dataset`` is the
852
+ inverse of ``push``. The adapter weights and the training run stay;
853
+ ``serve`` the run again to bring it back (at version 1).
854
+ Returns ``{"name": ..., "deleted": True}``."""
855
+ call = transport or _call
856
+ key = str(name).strip().lower()
857
+ if not key:
858
+ raise ValueError("unserve: name is the hosted model's name, as models() lists it")
859
+ out = call("DELETE", f"/models/{key}", api_key)
860
+ return dict(out) if isinstance(out, dict) else {"name": key, "deleted": True}
861
+
862
+
863
+ #: Same call, the other spelling: symmetric with ``delete_dataset``.
864
+ delete_model = unserve
865
+
866
+
833
867
  def serve(
834
868
  name: str,
835
869
  run: TrainingRun | str | None = None,
@@ -842,18 +876,36 @@ def serve(
842
876
  row; ``endpoint`` is the OpenAI-compatible base URL and ``name`` the
843
877
  model id to send. Posting an existing name bumps ``version``.
844
878
 
845
- ``run`` is a ``TrainingRun`` or its id; the adapter and base model
846
- come from the run record unless ``base_model`` is given. No ``run``
847
- serves the bare base (``base_model`` required).
879
+ ``run`` is a ``TrainingRun``, the record ``get_run`` returns, or the
880
+ run id; the adapter and base model come from the run record unless
881
+ ``base_model`` is given. No ``run`` serves the bare base
882
+ (``base_model`` required). ``unserve`` is the inverse.
848
883
  """
849
884
  call = transport or _call
850
885
  adapter: str | None = None
851
886
  base = base_model
887
+ run_id: str | None
852
888
  if isinstance(run, TrainingRun):
853
889
  adapter = run.adapter
854
- run_id: str | None = run.run_id
890
+ run_id = run.run_id
891
+ elif isinstance(run, dict):
892
+ # The record ``get_run`` returns. Reading the id here is what lets
893
+ # train in one process and serve in the next compose (#262).
894
+ run_id = str(run.get("runId") or run.get("run_id") or run.get("id") or "").strip() or None
895
+ if not run_id:
896
+ raise TypeError(
897
+ "run is a dict with no runId; pass the record wai.get_run(run_id) returns, "
898
+ "a TrainingRun, or the run id string"
899
+ )
900
+ adapter = str(run.get("adapter") or "").strip() or None
901
+ base = base or run.get("baseModel") or run.get("base_model")
902
+ elif run is None or isinstance(run, str):
903
+ run_id = (str(run).strip() or None) if run else None
855
904
  else:
856
- run_id = str(run) if run else None
905
+ raise TypeError(
906
+ "run must be a TrainingRun, the record wai.get_run(run_id) returns, or the run "
907
+ f"id string; got {type(run).__name__}"
908
+ )
857
909
  if run_id and (adapter is None or base is None):
858
910
  meta = call("GET", f"/runs/{run_id}", api_key)
859
911
  meta = meta if isinstance(meta, dict) else {}
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: whileai
3
- Version: 0.54
3
+ Version: 0.56
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
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
File without changes