whileai 0.55__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.55/whileai.egg-info → whileai-0.56}/PKG-INFO +1 -1
  2. {whileai-0.55 → whileai-0.56}/pyproject.toml +1 -1
  3. {whileai-0.55 → whileai-0.56}/whileai/simulations/__init__.py +6 -0
  4. {whileai-0.55 → whileai-0.56}/whileai/simulations/ingest/platform.py +33 -0
  5. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/delta.py +37 -1
  6. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/optimize.py +127 -0
  7. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/stats.py +110 -0
  8. {whileai-0.55 → whileai-0.56/whileai.egg-info}/PKG-INFO +1 -1
  9. {whileai-0.55 → whileai-0.56}/LICENSE +0 -0
  10. {whileai-0.55 → whileai-0.56}/MANIFEST.in +0 -0
  11. {whileai-0.55 → whileai-0.56}/README.md +0 -0
  12. {whileai-0.55 → whileai-0.56}/setup.cfg +0 -0
  13. {whileai-0.55 → whileai-0.56}/whileai/__init__.py +0 -0
  14. {whileai-0.55 → whileai-0.56}/whileai/_env.py +0 -0
  15. {whileai-0.55 → whileai-0.56}/whileai/auth.py +0 -0
  16. {whileai-0.55 → whileai-0.56}/whileai/cli.py +0 -0
  17. {whileai-0.55 → whileai-0.56}/whileai/ingest.py +0 -0
  18. {whileai-0.55 → whileai-0.56}/whileai/py.typed +0 -0
  19. {whileai-0.55 → whileai-0.56}/whileai/simulations/__main__.py +0 -0
  20. {whileai-0.55 → whileai-0.56}/whileai/simulations/data.py +0 -0
  21. {whileai-0.55 → whileai-0.56}/whileai/simulations/environment.py +0 -0
  22. {whileai-0.55 → whileai-0.56}/whileai/simulations/export.py +0 -0
  23. {whileai-0.55 → whileai-0.56}/whileai/simulations/generate/__init__.py +0 -0
  24. {whileai-0.55 → whileai-0.56}/whileai/simulations/generate/actionspace.py +0 -0
  25. {whileai-0.55 → whileai-0.56}/whileai/simulations/generate/adapters.py +0 -0
  26. {whileai-0.55 → whileai-0.56}/whileai/simulations/generate/agents.py +0 -0
  27. {whileai-0.55 → whileai-0.56}/whileai/simulations/generate/coverage.py +0 -0
  28. {whileai-0.55 → whileai-0.56}/whileai/simulations/generate/diversity.py +0 -0
  29. {whileai-0.55 → whileai-0.56}/whileai/simulations/generate/embeddings.py +0 -0
  30. {whileai-0.55 → whileai-0.56}/whileai/simulations/generate/explore.py +0 -0
  31. {whileai-0.55 → whileai-0.56}/whileai/simulations/generate/generator.py +0 -0
  32. {whileai-0.55 → whileai-0.56}/whileai/simulations/generate/offline_agent.py +0 -0
  33. {whileai-0.55 → whileai-0.56}/whileai/simulations/generate/scenarios.py +0 -0
  34. {whileai-0.55 → whileai-0.56}/whileai/simulations/generate/usage_meter.py +0 -0
  35. {whileai-0.55 → whileai-0.56}/whileai/simulations/ingest/__init__.py +0 -0
  36. {whileai-0.55 → whileai-0.56}/whileai/simulations/ingest/otel.py +0 -0
  37. {whileai-0.55 → whileai-0.56}/whileai/simulations/ingest/traces.py +0 -0
  38. {whileai-0.55 → whileai-0.56}/whileai/simulations/monitor.py +0 -0
  39. {whileai-0.55 → whileai-0.56}/whileai/simulations/py.typed +0 -0
  40. {whileai-0.55 → whileai-0.56}/whileai/simulations/run/__init__.py +0 -0
  41. {whileai-0.55 → whileai-0.56}/whileai/simulations/run/config.py +0 -0
  42. {whileai-0.55 → whileai-0.56}/whileai/simulations/run/engine.py +0 -0
  43. {whileai-0.55 → whileai-0.56}/whileai/simulations/run/rows.py +0 -0
  44. {whileai-0.55 → whileai-0.56}/whileai/simulations/run/spec.py +0 -0
  45. {whileai-0.55 → whileai-0.56}/whileai/simulations/schema.py +0 -0
  46. {whileai-0.55 → whileai-0.56}/whileai/simulations/schemas/row-v1.json +0 -0
  47. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/__init__.py +0 -0
  48. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/agreement.py +0 -0
  49. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/checklist.py +0 -0
  50. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/curriculum.py +0 -0
  51. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/grade_llm.py +0 -0
  52. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/grading.py +0 -0
  53. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/grounding.py +0 -0
  54. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/hack_scan.py +0 -0
  55. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/hygiene.py +0 -0
  56. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/judge_trust.py +0 -0
  57. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/judging.py +0 -0
  58. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/labels.py +0 -0
  59. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/llm_judge.py +0 -0
  60. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/logprobs.py +0 -0
  61. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/markers.py +0 -0
  62. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/pairwise.py +0 -0
  63. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/passat.py +0 -0
  64. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/preflight.py +0 -0
  65. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/privileged.py +0 -0
  66. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/publish_gate.py +0 -0
  67. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/quality.py +0 -0
  68. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/reference.py +0 -0
  69. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/rubric.py +0 -0
  70. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/spec.py +0 -0
  71. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/stage.py +0 -0
  72. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/style.py +0 -0
  73. {whileai-0.55 → whileai-0.56}/whileai/simulations/score/trace.py +0 -0
  74. {whileai-0.55 → whileai-0.56}/whileai/simulations/simulation.py +0 -0
  75. {whileai-0.55 → whileai-0.56}/whileai/simulations/training.py +0 -0
  76. {whileai-0.55 → whileai-0.56}/whileai/simulations/verify/__init__.py +0 -0
  77. {whileai-0.55 → whileai-0.56}/whileai/simulations/verify/base.py +0 -0
  78. {whileai-0.55 → whileai-0.56}/whileai/simulations/verify/code.py +0 -0
  79. {whileai-0.55 → whileai-0.56}/whileai/simulations/verify/math.py +0 -0
  80. {whileai-0.55 → whileai-0.56}/whileai/simulations/verify/structured.py +0 -0
  81. {whileai-0.55 → whileai-0.56}/whileai/simulations/verify/text.py +0 -0
  82. {whileai-0.55 → whileai-0.56}/whileai/simulations/world/__init__.py +0 -0
  83. {whileai-0.55 → whileai-0.56}/whileai/simulations/world/sandbox.py +0 -0
  84. {whileai-0.55 → whileai-0.56}/whileai.egg-info/SOURCES.txt +0 -0
  85. {whileai-0.55 → whileai-0.56}/whileai.egg-info/dependency_links.txt +0 -0
  86. {whileai-0.55 → whileai-0.56}/whileai.egg-info/entry_points.txt +0 -0
  87. {whileai-0.55 → whileai-0.56}/whileai.egg-info/requires.txt +0 -0
  88. {whileai-0.55 → 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.55
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.55"
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,
@@ -258,6 +261,7 @@ __all__ = [
258
261
  "delete_model",
259
262
  "delete_run",
260
263
  "delta_report",
264
+ "detectable_effect",
261
265
  "dimensions_from_traces",
262
266
  "drop_leaky_rows",
263
267
  "eval_variance",
@@ -290,6 +294,7 @@ __all__ = [
290
294
  "hf_publish",
291
295
  "hf_publish_run",
292
296
  "hf_status",
297
+ "holdout_size",
293
298
  "hosted_model",
294
299
  "import_hf",
295
300
  "inspect",
@@ -317,6 +322,7 @@ __all__ = [
317
322
  "mine_traces",
318
323
  "models",
319
324
  "near_duplicate_prompts",
325
+ "next_round",
320
326
  "normalize_judge_result",
321
327
  "novelty",
322
328
  "open_ended_probes",
@@ -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,
@@ -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
  *,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: whileai
3
- Version: 0.55
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