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.
- {whileai-0.54/whileai.egg-info → whileai-0.56}/PKG-INFO +1 -1
- {whileai-0.54 → whileai-0.56}/pyproject.toml +1 -1
- {whileai-0.54 → whileai-0.56}/whileai/simulations/__init__.py +10 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/export.py +7 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/agents.py +34 -2
- {whileai-0.54 → whileai-0.56}/whileai/simulations/ingest/platform.py +33 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/delta.py +37 -1
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/grading.py +11 -2
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/judging.py +42 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/optimize.py +127 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/stats.py +110 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/training.py +58 -6
- {whileai-0.54 → whileai-0.56/whileai.egg-info}/PKG-INFO +1 -1
- {whileai-0.54 → whileai-0.56}/LICENSE +0 -0
- {whileai-0.54 → whileai-0.56}/MANIFEST.in +0 -0
- {whileai-0.54 → whileai-0.56}/README.md +0 -0
- {whileai-0.54 → whileai-0.56}/setup.cfg +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/__init__.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/_env.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/auth.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/cli.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/ingest.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/py.typed +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/__main__.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/data.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/environment.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/__init__.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/actionspace.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/adapters.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/coverage.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/diversity.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/embeddings.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/explore.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/generator.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/offline_agent.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/scenarios.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/generate/usage_meter.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/ingest/__init__.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/ingest/otel.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/ingest/traces.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/monitor.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/py.typed +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/run/__init__.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/run/config.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/run/engine.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/run/rows.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/run/spec.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/schema.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/schemas/row-v1.json +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/__init__.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/agreement.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/checklist.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/curriculum.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/grade_llm.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/grounding.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/hack_scan.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/hygiene.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/judge_trust.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/labels.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/llm_judge.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/logprobs.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/markers.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/pairwise.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/passat.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/preflight.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/privileged.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/publish_gate.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/quality.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/reference.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/rubric.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/spec.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/stage.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/style.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/score/trace.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/simulation.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/verify/__init__.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/verify/base.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/verify/code.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/verify/math.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/verify/structured.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/verify/text.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/world/__init__.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai/simulations/world/sandbox.py +0 -0
- {whileai-0.54 → whileai-0.56}/whileai.egg-info/SOURCES.txt +0 -0
- {whileai-0.54 → whileai-0.56}/whileai.egg-info/dependency_links.txt +0 -0
- {whileai-0.54 → whileai-0.56}/whileai.egg-info/entry_points.txt +0 -0
- {whileai-0.54 → whileai-0.56}/whileai.egg-info/requires.txt +0 -0
- {whileai-0.54 → whileai-0.56}/whileai.egg-info/top_level.txt +0 -0
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "whileai"
|
|
7
|
-
version = "0.
|
|
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
|
|
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
|
|
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
|
|
846
|
-
|
|
847
|
-
serves the bare base
|
|
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
|
|
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
|
-
|
|
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 {}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|