world-model-optimizer 0.2.0__py3-none-any.whl
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.
- llm_waterfall/LICENSE +21 -0
- llm_waterfall/__init__.py +53 -0
- llm_waterfall/adapters/__init__.py +36 -0
- llm_waterfall/adapters/anthropic.py +105 -0
- llm_waterfall/adapters/aws_mantle.py +47 -0
- llm_waterfall/adapters/azure_openai.py +71 -0
- llm_waterfall/adapters/base.py +51 -0
- llm_waterfall/adapters/bedrock.py +309 -0
- llm_waterfall/adapters/openai.py +130 -0
- llm_waterfall/classify.py +184 -0
- llm_waterfall/pricing.py +110 -0
- llm_waterfall/py.typed +0 -0
- llm_waterfall/types.py +295 -0
- llm_waterfall/waterfall.py +255 -0
- wmo/__init__.py +38 -0
- wmo/agents/__init__.py +7 -0
- wmo/agents/default.py +29 -0
- wmo/agents/meta.py +55 -0
- wmo/agents/optimizer.py +55 -0
- wmo/agents/project.py +928 -0
- wmo/cli/__init__.py +5 -0
- wmo/cli/agent_session.py +1123 -0
- wmo/cli/app.py +2489 -0
- wmo/cli/e2b_cmds.py +212 -0
- wmo/cli/eval_closed_loop.py +207 -0
- wmo/cli/harness_app.py +1147 -0
- wmo/cli/harness_distill.py +659 -0
- wmo/cli/hosted_session.py +880 -0
- wmo/cli/ingest_cmd.py +165 -0
- wmo/cli/model_roles.py +82 -0
- wmo/cli/platform_cmds.py +372 -0
- wmo/cli/route_app.py +274 -0
- wmo/cli/session_state.py +243 -0
- wmo/cli/ui.py +1107 -0
- wmo/cli/workspace_sync.py +504 -0
- wmo/config/__init__.py +60 -0
- wmo/config/card.py +129 -0
- wmo/config/config.py +367 -0
- wmo/config/dotenv.py +67 -0
- wmo/config/settings.py +128 -0
- wmo/config/store.py +177 -0
- wmo/conftest.py +19 -0
- wmo/connect/__init__.py +88 -0
- wmo/connect/apps.py +78 -0
- wmo/connect/brave.py +284 -0
- wmo/connect/connector.py +79 -0
- wmo/connect/credentials.py +164 -0
- wmo/connect/github.py +321 -0
- wmo/connect/google.py +627 -0
- wmo/connect/notion.py +790 -0
- wmo/connect/oauth.py +461 -0
- wmo/connect/slack.py +555 -0
- wmo/connect/store.py +199 -0
- wmo/connect/types.py +156 -0
- wmo/core/__init__.py +21 -0
- wmo/core/parsing.py +281 -0
- wmo/core/render.py +271 -0
- wmo/core/text.py +40 -0
- wmo/core/types.py +116 -0
- wmo/distill/__init__.py +14 -0
- wmo/distill/agents.py +140 -0
- wmo/distill/config.py +1006 -0
- wmo/distill/cost.py +437 -0
- wmo/distill/data.py +921 -0
- wmo/distill/deadlines.py +254 -0
- wmo/distill/fake_tinker.py +734 -0
- wmo/distill/gate.py +122 -0
- wmo/distill/loop.py +3499 -0
- wmo/distill/renderers.py +399 -0
- wmo/distill/rendering.py +620 -0
- wmo/distill/rollouts.py +726 -0
- wmo/distill/samples.py +195 -0
- wmo/distill/store.py +829 -0
- wmo/distill/teacher.py +714 -0
- wmo/distill/tokens.py +535 -0
- wmo/distill/tracking.py +552 -0
- wmo/distill/tripwire.py +411 -0
- wmo/distill/xtoken/byte_offsets.py +152 -0
- wmo/distill/xtoken/chunks.py +457 -0
- wmo/distill/xtoken/prompt_logprobs.py +475 -0
- wmo/distill/xtoken/teacher_render.py +346 -0
- wmo/engine/__init__.py +28 -0
- wmo/engine/autoconfig.py +367 -0
- wmo/engine/build.py +346 -0
- wmo/engine/demo.py +77 -0
- wmo/engine/eval_suites.py +245 -0
- wmo/engine/grounding.py +491 -0
- wmo/engine/knowledge.py +291 -0
- wmo/engine/loader.py +36 -0
- wmo/engine/play.py +92 -0
- wmo/engine/prompts.py +99 -0
- wmo/engine/replay.py +443 -0
- wmo/engine/reporting.py +58 -0
- wmo/engine/workspace.py +468 -0
- wmo/engine/world_model.py +568 -0
- wmo/env/__init__.py +22 -0
- wmo/env/base.py +121 -0
- wmo/env/closed_loop.py +229 -0
- wmo/env/episode.py +107 -0
- wmo/env/llm_agent.py +93 -0
- wmo/env/scenarios.py +73 -0
- wmo/evals/__init__.py +52 -0
- wmo/evals/agreement.py +110 -0
- wmo/evals/base.py +45 -0
- wmo/evals/closed_loop.py +480 -0
- wmo/evals/failover.py +96 -0
- wmo/evals/gold.py +127 -0
- wmo/evals/grid.py +394 -0
- wmo/evals/grid_plot.py +205 -0
- wmo/evals/harbor/__init__.py +27 -0
- wmo/evals/harbor/agent.py +573 -0
- wmo/evals/harbor/ctrf.py +171 -0
- wmo/evals/harbor/e2b_environment.py +587 -0
- wmo/evals/harbor/e2b_template_policy.py +144 -0
- wmo/evals/harbor/scorer.py +875 -0
- wmo/evals/harbor/tasks.py +140 -0
- wmo/evals/open_loop.py +194 -0
- wmo/evals/tasks.py +53 -0
- wmo/harness/__init__.py +51 -0
- wmo/harness/code_runtime.py +288 -0
- wmo/harness/create.py +1191 -0
- wmo/harness/delta.py +220 -0
- wmo/harness/doc.py +556 -0
- wmo/harness/e2b_ledger.py +342 -0
- wmo/harness/e2b_reap.py +476 -0
- wmo/harness/e2b_sandbox.py +350 -0
- wmo/harness/environment.py +35 -0
- wmo/harness/live_session.py +543 -0
- wmo/harness/mutate.py +343 -0
- wmo/harness/pi_e2b.py +1710 -0
- wmo/harness/pi_entry/entry.ts +268 -0
- wmo/harness/pi_entry/runner_frames.ts +92 -0
- wmo/harness/pi_entry/runner_live.ts +587 -0
- wmo/harness/pi_entry/runner_service.ts +270 -0
- wmo/harness/pi_entry/runner_stdio.ts +374 -0
- wmo/harness/pi_entry/runner_termination.ts +142 -0
- wmo/harness/pi_local.py +262 -0
- wmo/harness/pi_runtime.py +495 -0
- wmo/harness/pi_vendor.py +65 -0
- wmo/harness/population.py +509 -0
- wmo/harness/project_proposer.py +569 -0
- wmo/harness/proposer.py +977 -0
- wmo/harness/runner_link.py +619 -0
- wmo/harness/runtime.py +389 -0
- wmo/harness/scoring.py +247 -0
- wmo/harness/skills.py +116 -0
- wmo/harness/source_tree.py +319 -0
- wmo/harness/store.py +176 -0
- wmo/harness/tools.py +105 -0
- wmo/harness/vendor/manifest.sha256 +58 -0
- wmo/harness/vendor/pi-agent/CHANGELOG.md +556 -0
- wmo/harness/vendor/pi-agent/LICENSE +21 -0
- wmo/harness/vendor/pi-agent/README.md +488 -0
- wmo/harness/vendor/pi-agent/VENDOR.md +39 -0
- wmo/harness/vendor/pi-agent/docs/agent-harness.md +486 -0
- wmo/harness/vendor/pi-agent/docs/durable-harness.md +212 -0
- wmo/harness/vendor/pi-agent/docs/hooks.md +445 -0
- wmo/harness/vendor/pi-agent/docs/models.md +966 -0
- wmo/harness/vendor/pi-agent/docs/observability.md +376 -0
- wmo/harness/vendor/pi-agent/package.json +60 -0
- wmo/harness/vendor/pi-agent/src/agent-loop.ts +748 -0
- wmo/harness/vendor/pi-agent/src/agent.ts +575 -0
- wmo/harness/vendor/pi-agent/src/harness/agent-harness.ts +1029 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/branch-summarization.ts +261 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/compaction.ts +747 -0
- wmo/harness/vendor/pi-agent/src/harness/compaction/utils.ts +144 -0
- wmo/harness/vendor/pi-agent/src/harness/env/nodejs.ts +550 -0
- wmo/harness/vendor/pi-agent/src/harness/messages.ts +164 -0
- wmo/harness/vendor/pi-agent/src/harness/prompt-templates.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-repo.ts +177 -0
- wmo/harness/vendor/pi-agent/src/harness/session/jsonl-storage.ts +293 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-repo.ts +50 -0
- wmo/harness/vendor/pi-agent/src/harness/session/memory-storage.ts +131 -0
- wmo/harness/vendor/pi-agent/src/harness/session/repo-utils.ts +51 -0
- wmo/harness/vendor/pi-agent/src/harness/session/session.ts +267 -0
- wmo/harness/vendor/pi-agent/src/harness/session/uuid.ts +54 -0
- wmo/harness/vendor/pi-agent/src/harness/skills.ts +375 -0
- wmo/harness/vendor/pi-agent/src/harness/system-prompt.ts +34 -0
- wmo/harness/vendor/pi-agent/src/harness/types.ts +836 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/shell-output.ts +135 -0
- wmo/harness/vendor/pi-agent/src/harness/utils/truncate.ts +344 -0
- wmo/harness/vendor/pi-agent/src/index.ts +44 -0
- wmo/harness/vendor/pi-agent/src/node.ts +2 -0
- wmo/harness/vendor/pi-agent/src/proxy.ts +367 -0
- wmo/harness/vendor/pi-agent/src/types.ts +428 -0
- wmo/harness/vendor/pi-agent/test/agent-loop.test.ts +1351 -0
- wmo/harness/vendor/pi-agent/test/agent.test.ts +699 -0
- wmo/harness/vendor/pi-agent/test/e2e.test.ts +404 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness-stream.test.ts +213 -0
- wmo/harness/vendor/pi-agent/test/harness/agent-harness.test.ts +608 -0
- wmo/harness/vendor/pi-agent/test/harness/compaction.test.ts +655 -0
- wmo/harness/vendor/pi-agent/test/harness/nodejs-env.test.ts +321 -0
- wmo/harness/vendor/pi-agent/test/harness/prompt-templates.test.ts +90 -0
- wmo/harness/vendor/pi-agent/test/harness/repo.test.ts +68 -0
- wmo/harness/vendor/pi-agent/test/harness/resource-formatting.test.ts +24 -0
- wmo/harness/vendor/pi-agent/test/harness/session-test-utils.ts +55 -0
- wmo/harness/vendor/pi-agent/test/harness/session-uuid.test.ts +50 -0
- wmo/harness/vendor/pi-agent/test/harness/session.test.ts +156 -0
- wmo/harness/vendor/pi-agent/test/harness/skills.test.ts +116 -0
- wmo/harness/vendor/pi-agent/test/harness/storage.test.ts +299 -0
- wmo/harness/vendor/pi-agent/test/harness/system-prompt.test.ts +66 -0
- wmo/harness/vendor/pi-agent/test/harness/truncate.test.ts +169 -0
- wmo/harness/vendor/pi-agent/test/scratch/simple.ts +72 -0
- wmo/harness/vendor/pi-agent/test/utils/calculate.ts +32 -0
- wmo/harness/vendor/pi-agent/test/utils/get-current-time.ts +46 -0
- wmo/harness/vendor/pi-agent/tsconfig.build.json +13 -0
- wmo/harness/vendor/pi-agent/vitest.config.ts +19 -0
- wmo/harness/vendor/pi-agent/vitest.harness.config.ts +28 -0
- wmo/harness/vendor/vendor_pi.sh +59 -0
- wmo/harness/workspace_patch.py +270 -0
- wmo/ingest/__init__.py +47 -0
- wmo/ingest/adapter.py +72 -0
- wmo/ingest/base.py +114 -0
- wmo/ingest/braintrust.py +339 -0
- wmo/ingest/detect.py +126 -0
- wmo/ingest/langfuse.py +291 -0
- wmo/ingest/langsmith.py +444 -0
- wmo/ingest/mastra.py +330 -0
- wmo/ingest/messages.py +170 -0
- wmo/ingest/normalize.py +679 -0
- wmo/ingest/otel_genai.py +69 -0
- wmo/ingest/otel_writer.py +100 -0
- wmo/ingest/phoenix.py +150 -0
- wmo/ingest/postgres.py +246 -0
- wmo/ingest/posthog.py +320 -0
- wmo/ingest/quality.py +28 -0
- wmo/ingest/stream.py +209 -0
- wmo/ingest/testdata/sample_otlp.json +60 -0
- wmo/ingest/testdata/sample_spans.jsonl +3 -0
- wmo/optimize/__init__.py +25 -0
- wmo/optimize/base.py +143 -0
- wmo/optimize/gepa.py +806 -0
- wmo/optimize/judge.py +262 -0
- wmo/optimize/judge_quality.py +359 -0
- wmo/optimize/knn.py +468 -0
- wmo/optimize/numeric.py +152 -0
- wmo/optimize/outcomes.py +103 -0
- wmo/optimize/policy.py +669 -0
- wmo/optimize/report.py +231 -0
- wmo/optimize/reward.py +129 -0
- wmo/optimize/routing.py +373 -0
- wmo/platform/__init__.py +6 -0
- wmo/platform/auth.py +115 -0
- wmo/platform/client.py +551 -0
- wmo/platform/credentials.py +126 -0
- wmo/platform/transfer.py +158 -0
- wmo/providers/__init__.py +40 -0
- wmo/providers/_bedrock_chat.py +155 -0
- wmo/providers/_openai_common.py +182 -0
- wmo/providers/_responses_common.py +472 -0
- wmo/providers/anthropic.py +134 -0
- wmo/providers/azure_openai.py +296 -0
- wmo/providers/base.py +300 -0
- wmo/providers/bedrock.py +312 -0
- wmo/providers/models.py +205 -0
- wmo/providers/openai.py +143 -0
- wmo/providers/openai_responses.py +240 -0
- wmo/providers/pool.py +170 -0
- wmo/providers/registry.py +73 -0
- wmo/providers/retry.py +151 -0
- wmo/providers/tinker.py +936 -0
- wmo/providers/waterfall.py +336 -0
- wmo/research/__init__.py +81 -0
- wmo/research/ablation.py +133 -0
- wmo/research/concurrency_plot.py +523 -0
- wmo/research/concurrency_run.py +240 -0
- wmo/research/concurrency_scaling.py +270 -0
- wmo/research/gepa_scaling.py +274 -0
- wmo/research/pipeline.py +198 -0
- wmo/research/scaling_split.py +82 -0
- wmo/research/scenario_fidelity.py +198 -0
- wmo/research/scenario_recovery.py +92 -0
- wmo/research/seed_stability.py +90 -0
- wmo/research/trace_scaling.py +348 -0
- wmo/retrieval/__init__.py +6 -0
- wmo/retrieval/embedders.py +105 -0
- wmo/retrieval/leakfree.py +52 -0
- wmo/retrieval/retriever.py +173 -0
- wmo/scenarios/__init__.py +58 -0
- wmo/scenarios/builder.py +152 -0
- wmo/scenarios/mining/__init__.py +27 -0
- wmo/scenarios/mining/clustering.py +171 -0
- wmo/scenarios/mining/facets.py +226 -0
- wmo/scenarios/mining/selection.py +220 -0
- wmo/scenarios/synthesis/__init__.py +6 -0
- wmo/scenarios/synthesis/scenario_set.py +63 -0
- wmo/scenarios/synthesis/synthesizer.py +85 -0
- wmo/scenarios/verification/__init__.py +17 -0
- wmo/scenarios/verification/judge.py +97 -0
- wmo/scenarios/verification/verify.py +135 -0
- wmo/serving/__init__.py +5 -0
- wmo/serving/builds.py +451 -0
- wmo/serving/chat.py +878 -0
- wmo/serving/endpoint_config.py +64 -0
- wmo/serving/savings.py +250 -0
- wmo/serving/server.py +553 -0
- wmo/serving/traces_source.py +206 -0
- wmo/telemetry.py +213 -0
- wmo/tracking/__init__.py +36 -0
- wmo/tracking/clock.py +24 -0
- wmo/tracking/metered.py +125 -0
- wmo/tracking/pricing.py +99 -0
- wmo/tracking/store.py +31 -0
- wmo/tracking/tracker.py +149 -0
- world_model_optimizer-0.2.0.dist-info/METADATA +203 -0
- world_model_optimizer-0.2.0.dist-info/RECORD +308 -0
- world_model_optimizer-0.2.0.dist-info/WHEEL +4 -0
- world_model_optimizer-0.2.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
"""`wmo optimize harness <agent> harbor --mode distill`: the CLI face of on-policy distillation.
|
|
2
|
+
|
|
3
|
+
Kept out of `harness_app.py` the way eval's closed-loop half lives in
|
|
4
|
+
`eval_closed_loop.py`: `optimize()` validates the flag surface and routes here
|
|
5
|
+
early when distill mode is selected. This module owns the distill run's CLI
|
|
6
|
+
lifecycle: load and pin the run inputs (config, task splits, seed harness),
|
|
7
|
+
project the run cost into a confirmation table, drive `run_distillation` with
|
|
8
|
+
progress rendering, and print the gate verdict plus the serving handoff. The
|
|
9
|
+
optional `--promote` step writes `[models.agent]` through the settings save
|
|
10
|
+
path after an explicit confirmation.
|
|
11
|
+
|
|
12
|
+
Run-dir pinning mirrors `run-config.json` in the harbor search flow: a fresh
|
|
13
|
+
run records its CLI-level inputs in `distill-run.json` (task splits, backend,
|
|
14
|
+
the exact seed version and doc hash), and a resume reuses that record instead
|
|
15
|
+
of live flags, rejecting explicit flags that conflict with it. The distill
|
|
16
|
+
config itself is snapshotted by the run store as `config.toml`, which is what
|
|
17
|
+
a bare `--resume` (no `--distill-config`) loads.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections.abc import Callable
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Literal
|
|
25
|
+
|
|
26
|
+
import typer
|
|
27
|
+
from pydantic import BaseModel, ConfigDict, ValidationError
|
|
28
|
+
from rich.console import Console
|
|
29
|
+
from rich.markup import escape
|
|
30
|
+
from rich.prompt import Confirm
|
|
31
|
+
from rich.table import Table
|
|
32
|
+
|
|
33
|
+
from wmo.agents.default import default_agent
|
|
34
|
+
from wmo.config.settings import ModelRole, load_settings, save_settings, settings_path
|
|
35
|
+
from wmo.config.store import validate_name
|
|
36
|
+
from wmo.distill.config import DistillConfig, load_distill_config
|
|
37
|
+
from wmo.distill.cost import CostEstimate, estimate_run_cost
|
|
38
|
+
from wmo.distill.loop import (
|
|
39
|
+
DistillBudgetError,
|
|
40
|
+
DistillProgress,
|
|
41
|
+
DistillResult,
|
|
42
|
+
run_distillation,
|
|
43
|
+
)
|
|
44
|
+
from wmo.distill.rollouts import E2B_SANDBOXES_PER_TRIAL
|
|
45
|
+
from wmo.distill.store import (
|
|
46
|
+
DEFAULT_TINKER_OPENAI_ENDPOINT,
|
|
47
|
+
AdapterStore,
|
|
48
|
+
DistillRunStore,
|
|
49
|
+
build_handoff_toml,
|
|
50
|
+
)
|
|
51
|
+
from wmo.harness.doc import HarnessDoc
|
|
52
|
+
from wmo.harness.e2b_reap import (
|
|
53
|
+
DEFAULT_E2B_SANDBOX_CAP,
|
|
54
|
+
E2B_API_KEY_ENV,
|
|
55
|
+
E2B_SANDBOX_CAP_ENV,
|
|
56
|
+
CapacityCheck,
|
|
57
|
+
check_capacity,
|
|
58
|
+
is_credential_error,
|
|
59
|
+
)
|
|
60
|
+
from wmo.harness.population import write_json_atomic
|
|
61
|
+
from wmo.harness.store import HarnessStore
|
|
62
|
+
|
|
63
|
+
DISTILL_RUN_RECORD = "distill-run.json"
|
|
64
|
+
"""The CLI-level pin file inside the run dir (see `DistillCliRunRecord`)."""
|
|
65
|
+
|
|
66
|
+
_PI_NODE_RUNTIME = "pi-node"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class DistillCliRunRecord(BaseModel):
|
|
70
|
+
"""The CLI inputs pinned into `distill-run.json` when a distill run starts.
|
|
71
|
+
|
|
72
|
+
A resume command carries only `--run-dir` (that is what a budget abort
|
|
73
|
+
prints), so everything else the CLI resolved at start is recorded here and
|
|
74
|
+
reloaded on resume; explicit flags that conflict with the record are
|
|
75
|
+
rejected instead of silently changing what is being trained or gated.
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
model_config = ConfigDict(frozen=True)
|
|
79
|
+
|
|
80
|
+
agent: str
|
|
81
|
+
"""The AGENT argument exactly as given (may carry an @ref)."""
|
|
82
|
+
|
|
83
|
+
backend: Literal["local", "e2b"]
|
|
84
|
+
seed_version: int | None
|
|
85
|
+
"""The stored seed version; None means the built-in default agent."""
|
|
86
|
+
|
|
87
|
+
seed_doc_hash: str
|
|
88
|
+
"""The resolved seed document's hash; a resume must re-resolve to it."""
|
|
89
|
+
|
|
90
|
+
train_task_ids: tuple[str, ...]
|
|
91
|
+
holdout_task_ids: tuple[str, ...]
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def run_distill(
|
|
95
|
+
console: Console,
|
|
96
|
+
*,
|
|
97
|
+
agent_name: str | None,
|
|
98
|
+
distill_config_path: str | None,
|
|
99
|
+
task_ids_path: str | None,
|
|
100
|
+
holdout_task_ids_path: str | None,
|
|
101
|
+
run_dir: str | None,
|
|
102
|
+
backend: str | None,
|
|
103
|
+
resume: bool,
|
|
104
|
+
yes: bool,
|
|
105
|
+
promote: bool,
|
|
106
|
+
root: str,
|
|
107
|
+
) -> None:
|
|
108
|
+
"""Run (or resume) one on-policy distillation from the CLI.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
console: The CLI's rich console (product output goes through it).
|
|
112
|
+
agent_name: The AGENT argument; the literal 'pi' is the built-in
|
|
113
|
+
default agent, 'name@ref' seeds from the harness store.
|
|
114
|
+
distill_config_path: The per-run distill TOML; required to start a
|
|
115
|
+
fresh run. On resume None loads the run dir's config.toml
|
|
116
|
+
snapshot, and an explicit path wins over it (the documented
|
|
117
|
+
budget-abort recovery is editing budget.max_usd and resuming).
|
|
118
|
+
task_ids_path: JSON array of train task ids; required to start.
|
|
119
|
+
holdout_task_ids_path: JSON array of holdout task ids; required to
|
|
120
|
+
start. Baselines and the gate are measured here.
|
|
121
|
+
run_dir: The run's durable state directory; always required.
|
|
122
|
+
backend: An explicit `--backend` override for the config's
|
|
123
|
+
harbor.backend, or None when the flag was not given.
|
|
124
|
+
resume: Continue the run recorded in `run_dir`.
|
|
125
|
+
yes: Skip the cost confirmation (see `_confirm_cost` for the one
|
|
126
|
+
case where confirmation is forced anyway).
|
|
127
|
+
promote: After an accepted gate, offer to write `[models.agent]`
|
|
128
|
+
pointing at the distilled adapter (explicit confirmation).
|
|
129
|
+
root: The project dir (harness store, adapter store, settings).
|
|
130
|
+
|
|
131
|
+
Raises:
|
|
132
|
+
typer.BadParameter: On any invalid or conflicting input; the message
|
|
133
|
+
names the flag and what to do.
|
|
134
|
+
typer.Exit: When the user declines a confirmation (code 0) or the
|
|
135
|
+
run fails/aborts (code 1).
|
|
136
|
+
"""
|
|
137
|
+
# Deferred import: harness_app routes to this module at module scope, so
|
|
138
|
+
# importing its helpers back at module scope would be a circular import.
|
|
139
|
+
from wmo.cli.harness_app import DEFAULT_SEED_AGENT, _load_harbor_task_ids
|
|
140
|
+
|
|
141
|
+
if agent_name is None:
|
|
142
|
+
raise typer.BadParameter(
|
|
143
|
+
"provide the agent NAME whose harness the pi trials run (the literal "
|
|
144
|
+
f"{DEFAULT_SEED_AGENT!r} is the built-in default agent): "
|
|
145
|
+
"`wmo optimize harness pi harbor --mode distill --distill-config run.toml "
|
|
146
|
+
"--task-ids train.json --holdout-task-ids holdout.json --run-dir <dir>`"
|
|
147
|
+
)
|
|
148
|
+
backend_override: Literal["local", "e2b"] | None
|
|
149
|
+
if backend is None:
|
|
150
|
+
backend_override = None
|
|
151
|
+
elif backend == "e2b":
|
|
152
|
+
backend_override = "e2b"
|
|
153
|
+
elif backend == "local":
|
|
154
|
+
backend_override = "local"
|
|
155
|
+
else:
|
|
156
|
+
raise typer.BadParameter(f"unknown --backend {backend!r}; choose local or e2b")
|
|
157
|
+
if run_dir is None:
|
|
158
|
+
raise typer.BadParameter(
|
|
159
|
+
"--run-dir is required for --mode distill: it holds all durable run "
|
|
160
|
+
"state (config snapshot, metrics, checkpoints, rollout artifacts)"
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
run_path = Path(run_dir)
|
|
164
|
+
record_path = run_path / DISTILL_RUN_RECORD
|
|
165
|
+
store = DistillRunStore(run_path)
|
|
166
|
+
seed_version: int | None
|
|
167
|
+
if resume:
|
|
168
|
+
record = _load_record(record_path)
|
|
169
|
+
_reject_resume_conflicts(
|
|
170
|
+
record,
|
|
171
|
+
agent_name=agent_name,
|
|
172
|
+
backend=backend_override,
|
|
173
|
+
task_ids_path=task_ids_path,
|
|
174
|
+
holdout_task_ids_path=holdout_task_ids_path,
|
|
175
|
+
load_task_ids=_load_harbor_task_ids,
|
|
176
|
+
)
|
|
177
|
+
train_ids = record.train_task_ids
|
|
178
|
+
holdout_ids = record.holdout_task_ids
|
|
179
|
+
cfg = _load_config(
|
|
180
|
+
Path(distill_config_path) if distill_config_path is not None else store.config_path
|
|
181
|
+
)
|
|
182
|
+
base, seed_doc = _pinned_seed_doc(root, record, DEFAULT_SEED_AGENT)
|
|
183
|
+
seed_version = record.seed_version
|
|
184
|
+
effective_backend = record.backend
|
|
185
|
+
else:
|
|
186
|
+
if store.config_path.exists():
|
|
187
|
+
raise typer.BadParameter(
|
|
188
|
+
f"{run_path} already holds a distillation run; pass --resume to "
|
|
189
|
+
"continue it or choose a fresh --run-dir"
|
|
190
|
+
)
|
|
191
|
+
if record_path.exists():
|
|
192
|
+
# The record is written before the loop starts, but the loop's very
|
|
193
|
+
# first durable action is the config.toml snapshot: a record with no
|
|
194
|
+
# snapshot means a previous start failed before doing (or spending)
|
|
195
|
+
# anything, so treat the dir as fresh instead of bricking it.
|
|
196
|
+
console.print(
|
|
197
|
+
f"[yellow]note[/yellow] {run_path} holds a run record from a start "
|
|
198
|
+
"that never began (no config.toml snapshot); starting fresh"
|
|
199
|
+
)
|
|
200
|
+
missing = [
|
|
201
|
+
flag
|
|
202
|
+
for flag, value in (
|
|
203
|
+
("--distill-config", distill_config_path),
|
|
204
|
+
("--task-ids", task_ids_path),
|
|
205
|
+
("--holdout-task-ids", holdout_task_ids_path),
|
|
206
|
+
)
|
|
207
|
+
if value is None
|
|
208
|
+
]
|
|
209
|
+
if missing:
|
|
210
|
+
raise typer.BadParameter(
|
|
211
|
+
f"{', '.join(missing)} required to start a distillation run "
|
|
212
|
+
"(a resume reuses the run dir's recorded inputs instead)"
|
|
213
|
+
)
|
|
214
|
+
assert distill_config_path is not None # narrowed by the missing check
|
|
215
|
+
assert task_ids_path is not None and holdout_task_ids_path is not None
|
|
216
|
+
cfg = _load_config(Path(distill_config_path))
|
|
217
|
+
train_ids = _load_harbor_task_ids(Path(task_ids_path))
|
|
218
|
+
holdout_ids = _load_harbor_task_ids(Path(holdout_task_ids_path))
|
|
219
|
+
base, seed_doc, seed_version = _resolve_seed_doc(root, agent_name, DEFAULT_SEED_AGENT)
|
|
220
|
+
effective_backend = backend_override if backend_override is not None else cfg.harbor.backend
|
|
221
|
+
|
|
222
|
+
overlap = sorted(set(train_ids) & set(holdout_ids))
|
|
223
|
+
if overlap:
|
|
224
|
+
raise typer.BadParameter(
|
|
225
|
+
f"task id(s) {', '.join(overlap)} appear in BOTH --task-ids and "
|
|
226
|
+
"--holdout-task-ids; the gate is only meaningful on tasks the student "
|
|
227
|
+
"never trained on, so make the splits disjoint"
|
|
228
|
+
)
|
|
229
|
+
if effective_backend != cfg.harbor.backend:
|
|
230
|
+
cfg = cfg.model_copy(
|
|
231
|
+
update={"harbor": cfg.harbor.model_copy(update={"backend": effective_backend})}
|
|
232
|
+
)
|
|
233
|
+
runtime_kind = seed_doc.runtime_kind()
|
|
234
|
+
if runtime_kind != _PI_NODE_RUNTIME:
|
|
235
|
+
raise typer.BadParameter(
|
|
236
|
+
f"distillation rollouts drive the pi agent through harbor trials, but "
|
|
237
|
+
f"harness {agent_name!r} has runtime kind {runtime_kind!r}; seed from a "
|
|
238
|
+
f"pi-node harness (the built-in {DEFAULT_SEED_AGENT!r} agent, or a "
|
|
239
|
+
"version optimized from it)"
|
|
240
|
+
)
|
|
241
|
+
template_path = Path(cfg.harbor.job_template)
|
|
242
|
+
if not template_path.is_file():
|
|
243
|
+
raise typer.BadParameter(
|
|
244
|
+
f"harbor.job_template {template_path} does not exist; point the distill "
|
|
245
|
+
"config's [harbor] job_template at the harbor JobConfig YAML/JSON the "
|
|
246
|
+
"rollouts should run"
|
|
247
|
+
)
|
|
248
|
+
if effective_backend == "e2b":
|
|
249
|
+
_preflight_e2b_capacity(console, trial_concurrency=cfg.train.trial_concurrency)
|
|
250
|
+
|
|
251
|
+
console.print(
|
|
252
|
+
f"distilling [bold]{base}[/bold]: student {cfg.student.base_model} <- teacher "
|
|
253
|
+
f"{cfg.teacher.checkpoint or cfg.teacher.model}, {cfg.train.steps} step(s) x "
|
|
254
|
+
f"{cfg.train.tasks_per_batch} task(s) x {cfg.train.group_size} attempt(s), "
|
|
255
|
+
f"{len(train_ids)} train / {len(holdout_ids)} holdout task(s), "
|
|
256
|
+
f"backend {effective_backend} -> {run_path}"
|
|
257
|
+
)
|
|
258
|
+
estimate = estimate_run_cost(cfg, len(train_ids), len(holdout_ids))
|
|
259
|
+
_print_cost_estimate(console, cfg, estimate)
|
|
260
|
+
_confirm_cost(console, estimate, cfg.budget.max_usd, yes=yes)
|
|
261
|
+
|
|
262
|
+
if not resume:
|
|
263
|
+
# Recorded only now: inputs validated and the user confirmed, so a
|
|
264
|
+
# declined or failed start never poisons the run dir.
|
|
265
|
+
record = DistillCliRunRecord(
|
|
266
|
+
agent=agent_name,
|
|
267
|
+
backend=effective_backend,
|
|
268
|
+
seed_version=seed_version,
|
|
269
|
+
seed_doc_hash=seed_doc.doc_hash,
|
|
270
|
+
train_task_ids=train_ids,
|
|
271
|
+
holdout_task_ids=holdout_ids,
|
|
272
|
+
)
|
|
273
|
+
run_path.mkdir(parents=True, exist_ok=True)
|
|
274
|
+
write_json_atomic(record_path, record.model_dump(mode="json"))
|
|
275
|
+
|
|
276
|
+
def _on_progress(event: DistillProgress) -> None:
|
|
277
|
+
spend = f" (${event.spent_usd:.2f} spent)" if event.spent_usd > 0 else ""
|
|
278
|
+
# The literal bracket is escaped so rich does not eat the phase as a markup tag.
|
|
279
|
+
console.print(f" \\[{event.phase}] {escape(event.message)}{spend}")
|
|
280
|
+
|
|
281
|
+
try:
|
|
282
|
+
result = run_distillation(
|
|
283
|
+
base,
|
|
284
|
+
cfg,
|
|
285
|
+
seed_doc,
|
|
286
|
+
list(train_ids),
|
|
287
|
+
list(holdout_ids),
|
|
288
|
+
run_path,
|
|
289
|
+
resume=resume,
|
|
290
|
+
on_progress=_on_progress,
|
|
291
|
+
adapter_store=AdapterStore(root),
|
|
292
|
+
# Resume commands must print the agent string as typed (it may carry
|
|
293
|
+
# an @ref that `base` strips), or the printed command would trip the
|
|
294
|
+
# CLI's resume conflict check.
|
|
295
|
+
cli_agent=agent_name,
|
|
296
|
+
)
|
|
297
|
+
except DistillBudgetError as exc:
|
|
298
|
+
console.print(f"[red]budget exhausted[/red] {escape(str(exc))}")
|
|
299
|
+
console.print(f"resume with: [bold]{escape(exc.resume_command)}[/bold]", soft_wrap=True)
|
|
300
|
+
raise typer.Exit(1) from exc
|
|
301
|
+
except ValueError as exc:
|
|
302
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
303
|
+
except (RuntimeError, ImportError) as exc:
|
|
304
|
+
console.print(f"[red]distillation failed[/red] {escape(str(exc))}")
|
|
305
|
+
raise typer.Exit(1) from exc
|
|
306
|
+
|
|
307
|
+
_print_result(
|
|
308
|
+
console, result, store, adapters=AdapterStore(root), base_model=cfg.student.base_model
|
|
309
|
+
)
|
|
310
|
+
if promote:
|
|
311
|
+
_maybe_promote(console, result, cfg, root)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
# -- input resolution ------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def _load_config(path: Path) -> DistillConfig:
|
|
318
|
+
"""Load the distill TOML, turning load failures into usage errors."""
|
|
319
|
+
try:
|
|
320
|
+
return load_distill_config(path)
|
|
321
|
+
except FileNotFoundError as exc:
|
|
322
|
+
raise typer.BadParameter(
|
|
323
|
+
f"{exc} (a fresh run needs --distill-config; a resume reads the run "
|
|
324
|
+
"dir's config.toml snapshot)"
|
|
325
|
+
) from exc
|
|
326
|
+
except ValueError as exc:
|
|
327
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def _load_record(path: Path) -> DistillCliRunRecord:
|
|
331
|
+
"""Load the pinned `distill-run.json` for a resume."""
|
|
332
|
+
try:
|
|
333
|
+
text = path.read_text(encoding="utf-8")
|
|
334
|
+
except FileNotFoundError as exc:
|
|
335
|
+
raise typer.BadParameter(
|
|
336
|
+
f"--resume found no {DISTILL_RUN_RECORD} under {path.parent}; start the "
|
|
337
|
+
"run once without --resume"
|
|
338
|
+
) from exc
|
|
339
|
+
try:
|
|
340
|
+
return DistillCliRunRecord.model_validate_json(text)
|
|
341
|
+
except ValidationError as exc:
|
|
342
|
+
raise typer.BadParameter(f"cannot load {path}: {exc}") from exc
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _reject_resume_conflicts(
|
|
346
|
+
record: DistillCliRunRecord,
|
|
347
|
+
*,
|
|
348
|
+
agent_name: str,
|
|
349
|
+
backend: str | None,
|
|
350
|
+
task_ids_path: str | None,
|
|
351
|
+
holdout_task_ids_path: str | None,
|
|
352
|
+
load_task_ids: Callable[[Path], tuple[str, ...]],
|
|
353
|
+
) -> None:
|
|
354
|
+
"""Reject explicit flags that conflict with the recorded run inputs."""
|
|
355
|
+
conflicts: list[str] = []
|
|
356
|
+
if agent_name != record.agent:
|
|
357
|
+
conflicts.append(f"AGENT {agent_name!r} != recorded {record.agent!r}")
|
|
358
|
+
if backend is not None and backend != record.backend:
|
|
359
|
+
conflicts.append(f"--backend {backend!r} != recorded {record.backend!r}")
|
|
360
|
+
if task_ids_path is not None and load_task_ids(Path(task_ids_path)) != record.train_task_ids:
|
|
361
|
+
conflicts.append("--task-ids differs from the recorded train split")
|
|
362
|
+
if (
|
|
363
|
+
holdout_task_ids_path is not None
|
|
364
|
+
and load_task_ids(Path(holdout_task_ids_path)) != record.holdout_task_ids
|
|
365
|
+
):
|
|
366
|
+
conflicts.append("--holdout-task-ids differs from the recorded holdout split")
|
|
367
|
+
if conflicts:
|
|
368
|
+
raise typer.BadParameter(
|
|
369
|
+
f"--resume uses the recorded {DISTILL_RUN_RECORD}; conflicting flag(s): "
|
|
370
|
+
+ "; ".join(conflicts)
|
|
371
|
+
+ ". Drop them to continue this run, or start a fresh --run-dir"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _resolve_seed_doc(
|
|
376
|
+
root: str, agent_ref: str, default_seed_name: str
|
|
377
|
+
) -> tuple[str, HarnessDoc, int | None]:
|
|
378
|
+
"""Resolve the AGENT positional to the harness the pi trials run.
|
|
379
|
+
|
|
380
|
+
Mirrors the harbor search's seed protocol: the bare default-agent literal
|
|
381
|
+
is ALWAYS the built-in agent; 'name@ref' loads a stored version. Returns
|
|
382
|
+
the base name, the document, and the resolved store version (None for the
|
|
383
|
+
built-in seed) so a resume can pin exactly what the run started from.
|
|
384
|
+
"""
|
|
385
|
+
base, _, ref = agent_ref.partition("@")
|
|
386
|
+
try:
|
|
387
|
+
validate_name(base)
|
|
388
|
+
except ValueError as exc:
|
|
389
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
390
|
+
if base == default_seed_name and not ref:
|
|
391
|
+
return base, default_agent(base), None
|
|
392
|
+
try:
|
|
393
|
+
doc = HarnessStore(root).load(base, ref or None)
|
|
394
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
395
|
+
raise typer.BadParameter(
|
|
396
|
+
f"{exc}; the built-in default agent is the literal {default_seed_name!r}"
|
|
397
|
+
) from exc
|
|
398
|
+
return base, doc, doc.version
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def _pinned_seed_doc(
|
|
402
|
+
root: str, record: DistillCliRunRecord, default_seed_name: str
|
|
403
|
+
) -> tuple[str, HarnessDoc]:
|
|
404
|
+
"""Re-resolve the recorded seed for a resume, never a live movable ref.
|
|
405
|
+
|
|
406
|
+
The record pins the exact version and doc hash, so champion movement (or
|
|
407
|
+
any store edit) between sessions cannot silently change which harness the
|
|
408
|
+
remaining trials run.
|
|
409
|
+
"""
|
|
410
|
+
base = record.agent.partition("@")[0]
|
|
411
|
+
if record.seed_version is None:
|
|
412
|
+
doc = default_agent(base)
|
|
413
|
+
else:
|
|
414
|
+
try:
|
|
415
|
+
doc = HarnessStore(root).load(base, str(record.seed_version))
|
|
416
|
+
except (FileNotFoundError, ValueError) as exc:
|
|
417
|
+
raise typer.BadParameter(
|
|
418
|
+
f"cannot reload the recorded seed {base}@v{record.seed_version}: {exc}"
|
|
419
|
+
) from exc
|
|
420
|
+
if doc.doc_hash != record.seed_doc_hash:
|
|
421
|
+
raise typer.BadParameter(
|
|
422
|
+
f"the recorded seed {base} resolved to doc hash {doc.doc_hash[:12]} but "
|
|
423
|
+
f"this run pinned {record.seed_doc_hash[:12]}; restore the recorded "
|
|
424
|
+
"harness version or start a fresh --run-dir"
|
|
425
|
+
)
|
|
426
|
+
return base, doc
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
# -- e2b capacity preflight ------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _preflight_e2b_capacity(console: Console, *, trial_concurrency: int) -> None:
|
|
433
|
+
"""Refuse to start an e2b run that cannot claim the concurrency it asks for.
|
|
434
|
+
|
|
435
|
+
E2B caps concurrent sandboxes per account, and a running trial holds
|
|
436
|
+
`E2B_SANDBOXES_PER_TRIAL` of them (harbor's task environment, which lives for its own
|
|
437
|
+
multi-hour timeout; terminus-2 itself runs in this process and needs no sandbox of its
|
|
438
|
+
own). When orphans of an earlier crashed run fill
|
|
439
|
+
the account, every trial fails at sandbox creation with a 429 and the run produces zero
|
|
440
|
+
token spans, which reads exactly like a broken model. So: count what is running, reclaim
|
|
441
|
+
this machine's provable orphans (exact ids whose owning process is gone), and fail with the
|
|
442
|
+
numbers if that is still not enough. The account-wide sweep is never automatic; the message
|
|
443
|
+
names it instead.
|
|
444
|
+
|
|
445
|
+
Raises:
|
|
446
|
+
typer.BadParameter: If capacity cannot be measured (missing extra or credential) or
|
|
447
|
+
too few slots are free after reaping the safe class.
|
|
448
|
+
"""
|
|
449
|
+
required = trial_concurrency * E2B_SANDBOXES_PER_TRIAL
|
|
450
|
+
try:
|
|
451
|
+
check = check_capacity(required=required)
|
|
452
|
+
except ImportError as error:
|
|
453
|
+
raise typer.BadParameter(
|
|
454
|
+
f"{error}; the distill config selects harbor.backend = 'e2b'"
|
|
455
|
+
) from error
|
|
456
|
+
except ValueError as error:
|
|
457
|
+
raise typer.BadParameter(str(error)) from error
|
|
458
|
+
except Exception as error: # noqa: BLE001 - a monitoring call must not break a resume
|
|
459
|
+
if is_credential_error(error):
|
|
460
|
+
raise typer.BadParameter(
|
|
461
|
+
f"E2B rejected the sandbox capacity check ({error}); harbor.backend = 'e2b' "
|
|
462
|
+
f"runs every trial in E2B, so set ${E2B_API_KEY_ENV} to an account key (or "
|
|
463
|
+
"switch the distill config to backend = 'local')"
|
|
464
|
+
) from error
|
|
465
|
+
console.print(
|
|
466
|
+
f"[yellow]warning[/yellow] could not check E2B sandbox capacity "
|
|
467
|
+
f"({type(error).__name__}: {escape(str(error))}); starting anyway"
|
|
468
|
+
)
|
|
469
|
+
return
|
|
470
|
+
if check.reaped:
|
|
471
|
+
console.print(
|
|
472
|
+
f"reaped {check.reaped} orphaned E2B sandbox(es) from dead local runs "
|
|
473
|
+
f"({check.alive_before} -> {check.alive} of {check.cap} in use)"
|
|
474
|
+
)
|
|
475
|
+
if not check.ok:
|
|
476
|
+
raise typer.BadParameter(_capacity_failure_message(check, trial_concurrency))
|
|
477
|
+
console.print(
|
|
478
|
+
f"e2b capacity ok: {check.alive}/{check.cap} sandbox(es) in use, {check.free} free, "
|
|
479
|
+
f"{required} needed ({E2B_SANDBOXES_PER_TRIAL} per trial x "
|
|
480
|
+
f"train.trial_concurrency={trial_concurrency})"
|
|
481
|
+
)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def _capacity_failure_message(check: CapacityCheck, trial_concurrency: int) -> str:
|
|
485
|
+
"""The actionable message for a run that cannot get enough sandbox slots."""
|
|
486
|
+
reaped = (
|
|
487
|
+
f" Reaping orphans of dead local runs freed {check.reaped} slot(s) and was not enough."
|
|
488
|
+
if check.reaped
|
|
489
|
+
else " No orphan of a dead local run was left to reclaim."
|
|
490
|
+
)
|
|
491
|
+
affordable = check.free // E2B_SANDBOXES_PER_TRIAL
|
|
492
|
+
lower = f"lower train.trial_concurrency to at most {affordable}, " if affordable >= 1 else ""
|
|
493
|
+
return (
|
|
494
|
+
f"not enough free E2B sandbox slots: {check.alive} of {check.cap} concurrent "
|
|
495
|
+
f"sandboxes are in use, leaving {check.free} free, but this run needs "
|
|
496
|
+
f"{check.required} ({E2B_SANDBOXES_PER_TRIAL} per trial x "
|
|
497
|
+
f"train.trial_concurrency={trial_concurrency}: harbor's task environment)"
|
|
498
|
+
f".{reaped} Either run `wmo e2b reap --stale-minutes 60 --yes` to kill older "
|
|
499
|
+
f"harbor trial sandboxes (account-wide: it can kill another machine's run), {lower}wait "
|
|
500
|
+
f"for the other runs to finish, or raise the account cap (set ${E2B_SANDBOX_CAP_ENV} "
|
|
501
|
+
f"when your cap is not {DEFAULT_E2B_SANDBOX_CAP})"
|
|
502
|
+
)
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# -- cost confirmation -----------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
def _print_cost_estimate(console: Console, cfg: DistillConfig, estimate: CostEstimate) -> None:
|
|
509
|
+
"""Render the per-meter cost projection; unpriced meters print "unknown"."""
|
|
510
|
+
table = Table(title="Distillation cost estimate")
|
|
511
|
+
table.add_column("Meter", no_wrap=True)
|
|
512
|
+
table.add_column("Tokens", justify="right")
|
|
513
|
+
table.add_column("$/Mtok", justify="right")
|
|
514
|
+
table.add_column("USD", justify="right")
|
|
515
|
+
for line in estimate.lines:
|
|
516
|
+
table.add_row(
|
|
517
|
+
line.meter,
|
|
518
|
+
f"{line.tokens:,}",
|
|
519
|
+
"unknown" if line.price_per_mtok is None else f"{line.price_per_mtok:.3f}",
|
|
520
|
+
"unknown" if line.usd is None else f"{line.usd:.2f}",
|
|
521
|
+
)
|
|
522
|
+
console.print(table)
|
|
523
|
+
cap = (
|
|
524
|
+
f"hard cap budget.max_usd=${cfg.budget.max_usd:.2f}"
|
|
525
|
+
if cfg.budget.max_usd is not None
|
|
526
|
+
else "no budget.max_usd cap"
|
|
527
|
+
)
|
|
528
|
+
warmup = f"{estimate.warmup_episodes} warmup + " if estimate.warmup_episodes > 0 else ""
|
|
529
|
+
console.print(
|
|
530
|
+
f"{estimate.train_episodes} train + {warmup}{estimate.eval_episodes} interim-eval + "
|
|
531
|
+
f"{estimate.baseline_episodes} gate/baseline episode(s); priced total "
|
|
532
|
+
f"${estimate.priced_usd:.2f}; {cap}"
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def _confirm_cost(
|
|
537
|
+
console: Console, estimate: CostEstimate, max_usd: float | None, *, yes: bool
|
|
538
|
+
) -> None:
|
|
539
|
+
"""Confirm the projected spend before anything is run.
|
|
540
|
+
|
|
541
|
+
The rule: `--yes` is honored whenever the spend is accountable, meaning
|
|
542
|
+
the estimate is fully priced OR `budget.max_usd` caps the worst case.
|
|
543
|
+
When unpriced meters exist AND `budget.max_usd` is unset, the run's spend
|
|
544
|
+
is unbounded and unaccounted, so interactive confirmation is forced even
|
|
545
|
+
with `--yes`; a non-interactive invocation in that state is rejected with
|
|
546
|
+
instructions (price the meters or set the cap).
|
|
547
|
+
|
|
548
|
+
Raises:
|
|
549
|
+
typer.BadParameter: Unbounded spend in a non-interactive session.
|
|
550
|
+
typer.Exit: The user declined (exit code 0).
|
|
551
|
+
"""
|
|
552
|
+
if estimate.unpriced_meters and max_usd is None:
|
|
553
|
+
meters = ", ".join(estimate.unpriced_meters)
|
|
554
|
+
console.print(
|
|
555
|
+
f"[yellow]warning[/yellow] meter(s) {meters} have no \\[pricing] entry and "
|
|
556
|
+
"budget.max_usd is unset: the run's spend is unbounded and unaccounted, "
|
|
557
|
+
"so --yes does not apply here"
|
|
558
|
+
)
|
|
559
|
+
if not console.is_terminal:
|
|
560
|
+
raise typer.BadParameter(
|
|
561
|
+
f"cannot start with unbounded spend non-interactively: meter(s) "
|
|
562
|
+
f"{meters} are unpriced and budget.max_usd is unset; add [pricing] "
|
|
563
|
+
"entries for them or set [budget] max_usd in the distill config, "
|
|
564
|
+
"or run at a TTY to confirm explicitly"
|
|
565
|
+
)
|
|
566
|
+
if not Confirm.ask("Proceed with unbounded spend?", default=False):
|
|
567
|
+
raise typer.Exit(0)
|
|
568
|
+
return
|
|
569
|
+
if console.is_terminal and not yes and not Confirm.ask("Proceed?", default=True):
|
|
570
|
+
raise typer.Exit(0)
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
# -- completion output -----------------------------------------------------------------------
|
|
574
|
+
|
|
575
|
+
|
|
576
|
+
def _print_result(
|
|
577
|
+
console: Console,
|
|
578
|
+
result: DistillResult,
|
|
579
|
+
store: DistillRunStore,
|
|
580
|
+
*,
|
|
581
|
+
adapters: AdapterStore,
|
|
582
|
+
base_model: str,
|
|
583
|
+
) -> None:
|
|
584
|
+
"""Print the gate verdict, artifact paths, and the serving handoff snippet."""
|
|
585
|
+
gate = result.gate
|
|
586
|
+
color = "green" if gate.accepted else "yellow"
|
|
587
|
+
console.print(f"[{color}]gate[/{color}] {escape(gate.reason)}")
|
|
588
|
+
console.print(
|
|
589
|
+
f" holdout solve rates: teacher {gate.teacher_solve_rate:.3f}, "
|
|
590
|
+
f"student before {gate.student_before_solve_rate:.3f}, "
|
|
591
|
+
f"after {gate.student_after_solve_rate:.3f}"
|
|
592
|
+
)
|
|
593
|
+
if result.adapter_version is not None:
|
|
594
|
+
console.print(
|
|
595
|
+
f"[green]adapter[/green] [bold]{result.name}[/bold] v{result.adapter_version} "
|
|
596
|
+
f"(champion) -> {adapters.dir_for(result.name) / f'v{result.adapter_version}'}",
|
|
597
|
+
soft_wrap=True,
|
|
598
|
+
)
|
|
599
|
+
else:
|
|
600
|
+
console.print("adapter not promoted; the run dir keeps every artifact for inspection")
|
|
601
|
+
console.print(f"final sampler weights: {result.final_sampler_path}", soft_wrap=True)
|
|
602
|
+
console.print(f"resumable training state: {result.final_state_path}", soft_wrap=True)
|
|
603
|
+
console.print(
|
|
604
|
+
f"spend: ${result.spend.total_usd:.2f} total "
|
|
605
|
+
f"(this session ${result.spend.session_usd:.2f}) -> {result.run_dir}",
|
|
606
|
+
soft_wrap=True,
|
|
607
|
+
)
|
|
608
|
+
try:
|
|
609
|
+
handoff = build_handoff_toml(result.final_sampler_path, base_model=base_model)
|
|
610
|
+
except ValueError as exc:
|
|
611
|
+
console.print(f"[yellow]no handoff snippet[/yellow]: {escape(str(exc))}")
|
|
612
|
+
return
|
|
613
|
+
location = f" (written to {store.handoff_path})" if gate.accepted else ""
|
|
614
|
+
console.print(f"serving handoff{location}:")
|
|
615
|
+
console.print(escape(handoff))
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def _maybe_promote(console: Console, result: DistillResult, cfg: DistillConfig, root: str) -> None:
|
|
619
|
+
"""Write `[models.agent]` for an accepted adapter, after an explicit confirm.
|
|
620
|
+
|
|
621
|
+
The write changes what every subsequent local run and optimization uses as
|
|
622
|
+
the agent model, so it always asks, even under `--yes`; a rejected gate
|
|
623
|
+
skips the write with a warning.
|
|
624
|
+
"""
|
|
625
|
+
if result.adapter_version is None:
|
|
626
|
+
console.print(
|
|
627
|
+
"[yellow]--promote skipped[/yellow]: the gate rejected this adapter, so "
|
|
628
|
+
"\\[models.agent] was not changed (the handoff snippet above still works "
|
|
629
|
+
"for manual experiments)"
|
|
630
|
+
)
|
|
631
|
+
return
|
|
632
|
+
path = settings_path(root)
|
|
633
|
+
try:
|
|
634
|
+
confirmed = Confirm.ask(
|
|
635
|
+
f"Write models.agent = {result.final_sampler_path} to {path}?",
|
|
636
|
+
default=False,
|
|
637
|
+
)
|
|
638
|
+
except EOFError:
|
|
639
|
+
confirmed = False
|
|
640
|
+
if not confirmed:
|
|
641
|
+
console.print(
|
|
642
|
+
f"skipped writing \\[models.agent]; paste the handoff snippet into {path} when ready"
|
|
643
|
+
)
|
|
644
|
+
return
|
|
645
|
+
try:
|
|
646
|
+
settings = load_settings(root)
|
|
647
|
+
except ValueError as exc:
|
|
648
|
+
raise typer.BadParameter(str(exc)) from exc
|
|
649
|
+
settings.models.agent = ModelRole(
|
|
650
|
+
provider="openai",
|
|
651
|
+
model=result.final_sampler_path,
|
|
652
|
+
model_type=cfg.student.base_model,
|
|
653
|
+
endpoint=DEFAULT_TINKER_OPENAI_ENDPOINT,
|
|
654
|
+
)
|
|
655
|
+
save_settings(settings, root)
|
|
656
|
+
console.print(
|
|
657
|
+
f"[green]wrote[/green] \\[models.agent] -> {path} (set WMO_ENDPOINT_API_KEY to "
|
|
658
|
+
"your Tinker API key before running the agent)"
|
|
659
|
+
)
|