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
wmo/distill/loop.py
ADDED
|
@@ -0,0 +1,3499 @@
|
|
|
1
|
+
"""The on-policy distillation orchestrator: `run_distillation` and its step loop.
|
|
2
|
+
|
|
3
|
+
One run couples every distill layer end to end: preflight checks before any
|
|
4
|
+
spend, teacher-in-harness and student-before baselines on the holdout split,
|
|
5
|
+
the optional supervised warmup (teacher rollouts on the train split filtered
|
|
6
|
+
to `warmup.keep`, then `warmup.steps` cross_entropy passes so a student that
|
|
7
|
+
would sample only failures starts OPD from the teacher's successful
|
|
8
|
+
trajectories; `warmup.trajectories_from` loads another run's recorded
|
|
9
|
+
collection instead of collecting, charging nothing), then the training loop
|
|
10
|
+
(harbor rollouts from the current student sampler, prefix-merge datums,
|
|
11
|
+
teacher scoring, reverse-KL advantages, one optimizer step per training step
|
|
12
|
+
under the loss `train.loss` selects: `importance_sampling` or `ppo` over the
|
|
13
|
+
same advantage-carrying datums, or `topk_ce`, where the teacher instead top-k
|
|
14
|
+
scores each datum in one prefill-only request and the step trains rank-aligned
|
|
15
|
+
weighted cross_entropy replicas, the reverse-KL metric still coming from that
|
|
16
|
+
same request's realized logprobs), and finally the holdout gate that decides
|
|
17
|
+
whether the adapter is promoted into the `AdapterStore`.
|
|
18
|
+
|
|
19
|
+
Layout: everything lands in the `DistillRunStore` run directory. The rollout
|
|
20
|
+
collector writes its per-step `harbor/step-NNNN/` jobs dir under the run dir
|
|
21
|
+
for training batches (each trial's sampled token spans live inside its own
|
|
22
|
+
harbor trial dir, in `result.json`); eval batches (baselines, interim
|
|
23
|
+
evals, student-after) get their own isolated roots under
|
|
24
|
+
`eval-rollouts/<eval-name>/` so their harbor job dirs never collide with a
|
|
25
|
+
training step's, and the warmup phase's teacher trials likewise land under
|
|
26
|
+
`warmup-rollouts/`. Every batch (training step, warmup collection, eval)
|
|
27
|
+
additionally renders its first `train.log_sample_rollouts` episodes to
|
|
28
|
+
human-readable text under `samples/` and the tracker's samples table
|
|
29
|
+
(`wmo.distill.samples`), chat-template framing included.
|
|
30
|
+
|
|
31
|
+
The Tinker SDK stays an optional extra: the real service client is built
|
|
32
|
+
lazily only when no `service_client` is injected, and the thin `Sdk*`
|
|
33
|
+
adapters here (mirroring `SdkSampler` and `SdkLogprobScorer`) are the only
|
|
34
|
+
code that touches it. Every SDK call is deadline-bounded
|
|
35
|
+
(`wmo.distill.deadlines`), so a wedged session raises a typed
|
|
36
|
+
`TinkerDeadlineError` instead of hanging; see `SdkTrainingClient` for the
|
|
37
|
+
per-call idempotency reasoning that decides what expiry does. Tests drive
|
|
38
|
+
the whole loop with the deterministic fakes in `wmo.distill.fake_tinker`,
|
|
39
|
+
whose `FakeTrainingClient` asserts the tokens-in-tokens-out invariant on
|
|
40
|
+
every `forward_backward` batch.
|
|
41
|
+
|
|
42
|
+
Resume: cadenced `save_state` checkpoints land in the run store's manifest;
|
|
43
|
+
`resume=True` restores the latest checkpoint via `load_state` as the very
|
|
44
|
+
first call on a freshly created training client (tinker accepts LoadWeights
|
|
45
|
+
only on an uninitialized model, so preflight and the sampler refresh follow
|
|
46
|
+
the restore and validate the RESTORED weights), continues the
|
|
47
|
+
step count from the checkpoint, restores the prior sessions' USD spend from
|
|
48
|
+
the run store's spend ledger (written on every charge, so eval spend between
|
|
49
|
+
metrics rows survives), and reuses recorded baseline evals. A finished warmup
|
|
50
|
+
phase is recorded as the run store's `warmup.json` marker and never re-runs
|
|
51
|
+
(when no step checkpoint exists yet, its recorded post-warmup state is what
|
|
52
|
+
`load_state` restores); an UNfinished warmup re-runs whole, with the teacher
|
|
53
|
+
trials themselves resuming trial-level through harbor (the teacher's identity
|
|
54
|
+
is stable across sessions). Steps and student evals whose harbor job dirs
|
|
55
|
+
were left by a prior session re-run whole (the rollout collector wipes
|
|
56
|
+
stale-policy job dirs). A budget abort (`DistillBudgetError`), an
|
|
57
|
+
all-empty-batch abort (`DistillEmptyBatchError`, raised when consecutive
|
|
58
|
+
training steps show the student provider producing no completions) and a
|
|
59
|
+
degeneration abort (`DistillDegenerationError`, below) all carry the exact
|
|
60
|
+
resume command.
|
|
61
|
+
|
|
62
|
+
Degeneration tripwires: every step also pools the student's own sampled
|
|
63
|
+
logprobs and generation lengths into `entropy_per_token` and
|
|
64
|
+
`mean_generation_tokens` (`wmo.distill.tripwire`) and compares them against a
|
|
65
|
+
baseline THIS run measured at its first training step, which is persisted in
|
|
66
|
+
the checkpoint manifest so a resumed session never re-anchors on an already
|
|
67
|
+
degenerated policy. Breaches warn, and a kill-level streak aborts the run the
|
|
68
|
+
same way the budget path does. The thresholds are fractions of that measured
|
|
69
|
+
baseline, never absolute values; `TripwireConfig` records why.
|
|
70
|
+
"""
|
|
71
|
+
|
|
72
|
+
from __future__ import annotations
|
|
73
|
+
|
|
74
|
+
import hashlib
|
|
75
|
+
import logging
|
|
76
|
+
import math
|
|
77
|
+
import random
|
|
78
|
+
import time
|
|
79
|
+
from collections.abc import Callable, Mapping, Sequence
|
|
80
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
81
|
+
from pathlib import Path
|
|
82
|
+
from typing import TYPE_CHECKING, Literal, NoReturn, Protocol, cast, runtime_checkable
|
|
83
|
+
from uuid import uuid4
|
|
84
|
+
|
|
85
|
+
from llm_waterfall.types import ChatMessage
|
|
86
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
87
|
+
|
|
88
|
+
from wmo.config.store import validate_name
|
|
89
|
+
from wmo.distill.config import DistillConfig, PricingConfig
|
|
90
|
+
from wmo.distill.cost import (
|
|
91
|
+
METER_NAMES,
|
|
92
|
+
BudgetExhausted,
|
|
93
|
+
BudgetMeter,
|
|
94
|
+
CostLine,
|
|
95
|
+
MeterName,
|
|
96
|
+
SpanBilling,
|
|
97
|
+
batch_billing,
|
|
98
|
+
)
|
|
99
|
+
from wmo.distill.data import (
|
|
100
|
+
TopkCandidates,
|
|
101
|
+
TrainDatum,
|
|
102
|
+
attach_advantages,
|
|
103
|
+
build_datums,
|
|
104
|
+
build_topk_ce_datums,
|
|
105
|
+
to_tinker_datums,
|
|
106
|
+
to_tinker_sft_datums,
|
|
107
|
+
)
|
|
108
|
+
from wmo.distill.deadlines import TinkerDeadlineError, call_with_deadline, wait_with_deadline
|
|
109
|
+
from wmo.distill.gate import DistillGateRecord, gate_distillation
|
|
110
|
+
from wmo.distill.rendering import ChatRendering, RendererTokenizer, build_renderer
|
|
111
|
+
from wmo.distill.rollouts import RolloutStats, collect_rollouts, rollout_stats
|
|
112
|
+
from wmo.distill.samples import sample_rollouts, samples_markdown
|
|
113
|
+
from wmo.distill.store import (
|
|
114
|
+
AdapterStore,
|
|
115
|
+
DistillModelCard,
|
|
116
|
+
DistillRunStore,
|
|
117
|
+
WarmupRecord,
|
|
118
|
+
WarmupTrialsManifest,
|
|
119
|
+
build_handoff_toml,
|
|
120
|
+
)
|
|
121
|
+
from wmo.distill.teacher import (
|
|
122
|
+
CachedUsageTeacher,
|
|
123
|
+
EncodingTokenizer,
|
|
124
|
+
SdkLogprobScorer,
|
|
125
|
+
TeacherClient,
|
|
126
|
+
TinkerTeacher,
|
|
127
|
+
tokenizer_fingerprint_check,
|
|
128
|
+
)
|
|
129
|
+
from wmo.distill.tokens import TrialRecord
|
|
130
|
+
from wmo.distill.tracking import DistillTracker, build_tracker
|
|
131
|
+
from wmo.distill.tripwire import (
|
|
132
|
+
PolicyHealth,
|
|
133
|
+
TripwireBaseline,
|
|
134
|
+
TripwireBreach,
|
|
135
|
+
capture_baseline,
|
|
136
|
+
evaluate_breaches,
|
|
137
|
+
health_summary,
|
|
138
|
+
metric_ratio,
|
|
139
|
+
policy_health,
|
|
140
|
+
)
|
|
141
|
+
from wmo.harness.doc import (
|
|
142
|
+
MAX_OUTPUT_TOKENS_ID,
|
|
143
|
+
MAX_TURNS_ID,
|
|
144
|
+
TEMPERATURE_ID,
|
|
145
|
+
HarnessDoc,
|
|
146
|
+
Surface,
|
|
147
|
+
SurfaceKind,
|
|
148
|
+
)
|
|
149
|
+
from wmo.providers.base import ProviderConfig, ProviderKind
|
|
150
|
+
from wmo.providers.tinker import (
|
|
151
|
+
TINKER_API_KEY_ENV,
|
|
152
|
+
SampledSequenceLike,
|
|
153
|
+
SdkSampler,
|
|
154
|
+
evict_shared_sampling_client,
|
|
155
|
+
shared_sampling_client,
|
|
156
|
+
shared_service_client,
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
if TYPE_CHECKING:
|
|
160
|
+
import tinker
|
|
161
|
+
from tinker import types as tinker_types
|
|
162
|
+
|
|
163
|
+
logger = logging.getLogger(__name__)
|
|
164
|
+
|
|
165
|
+
_WARMUP_STREAMS = 8
|
|
166
|
+
"""Concurrent throwaway calls issued against freshly published sampler weights.
|
|
167
|
+
|
|
168
|
+
Not 1: a single serial token woke the model but a 64-episode wave still lost ~21% of its
|
|
169
|
+
episodes at a mean of 0.7 turns, because the cost is the sampler's ramp to many simultaneous
|
|
170
|
+
streams, not one weight load. Not 64: the point is to trigger the ramp, and the wave itself
|
|
171
|
+
finishes it, so this trades a few seconds of warmup for the tail of a 45-step run."""
|
|
172
|
+
|
|
173
|
+
IMPORTANCE_SAMPLING_LOSS = "importance_sampling"
|
|
174
|
+
PPO_LOSS = "ppo"
|
|
175
|
+
"""The clipped-ratio surrogate over the SAME datums as importance_sampling.
|
|
176
|
+
|
|
177
|
+
Both are values of the SDK's `types.LossFnType` (tinker 0.23.3) and both take
|
|
178
|
+
exactly the `to_tinker_datums` keyset; `ppo` differs only in what the service
|
|
179
|
+
does with the advantages: it bounds the update by clipping the policy ratio
|
|
180
|
+
(service-side epsilon, no `loss_fn_config` is sent) instead of relying on a
|
|
181
|
+
bounded advantage, which is why `train.loss = "ppo"` is the mode that wants
|
|
182
|
+
`advantage_clip` unset and `center_advantages = false`. With one
|
|
183
|
+
forward/backward per batch off a sampler refreshed every step, that ratio sits
|
|
184
|
+
at ~1 and the clip rarely binds (see `TrainConfig.loss`); `advantage_std` and
|
|
185
|
+
`grad_norm` in the metrics row are what actually show an outlier-driven step.
|
|
186
|
+
"""
|
|
187
|
+
|
|
188
|
+
CROSS_ENTROPY_LOSS = "cross_entropy"
|
|
189
|
+
"""The warmup phase's supervised loss (see `to_tinker_sft_datums` for the keyset)."""
|
|
190
|
+
|
|
191
|
+
ADVANTAGE_LOSS_BY_MODE = {
|
|
192
|
+
"importance_sampling": IMPORTANCE_SAMPLING_LOSS,
|
|
193
|
+
"ppo": PPO_LOSS,
|
|
194
|
+
}
|
|
195
|
+
"""Wire `loss_fn` per `train.loss` mode that trains advantages (topk_ce is CE)."""
|
|
196
|
+
|
|
197
|
+
DEFAULT_TITO_MEAN_TOLERANCE = 0.25
|
|
198
|
+
"""Max mean |issued - recomputed| logprob gap the preflight recompute accepts.
|
|
199
|
+
|
|
200
|
+
The fakes agree exactly, but the real service computes sampling and scoring on
|
|
201
|
+
different execution paths (kernel and batching differences), so individual
|
|
202
|
+
positions legitimately drift by a few hundredths of a nat (observed ~0.08 on
|
|
203
|
+
Qwen3.5-4B at temperature 1.0). That noise is zero-mean across the probe
|
|
204
|
+
sample, while the failures this check exists to catch (wrong sampler path,
|
|
205
|
+
tokenizer or renderer drift, SDK change) shift logprobs systematically by
|
|
206
|
+
whole nats. The mean bound separates those regimes robustly.
|
|
207
|
+
"""
|
|
208
|
+
|
|
209
|
+
DEFAULT_TITO_MAX_TOLERANCE = 1.0
|
|
210
|
+
"""Max |issued - recomputed| gap at any single position (catastrophic bound)."""
|
|
211
|
+
|
|
212
|
+
_PREFLIGHT_SAMPLE_TOKENS = 16
|
|
213
|
+
"""Length of the preflight sample whose logprobs are recomputed (the TITO proof)."""
|
|
214
|
+
|
|
215
|
+
EVAL_ROLLOUTS_DIR = "eval-rollouts"
|
|
216
|
+
"""Run-dir subdirectory holding each eval batch's isolated rollout root."""
|
|
217
|
+
|
|
218
|
+
WARMUP_ROLLOUTS_DIR = "warmup-rollouts"
|
|
219
|
+
"""Run-dir subdirectory holding the warmup phase's teacher rollout root."""
|
|
220
|
+
|
|
221
|
+
TEACHER_BASELINE_EVAL = "baseline-teacher"
|
|
222
|
+
STUDENT_BEFORE_EVAL = "baseline-student-before"
|
|
223
|
+
STUDENT_AFTER_EVAL = "student-after"
|
|
224
|
+
|
|
225
|
+
DistillPhase = Literal[
|
|
226
|
+
"preflight", "baseline", "warmup", "rollouts", "training", "eval", "finalize", "gate"
|
|
227
|
+
]
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
class DistillProgress(BaseModel):
|
|
231
|
+
"""One typed progress event emitted to the `on_progress` callback."""
|
|
232
|
+
|
|
233
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
234
|
+
|
|
235
|
+
phase: DistillPhase
|
|
236
|
+
message: str
|
|
237
|
+
total_steps: int = Field(ge=0)
|
|
238
|
+
step: int | None = None
|
|
239
|
+
"""0-based training step the event belongs to; None for run-level phases."""
|
|
240
|
+
|
|
241
|
+
spent_usd: float = Field(ge=0.0)
|
|
242
|
+
"""Total priced spend so far, including prior sessions of a resumed run."""
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
ProgressCallback = Callable[[DistillProgress], None]
|
|
246
|
+
|
|
247
|
+
LiveTrialPreflight = Callable[[ProviderConfig], None]
|
|
248
|
+
"""The documented hook for the live single-trial pi preflight (Phase 6).
|
|
249
|
+
|
|
250
|
+
Called with the student's rollout provider config (kind TINKER, model = the
|
|
251
|
+
current sampler path) after every cheap preflight check has passed and before
|
|
252
|
+
any baseline or training spend. A real implementation runs one short pi trial
|
|
253
|
+
on a cheap task through the actual harbor stack and raises on failure (spans
|
|
254
|
+
missing, prefix property broken, tool calls not round-tripping); it is
|
|
255
|
+
deliberately NOT implemented in this module, which stays free of harbor
|
|
256
|
+
calls beyond `collect_rollouts`.
|
|
257
|
+
"""
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
class DistillBudgetError(RuntimeError):
|
|
261
|
+
"""The hard budget cap was hit; artifacts were persisted so the run can resume.
|
|
262
|
+
|
|
263
|
+
Attributes:
|
|
264
|
+
resume_command: The exact CLI command that resumes this run.
|
|
265
|
+
spent_usd: Total priced spend (including prior sessions) at abort.
|
|
266
|
+
max_usd: The configured `budget.max_usd` cap.
|
|
267
|
+
"""
|
|
268
|
+
|
|
269
|
+
def __init__(
|
|
270
|
+
self, message: str, *, resume_command: str, spent_usd: float, max_usd: float
|
|
271
|
+
) -> None:
|
|
272
|
+
super().__init__(message)
|
|
273
|
+
self.resume_command = resume_command
|
|
274
|
+
self.spent_usd = spent_usd
|
|
275
|
+
self.max_usd = max_usd
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
_DEFERRED_BASELINE_WAIT_S = 1800.0
|
|
279
|
+
"""How long finalize waits for a concurrent run to write a deferred baseline.
|
|
280
|
+
|
|
281
|
+
30 minutes covers a baseline arm that is merely slow (a retry, a long tail of
|
|
282
|
+
holdout trials) without letting a producer that DIED hang a finished training
|
|
283
|
+
run indefinitely. The trained weights are saved before the wait begins, so a
|
|
284
|
+
timeout costs a resume rather than the training."""
|
|
285
|
+
|
|
286
|
+
_DEFERRED_BASELINE_POLL_S = 15.0
|
|
287
|
+
"""Poll interval while waiting; the file appears atomically, so this only sets
|
|
288
|
+
how quickly the wait notices."""
|
|
289
|
+
|
|
290
|
+
MAX_CONSECUTIVE_EMPTY_STEPS = 2
|
|
291
|
+
"""Consecutive all-empty training steps tolerated before the run aborts.
|
|
292
|
+
|
|
293
|
+
An all-empty step ran trials (`trials > 0`) but every trial produced zero
|
|
294
|
+
token spans (`empty_span_trials == trials`) and therefore zero datums: the
|
|
295
|
+
student provider is producing no completions at all, so training cannot make
|
|
296
|
+
progress and further steps only burn rollout budget. One such step can be a
|
|
297
|
+
transient batch-wide outage that self-heals; two in a row means the provider
|
|
298
|
+
is down (the live failure mode was silently swallowed worker errors upstream)
|
|
299
|
+
and the run aborts with `DistillEmptyBatchError`. The streak is tracked
|
|
300
|
+
within one session and a non-empty step resets it.
|
|
301
|
+
"""
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
class DistillEmptyBatchError(RuntimeError):
|
|
305
|
+
"""Consecutive training steps produced no completions; the run aborted.
|
|
306
|
+
|
|
307
|
+
Mirrors `DistillBudgetError` ergonomics: state was checkpointed and the
|
|
308
|
+
error carries the exact resume command.
|
|
309
|
+
|
|
310
|
+
Attributes:
|
|
311
|
+
resume_command: The exact CLI command that resumes this run.
|
|
312
|
+
consecutive_steps: How many all-empty steps ran back to back.
|
|
313
|
+
"""
|
|
314
|
+
|
|
315
|
+
def __init__(self, message: str, *, resume_command: str, consecutive_steps: int) -> None:
|
|
316
|
+
super().__init__(message)
|
|
317
|
+
self.resume_command = resume_command
|
|
318
|
+
self.consecutive_steps = consecutive_steps
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
class DistillDegenerationError(RuntimeError):
|
|
322
|
+
"""The policy degenerated past a tripwire's kill bound; the run aborted.
|
|
323
|
+
|
|
324
|
+
Mirrors `DistillEmptyBatchError` ergonomics: state was checkpointed and the
|
|
325
|
+
error carries the exact resume command (a resumed run restarts from the last
|
|
326
|
+
healthy checkpoint and keeps the SAME persisted baseline).
|
|
327
|
+
|
|
328
|
+
Attributes:
|
|
329
|
+
resume_command: The exact CLI command that resumes this run.
|
|
330
|
+
consecutive_steps: How many kill-level steps ran back to back.
|
|
331
|
+
breaches: The final step's kill-level breaches, in report order.
|
|
332
|
+
"""
|
|
333
|
+
|
|
334
|
+
def __init__(
|
|
335
|
+
self,
|
|
336
|
+
message: str,
|
|
337
|
+
*,
|
|
338
|
+
resume_command: str,
|
|
339
|
+
consecutive_steps: int,
|
|
340
|
+
breaches: Sequence[TripwireBreach],
|
|
341
|
+
) -> None:
|
|
342
|
+
super().__init__(message)
|
|
343
|
+
self.resume_command = resume_command
|
|
344
|
+
self.consecutive_steps = consecutive_steps
|
|
345
|
+
self.breaches = tuple(breaches)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class DistillNullEvalError(RuntimeError):
|
|
349
|
+
"""An eval batch produced no verifier evidence at all, so it has no solve rate.
|
|
350
|
+
|
|
351
|
+
Raised instead of writing a `DistillEvalReport` when no trial in the batch was graded, either
|
|
352
|
+
because none ran (the live failure was the E2B concurrent-sandbox cap: 51/51 trials
|
|
353
|
+
rate-limited, harbor jobs finishing in 52 to 199 seconds, and three `baseline-student-before`
|
|
354
|
+
reports written as 0.0%, then used as the no-regression leg of a promotion gate) or because
|
|
355
|
+
the verifier never produced a reward for any of them. A null measurement must stop the run,
|
|
356
|
+
not become a number.
|
|
357
|
+
"""
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
class DistillEvalReport(BaseModel):
|
|
361
|
+
"""One eval batch's persisted outcome (baselines, interim evals, student-after)."""
|
|
362
|
+
|
|
363
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
364
|
+
|
|
365
|
+
name: str = Field(min_length=1)
|
|
366
|
+
provider_model: str = Field(min_length=1)
|
|
367
|
+
"""The provider `model` the trials sampled (sampler path or teacher ref)."""
|
|
368
|
+
|
|
369
|
+
base_model: str | None = None
|
|
370
|
+
"""The base model behind `provider_model` (the provider's `model_type`).
|
|
371
|
+
|
|
372
|
+
This is what `eval.student_baseline_from` reuse validates against: a
|
|
373
|
+
student's `provider_model` is a per-run sampler path and never matches
|
|
374
|
+
across runs, while the base model is exactly what a shared pre-training
|
|
375
|
+
baseline must agree on. None only on reports from before this field.
|
|
376
|
+
"""
|
|
377
|
+
|
|
378
|
+
task_ids: list[str]
|
|
379
|
+
attempts: int = Field(ge=1)
|
|
380
|
+
trials: int = Field(ge=0)
|
|
381
|
+
solve_rate: float = Field(ge=0.0, le=1.0)
|
|
382
|
+
"""Passing share of EXECUTED trials (infrastructure failures excluded)."""
|
|
383
|
+
|
|
384
|
+
graded_solve_rate: float = Field(default=0.0, ge=0.0, le=1.0)
|
|
385
|
+
"""Mean graded test-pass score over `graded_trials`, beside the binary `solve_rate`.
|
|
386
|
+
|
|
387
|
+
The power metric for comparing two runs on a small holdout, where binary has no resolution;
|
|
388
|
+
`solve_rate` stays the headline and the gate's number. 0.0 with `graded_trials == 0` is a null
|
|
389
|
+
measurement, not a score. See `RolloutStats.graded_solve_rate`. 0.0 on reports from before this
|
|
390
|
+
field."""
|
|
391
|
+
|
|
392
|
+
graded_trials: int = Field(default=0, ge=0)
|
|
393
|
+
"""Gradeable trials that carried a readable test report: the `graded_solve_rate`
|
|
394
|
+
denominator."""
|
|
395
|
+
|
|
396
|
+
empty_span_trials: int = Field(ge=0)
|
|
397
|
+
|
|
398
|
+
executed_trials: int = Field(default=0, ge=0)
|
|
399
|
+
"""Trials that produced verifier evidence; 0 only on reports from before this field."""
|
|
400
|
+
|
|
401
|
+
infra_failed_trials: int = Field(default=0, ge=0)
|
|
402
|
+
"""Trials excluded from `solve_rate` because no verifier reward exists for them.
|
|
403
|
+
|
|
404
|
+
Either the agent never ran or its work was never graded; see `RolloutStats.infra_failed_trials`
|
|
405
|
+
for why one count covers both and where the per-trial causes live."""
|
|
406
|
+
|
|
407
|
+
scaffold_loss_rate: float = Field(default=0.0, ge=0.0, le=1.0)
|
|
408
|
+
"""Share of executed trials that never reached an explicit submit."""
|
|
409
|
+
|
|
410
|
+
stop_reason_counts: dict[str, int] = Field(default_factory=dict)
|
|
411
|
+
"""Trials per recorded stop reason."""
|
|
412
|
+
|
|
413
|
+
source: str | None = None
|
|
414
|
+
"""Provenance note when the report was imported from a prior run via
|
|
415
|
+
`eval.teacher_baseline_from` / `eval.student_baseline_from` instead of
|
|
416
|
+
measured here; None for reports this run's own trials produced."""
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
class StepMetrics(BaseModel):
|
|
420
|
+
"""One training step's metrics row (the store adds the `step` key).
|
|
421
|
+
|
|
422
|
+
The row reports two accounting stages, and mixing them in a ratio is a
|
|
423
|
+
measurement bug. `fragments`, `fragmentation_rate`, `overflow_drops`, and
|
|
424
|
+
`overlong_drops` describe the BUILD stage: rollouts merged into datums,
|
|
425
|
+
before the teacher scored anything. Every other datum or token count
|
|
426
|
+
(`datums`, `mismatch_drops`, `clipped_tokens`, `loss_tokens`,
|
|
427
|
+
`context_tokens`, `clip_fraction`, the advantage stats, `pg_loss`,
|
|
428
|
+
`grad_norm`) describes the TRAINED batch, after the teacher's misaligned
|
|
429
|
+
rows were dropped, so `clipped_tokens / loss_tokens` equals
|
|
430
|
+
`clip_fraction` and the token counts are what forward_backward consumed.
|
|
431
|
+
The pre-drop volume is not duplicated here: the teacher scored every built
|
|
432
|
+
datum, so `teacher_prefill_tokens` already carries it, and
|
|
433
|
+
`mismatch_drops` says how many datums the gap cost.
|
|
434
|
+
"""
|
|
435
|
+
|
|
436
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
437
|
+
|
|
438
|
+
tasks: int = Field(ge=0)
|
|
439
|
+
trials: int = Field(ge=0)
|
|
440
|
+
solve_rate: float = Field(ge=0.0, le=1.0)
|
|
441
|
+
"""Passing share of EXECUTED trials (infrastructure failures excluded)."""
|
|
442
|
+
|
|
443
|
+
graded_solve_rate: float = Field(default=0.0, ge=0.0, le=1.0)
|
|
444
|
+
"""Mean graded test-pass score over `graded_trials`, beside the binary `solve_rate`.
|
|
445
|
+
|
|
446
|
+
Extra resolution for run-to-run comparison, never a replacement: `solve_rate` is the benchmark's
|
|
447
|
+
own definition and stays the headline. 0.0 with `graded_trials == 0` is a null measurement. See
|
|
448
|
+
`RolloutStats.graded_solve_rate`. 0.0 on rows from before this field."""
|
|
449
|
+
|
|
450
|
+
graded_trials: int = Field(default=0, ge=0)
|
|
451
|
+
"""Gradeable trials that carried a readable test report: the `graded_solve_rate`
|
|
452
|
+
denominator."""
|
|
453
|
+
|
|
454
|
+
raw_solve_rate: float = Field(default=0.0, ge=0.0, le=1.0)
|
|
455
|
+
"""Passing share of ALL trials, which is what advantage estimation sees."""
|
|
456
|
+
|
|
457
|
+
executed_trials: int = Field(default=0, ge=0)
|
|
458
|
+
infra_failed_trials: int = Field(default=0, ge=0)
|
|
459
|
+
"""Trials with no verifier reward (nothing ran, or nothing graded it); not in `solve_rate`."""
|
|
460
|
+
|
|
461
|
+
scaffold_loss_rate: float = Field(default=0.0, ge=0.0, le=1.0)
|
|
462
|
+
"""Share of executed trials that never reached an explicit submit.
|
|
463
|
+
|
|
464
|
+
The audit headline: 88.8% for Super, invisible in every artifact. A rise here means the harness,
|
|
465
|
+
not the model, is deciding the solve rate."""
|
|
466
|
+
|
|
467
|
+
stop_reason_counts: dict[str, int] = Field(default_factory=dict)
|
|
468
|
+
"""Trials per recorded stop reason (`submitted`, `max_turns`, `budget`, `no_tool_call`,
|
|
469
|
+
`output_truncated`, `unparsed_tool_call`, `provider_error`, `unknown`)."""
|
|
470
|
+
|
|
471
|
+
empty_span_trials: int = Field(ge=0)
|
|
472
|
+
truncated_spans: int = Field(default=0, ge=0)
|
|
473
|
+
"""Turns that sampled the full `sampling.max_tokens` and were cut off mid-answer.
|
|
474
|
+
|
|
475
|
+
Charts as `train/truncated_spans`. Nothing else reports it: harbor's own truncation guard is
|
|
476
|
+
unreachable (see `RolloutStats.truncated_spans`), so without this series a run whose output
|
|
477
|
+
cap is too low reads as a run whose model writes broken actions. 0 on rows from before this
|
|
478
|
+
field."""
|
|
479
|
+
|
|
480
|
+
datums: int = Field(ge=0)
|
|
481
|
+
"""Datums the optimizer step trained on; under `train.loss = "topk_ce"`
|
|
482
|
+
these are the rank replicas, so k x the surviving source datums."""
|
|
483
|
+
|
|
484
|
+
fragments: int = Field(ge=0)
|
|
485
|
+
fragmentation_rate: float = Field(ge=0.0, le=1.0)
|
|
486
|
+
overflow_drops: int = Field(ge=0)
|
|
487
|
+
overlong_drops: int = Field(ge=0)
|
|
488
|
+
mismatch_drops: int = Field(ge=0)
|
|
489
|
+
clipped_tokens: int = Field(ge=0)
|
|
490
|
+
"""Trained loss tokens whose raw advantage hit the clip bound (0 under
|
|
491
|
+
`topk_ce`, which clips nothing, and 0 when `train.advantage_clip` is
|
|
492
|
+
unset)."""
|
|
493
|
+
|
|
494
|
+
optimizer_updates: int = Field(default=1, ge=0)
|
|
495
|
+
"""Optimizer updates this step took (`train.num_substeps`, or fewer if the
|
|
496
|
+
batch had less than that many replica groups).
|
|
497
|
+
|
|
498
|
+
Recorded because it is the quantity that most cheaply explains a null
|
|
499
|
+
result and was invisible for the project's first several runs: the loop
|
|
500
|
+
took ONE update per collected batch, so a 4-step run was 5 updates total
|
|
501
|
+
and could not have moved a rank-32 LoRA whatever the objective did. A run
|
|
502
|
+
summary that reports tokens but not updates hides that."""
|
|
503
|
+
|
|
504
|
+
loss_tokens: int = Field(ge=0)
|
|
505
|
+
"""Sampled (mask 1.0) tokens in the trained batch, counted once per SOURCE
|
|
506
|
+
datum: under `topk_ce` the k rank replicas share one source's tokens, so
|
|
507
|
+
`student_train_tokens` is k x (`loss_tokens` + `context_tokens`)."""
|
|
508
|
+
|
|
509
|
+
context_tokens: int = Field(ge=0)
|
|
510
|
+
"""Non-loss tokens in the trained batch, on `loss_tokens`' convention."""
|
|
511
|
+
|
|
512
|
+
reverse_kl_per_token: float | None
|
|
513
|
+
"""mean(sampled_lp - teacher_lp) over every SCORED loss token; None when
|
|
514
|
+
none. This is the teacher-scoring stage's measurement, not the trained
|
|
515
|
+
batch's: a datum the teacher scored but the alignment check later dropped
|
|
516
|
+
still contributed the positions that did come back."""
|
|
517
|
+
|
|
518
|
+
entropy_per_token: float | None
|
|
519
|
+
"""`-mean(sampled_logprobs)` over the batch's loss positions, pooled.
|
|
520
|
+
|
|
521
|
+
The degeneration tripwire's first signal, and the one a KL curve cannot
|
|
522
|
+
give: reverse KL can fall while the policy collapses into a single mode.
|
|
523
|
+
The tokens came from the policy that scored them, so this is an unbiased
|
|
524
|
+
single-sample estimator of that policy's entropy, pooled over the batch.
|
|
525
|
+
Read it as a LOWER bound on the T=1 entropy, since rollouts sample at
|
|
526
|
+
`sampling.temperature` (0.7 for the headline run) and that concentrates the
|
|
527
|
+
distribution the tokens are drawn from. Pooled over the whole batch, never
|
|
528
|
+
per episode (`wmo.distill.tripwire.policy_health` says why). None when the
|
|
529
|
+
batch recorded no sampled token."""
|
|
530
|
+
|
|
531
|
+
mean_generation_tokens: float | None
|
|
532
|
+
"""Sampled tokens per episode, pooled over the batch's span-bearing episodes.
|
|
533
|
+
|
|
534
|
+
The tripwire's second signal, against the mirror pathology: pure KL gives
|
|
535
|
+
EOS no gradient, so a student either never learns to stop or collapses to
|
|
536
|
+
near-empty answers (a sibling lane fell 2,866 to about 50 tokens). Measured
|
|
537
|
+
from the recorded spans, so whole-episode datum drops (the longest episodes)
|
|
538
|
+
cannot deflate it. None when no episode recorded a sampled token."""
|
|
539
|
+
|
|
540
|
+
entropy_baseline: float | None
|
|
541
|
+
"""`entropy_per_token` as measured at this run's first training step; None
|
|
542
|
+
until the baseline is captured. Every tripwire bound is a fraction of this,
|
|
543
|
+
never an absolute nats value (see `TripwireConfig`)."""
|
|
544
|
+
|
|
545
|
+
entropy_ratio: float | None
|
|
546
|
+
"""`entropy_per_token / entropy_baseline`: the number the tripwire bounds.
|
|
547
|
+
1.0 at the baseline step; None until a baseline exists."""
|
|
548
|
+
|
|
549
|
+
generation_tokens_baseline: float | None
|
|
550
|
+
"""`mean_generation_tokens` at this run's first training step; None until
|
|
551
|
+
the baseline is captured."""
|
|
552
|
+
|
|
553
|
+
generation_tokens_ratio: float | None
|
|
554
|
+
"""`mean_generation_tokens / generation_tokens_baseline`; None until a
|
|
555
|
+
baseline exists."""
|
|
556
|
+
|
|
557
|
+
reward_mean: float | None
|
|
558
|
+
"""Mean verifier reward over the step's trials; None when no trial ran.
|
|
559
|
+
|
|
560
|
+
Harbor's verifier rewards are binary today, so this equals `solve_rate`
|
|
561
|
+
and only diverges if a verifier ever emits fractional rewards.
|
|
562
|
+
"""
|
|
563
|
+
|
|
564
|
+
loss: Literal["importance_sampling", "ppo", "topk_ce"]
|
|
565
|
+
"""Which objective this step trained (`train.loss`), so a metrics row and
|
|
566
|
+
a dashboard point say what produced them.
|
|
567
|
+
|
|
568
|
+
`importance_sampling` and `ppo` submit the same advantage-carrying datums
|
|
569
|
+
and differ only in the service-side loss (`ppo` clips the policy ratio);
|
|
570
|
+
`topk_ce` submits rank-aligned replicas under the `cross_entropy` wire
|
|
571
|
+
loss and carries no advantage metrics at all."""
|
|
572
|
+
|
|
573
|
+
advantage_mean: float | None
|
|
574
|
+
"""Mean advantage over the trained loss tokens, exactly as trained (after
|
|
575
|
+
any clipping and any centering); None when nothing was trained or the
|
|
576
|
+
mode builds no advantages (`topk_ce`).
|
|
577
|
+
|
|
578
|
+
With the default objective (no clip, no centering) this is the mean
|
|
579
|
+
teacher-minus-student gap over the trained tokens, i.e. the negated
|
|
580
|
+
reverse KL: the number that should move toward 0 as the student closes on
|
|
581
|
+
the teacher. Under `train.center_advantages` it is ~0.0 by construction
|
|
582
|
+
and carries no information."""
|
|
583
|
+
|
|
584
|
+
advantage_std: float | None
|
|
585
|
+
"""Population std over the same loss tokens; None when nothing was trained.
|
|
586
|
+
|
|
587
|
+
Read it next to `advantage_mean` under the unclipped objective: it is the
|
|
588
|
+
per-token magnitude the gradient actually sees, and a run whose std grows
|
|
589
|
+
without its mean moving is being driven by outlier tokens."""
|
|
590
|
+
|
|
591
|
+
clip_fraction: float = Field(ge=0.0, le=1.0)
|
|
592
|
+
"""Trained loss tokens whose raw advantage hit the clip bound, as a
|
|
593
|
+
fraction of the trained loss tokens (0.0 when nothing was trained, and
|
|
594
|
+
0.0 for the whole run when `train.advantage_clip` is unset)."""
|
|
595
|
+
|
|
596
|
+
pg_loss: float | None
|
|
597
|
+
"""The batch loss the training backend reported for the step's
|
|
598
|
+
forward/backward; None when no optimizer step ran or the backend reported
|
|
599
|
+
no loss metric (tinker 0.23.3 has no typed loss field; see
|
|
600
|
+
`SDK_LOSS_METRIC_NAMES`)."""
|
|
601
|
+
|
|
602
|
+
grad_norm: float | None
|
|
603
|
+
"""The gradient norm the optim step reported; None when no optimizer step
|
|
604
|
+
ran or the backend reported none (see `SDK_GRAD_NORM_METRIC_NAMES`)."""
|
|
605
|
+
|
|
606
|
+
sampler_path: str
|
|
607
|
+
"""The tinker:// sampler path the step's rollouts sampled from."""
|
|
608
|
+
|
|
609
|
+
student_prefill_tokens: int = Field(ge=0)
|
|
610
|
+
student_cached_prefill_tokens: int = Field(ge=0)
|
|
611
|
+
student_sample_tokens: int = Field(ge=0)
|
|
612
|
+
student_train_tokens: int = Field(ge=0)
|
|
613
|
+
teacher_prefill_tokens: int = Field(ge=0)
|
|
614
|
+
teacher_cached_prefill_tokens: int = Field(ge=0)
|
|
615
|
+
teacher_sample_tokens: int = Field(ge=0)
|
|
616
|
+
usd: float = Field(ge=0.0)
|
|
617
|
+
"""Priced spend since the previous metrics row (spend before the first row,
|
|
618
|
+
the baselines and any warmup collection, folds into that first row)."""
|
|
619
|
+
|
|
620
|
+
cumulative_usd: float = Field(ge=0.0)
|
|
621
|
+
"""Total priced spend through this row, across every session of the run
|
|
622
|
+
(the budget meter's total, matching the spend ledger)."""
|
|
623
|
+
|
|
624
|
+
|
|
625
|
+
class WarmupMetrics(BaseModel):
|
|
626
|
+
"""One warmup step's metrics row (the store adds the `step` key).
|
|
627
|
+
|
|
628
|
+
Warmup rows share `metrics.jsonl` with training rows and their step
|
|
629
|
+
indices restart at 0 for OPD, so the constant `phase` field is what
|
|
630
|
+
distinguishes them (training rows carry no `phase` key). A run whose
|
|
631
|
+
warmup was skipped (zero kept trials) writes exactly one row with
|
|
632
|
+
`datums = 0` so the degradation to pure OPD is visible in the metrics.
|
|
633
|
+
"""
|
|
634
|
+
|
|
635
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
636
|
+
|
|
637
|
+
phase: Literal["warmup"] = "warmup"
|
|
638
|
+
tasks: int = Field(ge=0)
|
|
639
|
+
trials: int = Field(ge=0)
|
|
640
|
+
"""Teacher trials collected on the train split (task x rollouts_per_task)."""
|
|
641
|
+
|
|
642
|
+
kept_trials: int = Field(ge=0)
|
|
643
|
+
"""Trials that survived the `warmup.keep` filter."""
|
|
644
|
+
|
|
645
|
+
solve_rate: float = Field(ge=0.0, le=1.0)
|
|
646
|
+
"""The teacher's solve rate over the collected warmup trials."""
|
|
647
|
+
|
|
648
|
+
datums: int = Field(ge=0)
|
|
649
|
+
loss_tokens: int = Field(ge=0)
|
|
650
|
+
context_tokens: int = Field(ge=0)
|
|
651
|
+
learning_rate: float = Field(gt=0)
|
|
652
|
+
"""The effective warmup LR (warmup.learning_rate or train.learning_rate)."""
|
|
653
|
+
|
|
654
|
+
student_prefill_tokens: int = Field(ge=0)
|
|
655
|
+
student_cached_prefill_tokens: int = Field(ge=0)
|
|
656
|
+
student_sample_tokens: int = Field(ge=0)
|
|
657
|
+
student_train_tokens: int = Field(ge=0)
|
|
658
|
+
teacher_prefill_tokens: int = Field(ge=0)
|
|
659
|
+
teacher_cached_prefill_tokens: int = Field(ge=0)
|
|
660
|
+
teacher_sample_tokens: int = Field(ge=0)
|
|
661
|
+
usd: float = Field(ge=0.0)
|
|
662
|
+
"""Priced spend since the previous metrics row (the teacher collection and
|
|
663
|
+
any earlier baseline spend fold into warmup step 0's row)."""
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
class SpendSummary(BaseModel):
|
|
667
|
+
"""Where the run's money went, in the cost module's line shape."""
|
|
668
|
+
|
|
669
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
670
|
+
|
|
671
|
+
lines: list[CostLine]
|
|
672
|
+
"""This session's actual per-meter tokens and USD."""
|
|
673
|
+
|
|
674
|
+
session_usd: float = Field(ge=0.0)
|
|
675
|
+
prior_usd: float = Field(ge=0.0)
|
|
676
|
+
"""USD earlier sessions of a resumed run recorded in the spend ledger."""
|
|
677
|
+
|
|
678
|
+
total_usd: float = Field(ge=0.0)
|
|
679
|
+
|
|
680
|
+
|
|
681
|
+
class DistillResult(BaseModel):
|
|
682
|
+
"""What `run_distillation` returns after the gate has been decided."""
|
|
683
|
+
|
|
684
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
685
|
+
|
|
686
|
+
name: str
|
|
687
|
+
run_dir: str
|
|
688
|
+
steps_completed: int = Field(ge=0)
|
|
689
|
+
final_sampler_path: str
|
|
690
|
+
final_state_path: str
|
|
691
|
+
gate: DistillGateRecord
|
|
692
|
+
adapter_version: int | None
|
|
693
|
+
"""The promoted AdapterStore version; None when the gate rejected."""
|
|
694
|
+
|
|
695
|
+
spend: SpendSummary
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
# -- injectable Tinker surface (fakes satisfy these directly) ------------------------------------
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
SDK_LOSS_METRIC_NAMES = frozenset({"loss", "total_loss"})
|
|
702
|
+
"""Metric names accepted as the batch loss in a tinker metrics dict.
|
|
703
|
+
|
|
704
|
+
The pinned SDK (tinker 0.23.3) exposes no typed loss field anywhere:
|
|
705
|
+
`ForwardBackwardOutput` carries exactly `loss_fn_output_type`, the per-datum
|
|
706
|
+
`loss_fn_outputs` (field name -> TensorData; the cookbook reads a per-datum
|
|
707
|
+
"logprobs" tensor from it), and a server-populated `metrics: dict[str, float]`
|
|
708
|
+
whose only documented keys are MoE routing diagnostics. The cookbook's
|
|
709
|
+
off-policy distillation reads a `total_loss` key from that dict, so these
|
|
710
|
+
spellings (bare, or with the SDK chunk combiner's ":reduction" suffix) are
|
|
711
|
+
what `sdk_metric_value` recognizes; anything else stays un-surfaced.
|
|
712
|
+
"""
|
|
713
|
+
|
|
714
|
+
SDK_GRAD_NORM_METRIC_NAMES = frozenset({"grad_norm"})
|
|
715
|
+
"""Metric names accepted as the gradient norm in a tinker metrics dict.
|
|
716
|
+
|
|
717
|
+
`OptimStepResponse` (tinker 0.23.3) carries only an untyped
|
|
718
|
+
`metrics: Optional[Dict[str, float]]` with no documented keys, and no grad
|
|
719
|
+
norm appears anywhere in the SDK or the cookbook; the value is surfaced only
|
|
720
|
+
if the service ever reports one under this name.
|
|
721
|
+
"""
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def sdk_metric_value(metrics: Mapping[str, float] | None, names: frozenset[str]) -> float | None:
|
|
725
|
+
"""Pull one named scalar out of a tinker metrics dict, if it is present.
|
|
726
|
+
|
|
727
|
+
Server metric keys carry a ":reduction" suffix (e.g. "total_loss:sum")
|
|
728
|
+
that the SDK's chunk combiner folds over, so the match is on the name
|
|
729
|
+
part before an optional suffix.
|
|
730
|
+
|
|
731
|
+
Args:
|
|
732
|
+
metrics: The SDK output's metrics mapping (None on some responses).
|
|
733
|
+
names: The metric names to accept.
|
|
734
|
+
|
|
735
|
+
Returns:
|
|
736
|
+
The first matching value in mapping order, or None when the backend
|
|
737
|
+
reported no such metric (never a fabricated stand-in).
|
|
738
|
+
"""
|
|
739
|
+
if not metrics:
|
|
740
|
+
return None
|
|
741
|
+
for key, value in metrics.items():
|
|
742
|
+
if key.split(":", 1)[0] in names:
|
|
743
|
+
return float(value)
|
|
744
|
+
return None
|
|
745
|
+
|
|
746
|
+
|
|
747
|
+
class TrainStepOutput(BaseModel):
|
|
748
|
+
"""What one `forward_backward` reports back, in loop currency.
|
|
749
|
+
|
|
750
|
+
Only real backend-reported values are ever set: `SdkTrainingClient`
|
|
751
|
+
extracts `loss` from `ForwardBackwardOutput.metrics` (see
|
|
752
|
+
`SDK_LOSS_METRIC_NAMES` for exactly what the SDK exposes), and None means
|
|
753
|
+
the backend reported nothing, which the loop logs as an absent metric.
|
|
754
|
+
"""
|
|
755
|
+
|
|
756
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
757
|
+
|
|
758
|
+
loss: float | None = None
|
|
759
|
+
"""The batch loss the backend reported; None when it reported none."""
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
class OptimStepOutput(BaseModel):
|
|
763
|
+
"""What one `optim_step` reports back, in loop currency.
|
|
764
|
+
|
|
765
|
+
`SdkTrainingClient` extracts `grad_norm` from `OptimStepResponse.metrics`
|
|
766
|
+
(see `SDK_GRAD_NORM_METRIC_NAMES`); None means the backend reported
|
|
767
|
+
nothing, which the loop logs as an absent metric.
|
|
768
|
+
"""
|
|
769
|
+
|
|
770
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
771
|
+
|
|
772
|
+
grad_norm: float | None = None
|
|
773
|
+
"""The gradient norm the backend reported; None when it reported none."""
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
class DistillSamplingClient(Protocol):
|
|
777
|
+
"""The sampling-client slice the loop uses, in token-id terms.
|
|
778
|
+
|
|
779
|
+
`wmo.distill.fake_tinker.FakeSamplingClient` satisfies this directly; real
|
|
780
|
+
`tinker.SamplingClient`s are adapted via `SdkSamplingClient`.
|
|
781
|
+
"""
|
|
782
|
+
|
|
783
|
+
def sample(
|
|
784
|
+
self,
|
|
785
|
+
prompt_token_ids: list[int],
|
|
786
|
+
*,
|
|
787
|
+
max_tokens: int,
|
|
788
|
+
temperature: float,
|
|
789
|
+
) -> SampledSequenceLike:
|
|
790
|
+
"""Sample one sequence conditioned on the prompt token ids."""
|
|
791
|
+
...
|
|
792
|
+
|
|
793
|
+
def compute_logprobs(self, token_ids: list[int]) -> list[float | None]:
|
|
794
|
+
"""Per-position logprobs for the sequence; entry 0 is None."""
|
|
795
|
+
...
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
@runtime_checkable
|
|
799
|
+
class TokenizerSource(Protocol):
|
|
800
|
+
"""A sampling client that can also supply its model's tokenizer."""
|
|
801
|
+
|
|
802
|
+
def get_tokenizer(self) -> EncodingTokenizer:
|
|
803
|
+
"""The tokenizer for the client's model."""
|
|
804
|
+
...
|
|
805
|
+
|
|
806
|
+
|
|
807
|
+
class DistillTrainingClient(Protocol):
|
|
808
|
+
"""The training-client slice the loop drives, in loop currency.
|
|
809
|
+
|
|
810
|
+
`forward_backward` takes the loop's own `TrainDatum`s; each backend owns
|
|
811
|
+
the conversion to its wire datum type (`SdkTrainingClient` converts via
|
|
812
|
+
`to_tinker_datums`, test shims convert to `FakeDatum`s), which keeps the
|
|
813
|
+
orchestrator identical for real and fake runs.
|
|
814
|
+
"""
|
|
815
|
+
|
|
816
|
+
def get_tokenizer(self) -> EncodingTokenizer:
|
|
817
|
+
"""The student base model's tokenizer."""
|
|
818
|
+
...
|
|
819
|
+
|
|
820
|
+
def forward_backward(self, datums: Sequence[TrainDatum], loss_fn: str) -> TrainStepOutput:
|
|
821
|
+
"""Accumulate gradients for one batch under the named loss.
|
|
822
|
+
|
|
823
|
+
Returns:
|
|
824
|
+
The backend-reported step output; `loss` is None when the
|
|
825
|
+
backend reported no loss metric (never fabricated).
|
|
826
|
+
"""
|
|
827
|
+
...
|
|
828
|
+
|
|
829
|
+
def optim_step(self, learning_rate: float) -> OptimStepOutput:
|
|
830
|
+
"""Apply one optimizer step.
|
|
831
|
+
|
|
832
|
+
Returns:
|
|
833
|
+
The backend-reported output; `grad_norm` is None when the
|
|
834
|
+
backend reported no such metric (never fabricated).
|
|
835
|
+
"""
|
|
836
|
+
...
|
|
837
|
+
|
|
838
|
+
def save_state(self) -> str:
|
|
839
|
+
"""Save resumable training state, returning its tinker:// path."""
|
|
840
|
+
...
|
|
841
|
+
|
|
842
|
+
def load_state(self, path: str) -> None:
|
|
843
|
+
"""Restore training state from a previously saved path."""
|
|
844
|
+
...
|
|
845
|
+
|
|
846
|
+
def save_weights_for_sampler(self, name: str) -> str:
|
|
847
|
+
"""Save current weights for sampling, returning the sampler path."""
|
|
848
|
+
...
|
|
849
|
+
|
|
850
|
+
|
|
851
|
+
class DistillServiceClient(Protocol):
|
|
852
|
+
"""The service-client slice the loop needs.
|
|
853
|
+
|
|
854
|
+
`wmo.distill.fake_tinker.FakeServiceClient` matches this shape (tests wrap
|
|
855
|
+
its training client to convert datums); the real SDK is adapted by
|
|
856
|
+
`SdkServiceClient`.
|
|
857
|
+
"""
|
|
858
|
+
|
|
859
|
+
def create_lora_training_client(self, base_model: str, rank: int = 32) -> DistillTrainingClient:
|
|
860
|
+
"""Create the LoRA training client for the student base model."""
|
|
861
|
+
...
|
|
862
|
+
|
|
863
|
+
def create_sampling_client(self, model_path: str) -> DistillSamplingClient:
|
|
864
|
+
"""Create a sampling client for a sampler path or base model name."""
|
|
865
|
+
...
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
# -- real-SDK adapters (lazy tinker imports, mirroring SdkSampler/SdkLogprobScorer) --------------
|
|
869
|
+
|
|
870
|
+
|
|
871
|
+
class SdkSamplingClient:
|
|
872
|
+
"""Adapts a real `tinker.SamplingClient` to `DistillSamplingClient`.
|
|
873
|
+
|
|
874
|
+
Args:
|
|
875
|
+
client: The SDK sampling client, normally fetched from the
|
|
876
|
+
process-wide shared cache in `wmo.providers.tinker`.
|
|
877
|
+
model: The shared-cache key the client was fetched under. When set, a
|
|
878
|
+
`TinkerDeadlineError` from any call evicts that cache entry so
|
|
879
|
+
every future user of the model string (including the harbor trial
|
|
880
|
+
providers sharing the cache) rebuilds through a fresh session;
|
|
881
|
+
None disables eviction for a directly constructed adapter.
|
|
882
|
+
"""
|
|
883
|
+
|
|
884
|
+
def __init__(self, client: tinker.SamplingClient, *, model: str | None = None) -> None:
|
|
885
|
+
self._client = client
|
|
886
|
+
self._model = model
|
|
887
|
+
self._sampler = SdkSampler(client)
|
|
888
|
+
self._scorer = SdkLogprobScorer(client)
|
|
889
|
+
|
|
890
|
+
def _evict_on_deadline(self) -> None:
|
|
891
|
+
"""Evict the wedged client from the shared cache (see class docstring)."""
|
|
892
|
+
if self._model is not None:
|
|
893
|
+
evict_shared_sampling_client(self._model, self._client)
|
|
894
|
+
|
|
895
|
+
def sample(
|
|
896
|
+
self,
|
|
897
|
+
prompt_token_ids: list[int],
|
|
898
|
+
*,
|
|
899
|
+
max_tokens: int,
|
|
900
|
+
temperature: float,
|
|
901
|
+
) -> SampledSequenceLike:
|
|
902
|
+
"""One synchronous sample through the provider's SDK adapter."""
|
|
903
|
+
try:
|
|
904
|
+
return self._sampler.sample(
|
|
905
|
+
prompt_token_ids, max_tokens=max_tokens, temperature=temperature
|
|
906
|
+
)
|
|
907
|
+
except TinkerDeadlineError:
|
|
908
|
+
self._evict_on_deadline()
|
|
909
|
+
raise
|
|
910
|
+
|
|
911
|
+
def compute_logprobs(self, token_ids: list[int]) -> list[float | None]:
|
|
912
|
+
"""One synchronous compute_logprobs call on the full sequence."""
|
|
913
|
+
try:
|
|
914
|
+
return self._scorer.compute_logprobs(token_ids)
|
|
915
|
+
except TinkerDeadlineError:
|
|
916
|
+
self._evict_on_deadline()
|
|
917
|
+
raise
|
|
918
|
+
|
|
919
|
+
def topk_prompt_logprobs(
|
|
920
|
+
self, token_ids: list[int], k: int
|
|
921
|
+
) -> tuple[list[float | None], list[TopkCandidates | None]]:
|
|
922
|
+
"""One prefill-only sample returning realized and top-k prompt logprobs.
|
|
923
|
+
|
|
924
|
+
The teacher's topk_ce path (`TinkerTeacher.score_topk`) narrows its
|
|
925
|
+
injected scorer to `TopkLogprobScorer` at runtime; this delegation
|
|
926
|
+
makes the loop's teacher sampling client satisfy it against the real
|
|
927
|
+
SDK, with the same cache eviction on a deadline expiry as every
|
|
928
|
+
other call.
|
|
929
|
+
"""
|
|
930
|
+
try:
|
|
931
|
+
return self._scorer.topk_prompt_logprobs(token_ids, k)
|
|
932
|
+
except TinkerDeadlineError:
|
|
933
|
+
self._evict_on_deadline()
|
|
934
|
+
raise
|
|
935
|
+
|
|
936
|
+
def get_tokenizer(self) -> EncodingTokenizer:
|
|
937
|
+
"""The HF tokenizer for the client's base model (deadline-bounded fetch)."""
|
|
938
|
+
try:
|
|
939
|
+
return cast(
|
|
940
|
+
"EncodingTokenizer", call_with_deadline("connect", self._client.get_tokenizer)
|
|
941
|
+
)
|
|
942
|
+
except TinkerDeadlineError:
|
|
943
|
+
self._evict_on_deadline()
|
|
944
|
+
raise
|
|
945
|
+
|
|
946
|
+
|
|
947
|
+
class SdkTrainingClient:
|
|
948
|
+
"""Adapts a real `tinker.TrainingClient` to `DistillTrainingClient`.
|
|
949
|
+
|
|
950
|
+
Save names carry a per-session nonce so a resumed run re-saving the same
|
|
951
|
+
step never collides with an earlier session's artifact names.
|
|
952
|
+
|
|
953
|
+
Every SDK call is deadline-bounded (`wmo.distill.deadlines`), and what a
|
|
954
|
+
deadline expiry does depends on the call's idempotency:
|
|
955
|
+
|
|
956
|
+
- `forward_backward` and `optim_step` are NOT idempotent mid-batch: the
|
|
957
|
+
request may have executed server-side before the deadline fired, so
|
|
958
|
+
re-submitting could accumulate the batch's gradients or apply the Adam
|
|
959
|
+
step twice. Expiry raises cleanly, aborting the step with state intact
|
|
960
|
+
(the loop's save_state cadence bounds what a resume loses).
|
|
961
|
+
- `save_state` and `save_weights_for_sampler` ARE safe to retry once:
|
|
962
|
+
they read state that does not change between the attempts, and each
|
|
963
|
+
save retry uses a fresh artifact name so a first attempt that completed
|
|
964
|
+
server-side after being abandoned can never collide.
|
|
965
|
+
- `load_state` is NOT retryable on the same client, and must be its FIRST
|
|
966
|
+
call: tinker accepts LoadWeights only on an uninitialized model, and an
|
|
967
|
+
abandoned request keeps running server-side. A live resume proved it,
|
|
968
|
+
dying with "LoadWeights can only be called on uninitialized models"
|
|
969
|
+
when the retry followed a restore the client had given up waiting for.
|
|
970
|
+
Expiry raises, and the retry belongs on a freshly created (still
|
|
971
|
+
uninitialized) client, which is what `_DistillRun._open_training_client`
|
|
972
|
+
does. `get_tokenizer` stays legal before a restore: the SDK answers it
|
|
973
|
+
from a metadata-only GetInfo request that never touches weights.
|
|
974
|
+
"""
|
|
975
|
+
|
|
976
|
+
def __init__(self, client: tinker.TrainingClient) -> None:
|
|
977
|
+
self._client = client
|
|
978
|
+
self._session = uuid4().hex[:8]
|
|
979
|
+
self._save_counter = 0
|
|
980
|
+
self._initialized = False
|
|
981
|
+
"""Whether a call that initializes the model server-side has been issued."""
|
|
982
|
+
|
|
983
|
+
def get_tokenizer(self) -> EncodingTokenizer:
|
|
984
|
+
"""The student base model's HF tokenizer (deadline-bounded fetch).
|
|
985
|
+
|
|
986
|
+
Metadata only (the SDK resolves it through GetInfo), so it never
|
|
987
|
+
initializes the model and stays legal before `load_state`.
|
|
988
|
+
"""
|
|
989
|
+
return cast("EncodingTokenizer", call_with_deadline("connect", self._client.get_tokenizer))
|
|
990
|
+
|
|
991
|
+
def forward_backward(self, datums: Sequence[TrainDatum], loss_fn: str) -> TrainStepOutput:
|
|
992
|
+
"""Convert to real tinker datums and run one bounded forward/backward.
|
|
993
|
+
|
|
994
|
+
The loss decides the wire conversion: cross_entropy datums (the
|
|
995
|
+
warmup phase and topk_ce replicas) carry {target_tokens, weights},
|
|
996
|
+
while both advantage losses (importance_sampling and ppo) carry
|
|
997
|
+
{target_tokens, logprobs, advantages}; each loss rejects any other
|
|
998
|
+
keyset server-side. No `loss_fn_config` is sent, so ppo's ratio-clip
|
|
999
|
+
epsilon is the service default.
|
|
1000
|
+
|
|
1001
|
+
Never retried on a deadline expiry: gradients may already have been
|
|
1002
|
+
accumulated server-side, so a re-submit could count the batch twice.
|
|
1003
|
+
The typed error aborts the step; resume restores the last checkpoint.
|
|
1004
|
+
|
|
1005
|
+
Returns:
|
|
1006
|
+
The step output. The SDK's `ForwardBackwardOutput` has no typed
|
|
1007
|
+
loss field (only `loss_fn_output_type`, per-datum
|
|
1008
|
+
`loss_fn_outputs`, and the untyped `metrics` dict), so `loss` is
|
|
1009
|
+
whatever `metrics` reports under `SDK_LOSS_METRIC_NAMES`, or
|
|
1010
|
+
None when the service reports no such key.
|
|
1011
|
+
"""
|
|
1012
|
+
converted = (
|
|
1013
|
+
to_tinker_sft_datums(datums)
|
|
1014
|
+
if loss_fn == CROSS_ENTROPY_LOSS
|
|
1015
|
+
else to_tinker_datums(datums)
|
|
1016
|
+
)
|
|
1017
|
+
self._initialized = True
|
|
1018
|
+
future = self._client.forward_backward(converted, cast("tinker_types.LossFnType", loss_fn))
|
|
1019
|
+
output = wait_with_deadline("forward_backward", future)
|
|
1020
|
+
return TrainStepOutput(loss=sdk_metric_value(output.metrics, SDK_LOSS_METRIC_NAMES))
|
|
1021
|
+
|
|
1022
|
+
def optim_step(self, learning_rate: float) -> OptimStepOutput:
|
|
1023
|
+
"""One bounded Adam step at the given learning rate.
|
|
1024
|
+
|
|
1025
|
+
Never retried on a deadline expiry: the step may have been applied
|
|
1026
|
+
server-side before the deadline fired, and re-applying would
|
|
1027
|
+
double-step the optimizer. The typed error aborts the step cleanly.
|
|
1028
|
+
|
|
1029
|
+
Returns:
|
|
1030
|
+
The step output. The SDK's `OptimStepResponse` carries only the
|
|
1031
|
+
untyped optional `metrics` dict with no documented keys, so
|
|
1032
|
+
`grad_norm` is whatever `metrics` reports under
|
|
1033
|
+
`SDK_GRAD_NORM_METRIC_NAMES`, or None when absent.
|
|
1034
|
+
"""
|
|
1035
|
+
from tinker import types
|
|
1036
|
+
|
|
1037
|
+
self._initialized = True
|
|
1038
|
+
response = wait_with_deadline(
|
|
1039
|
+
"optim_step", self._client.optim_step(types.AdamParams(learning_rate=learning_rate))
|
|
1040
|
+
)
|
|
1041
|
+
return OptimStepOutput(
|
|
1042
|
+
grad_norm=sdk_metric_value(response.metrics, SDK_GRAD_NORM_METRIC_NAMES)
|
|
1043
|
+
)
|
|
1044
|
+
|
|
1045
|
+
def save_state(self) -> str:
|
|
1046
|
+
"""Save resumable training state under a session-unique name.
|
|
1047
|
+
|
|
1048
|
+
Retried once on a deadline expiry: saving snapshots state that does
|
|
1049
|
+
not change between the attempts, and the retry advances the name
|
|
1050
|
+
counter so a first attempt that completed server-side after being
|
|
1051
|
+
abandoned can never collide.
|
|
1052
|
+
"""
|
|
1053
|
+
try:
|
|
1054
|
+
return self._save_state_once()
|
|
1055
|
+
except TinkerDeadlineError as exc:
|
|
1056
|
+
logger.warning("retrying save_state once with a fresh request: %s", exc)
|
|
1057
|
+
return self._save_state_once()
|
|
1058
|
+
|
|
1059
|
+
def _save_state_once(self) -> str:
|
|
1060
|
+
name = f"wmo-distill-{self._session}-state-{self._save_counter:04d}"
|
|
1061
|
+
self._save_counter += 1
|
|
1062
|
+
self._initialized = True
|
|
1063
|
+
return wait_with_deadline("save_state", self._client.save_state(name)).path
|
|
1064
|
+
|
|
1065
|
+
def load_state(self, path: str) -> None:
|
|
1066
|
+
"""Restore training state from a tinker:// state path, first of all calls.
|
|
1067
|
+
|
|
1068
|
+
Never retried on this client: tinker accepts LoadWeights only on an
|
|
1069
|
+
uninitialized model and an abandoned request keeps running
|
|
1070
|
+
server-side, so a second attempt here is the live
|
|
1071
|
+
"LoadWeights can only be called on uninitialized models" failure. The
|
|
1072
|
+
deadline (`WMO_TINKER_DEADLINE_LOAD_STATE`) is the whole budget, and
|
|
1073
|
+
the caller retries on a fresh client.
|
|
1074
|
+
|
|
1075
|
+
Args:
|
|
1076
|
+
path: The tinker:// state path to restore.
|
|
1077
|
+
|
|
1078
|
+
Raises:
|
|
1079
|
+
RuntimeError: If a call that initializes the model already ran on
|
|
1080
|
+
this client, so the service could only reject the restore.
|
|
1081
|
+
TinkerDeadlineError: If the restore blows its deadline.
|
|
1082
|
+
"""
|
|
1083
|
+
if self._initialized:
|
|
1084
|
+
raise RuntimeError(
|
|
1085
|
+
"load_state must be the first call on a tinker training client: the "
|
|
1086
|
+
"service accepts LoadWeights only on an uninitialized model, and this "
|
|
1087
|
+
"client already issued a weights call. Create a fresh training client "
|
|
1088
|
+
f"and restore {path} before anything else touches it"
|
|
1089
|
+
)
|
|
1090
|
+
self._initialized = True
|
|
1091
|
+
wait_with_deadline("load_state", self._client.load_state(path))
|
|
1092
|
+
|
|
1093
|
+
def save_weights_for_sampler(self, name: str) -> str:
|
|
1094
|
+
"""Save sampler weights, returning the tinker:// sampler path.
|
|
1095
|
+
|
|
1096
|
+
Retried once on a deadline expiry: the weights do not change between
|
|
1097
|
+
the attempts, and the retry saves under a distinct "-r1" name so a
|
|
1098
|
+
first attempt that completed server-side after being abandoned can
|
|
1099
|
+
never collide.
|
|
1100
|
+
"""
|
|
1101
|
+
try:
|
|
1102
|
+
return self._save_weights_once(f"{name}-{self._session}")
|
|
1103
|
+
except TinkerDeadlineError as exc:
|
|
1104
|
+
logger.warning("retrying save_weights_for_sampler once under a fresh name: %s", exc)
|
|
1105
|
+
return self._save_weights_once(f"{name}-{self._session}-r1")
|
|
1106
|
+
|
|
1107
|
+
def _save_weights_once(self, full_name: str) -> str:
|
|
1108
|
+
self._initialized = True
|
|
1109
|
+
future = self._client.save_weights_for_sampler(full_name)
|
|
1110
|
+
return wait_with_deadline("save_weights_for_sampler", future).path
|
|
1111
|
+
|
|
1112
|
+
|
|
1113
|
+
class SdkServiceClient:
|
|
1114
|
+
"""Adapts a real `tinker.ServiceClient` to `DistillServiceClient`.
|
|
1115
|
+
|
|
1116
|
+
The wrapped service builds training clients; sampling clients come from
|
|
1117
|
+
the process-wide shared cache in `wmo.providers.tinker` (one
|
|
1118
|
+
`SamplingClient` per model string, shared with the rollout providers and
|
|
1119
|
+
the teacher), so per-refresh construction never adds server-side sessions
|
|
1120
|
+
beyond one per distinct sampler path.
|
|
1121
|
+
"""
|
|
1122
|
+
|
|
1123
|
+
def __init__(self, service: tinker.ServiceClient) -> None:
|
|
1124
|
+
self._service = service
|
|
1125
|
+
|
|
1126
|
+
def create_lora_training_client(self, base_model: str, rank: int = 32) -> SdkTrainingClient:
|
|
1127
|
+
"""Create the real LoRA training client for the student (deadline-bounded)."""
|
|
1128
|
+
|
|
1129
|
+
def build() -> tinker.TrainingClient:
|
|
1130
|
+
return self._service.create_lora_training_client(base_model=base_model, rank=rank)
|
|
1131
|
+
|
|
1132
|
+
return SdkTrainingClient(call_with_deadline("connect", build))
|
|
1133
|
+
|
|
1134
|
+
def create_sampling_client(self, model_path: str) -> SdkSamplingClient:
|
|
1135
|
+
"""A sampling client for a tinker:// path or base model name (shared cache).
|
|
1136
|
+
|
|
1137
|
+
Fetched from (or built into) the process-wide cache, deadline-bounded;
|
|
1138
|
+
the returned adapter evicts the cache entry on a `TinkerDeadlineError`
|
|
1139
|
+
so every future user rebuilds through a fresh session.
|
|
1140
|
+
"""
|
|
1141
|
+
return SdkSamplingClient(shared_sampling_client(model_path), model=model_path)
|
|
1142
|
+
|
|
1143
|
+
|
|
1144
|
+
def _build_sdk_service_client() -> SdkServiceClient:
|
|
1145
|
+
"""Build the real service adapter (the `service_client=None` path).
|
|
1146
|
+
|
|
1147
|
+
The SDK's service client pins one live server-side session for the life
|
|
1148
|
+
of the process, so the loop adapts `wmo.providers.tinker`'s process-wide
|
|
1149
|
+
shared instance instead of constructing a second one.
|
|
1150
|
+
|
|
1151
|
+
Raises:
|
|
1152
|
+
ImportError: If the tinker SDK is not installed (distill extra).
|
|
1153
|
+
RuntimeError: If TINKER_API_KEY is missing from the environment.
|
|
1154
|
+
"""
|
|
1155
|
+
return SdkServiceClient(shared_service_client())
|
|
1156
|
+
|
|
1157
|
+
|
|
1158
|
+
# -- samplers ------------------------------------------------------------------------------------
|
|
1159
|
+
|
|
1160
|
+
|
|
1161
|
+
def _seed_from_name(name: str) -> int:
|
|
1162
|
+
"""A stable cross-process seed derived from the run name (hash() is salted)."""
|
|
1163
|
+
return int.from_bytes(hashlib.sha256(name.encode("utf-8")).digest()[:8], "big")
|
|
1164
|
+
|
|
1165
|
+
|
|
1166
|
+
def tinker_provider_config(model: str, base_model: str) -> ProviderConfig:
|
|
1167
|
+
"""The rollout provider config for one Tinker model identity.
|
|
1168
|
+
|
|
1169
|
+
The single shape every distillation rollout samples through: the tinker
|
|
1170
|
+
provider kind (the only one that records token spans), `model` as the exact
|
|
1171
|
+
identity being sampled, and `model_type` as the base model whose renderer
|
|
1172
|
+
and tokenizer that identity uses. Both loop callers go through it (the
|
|
1173
|
+
student's current sampler weights, the teacher's stable model or
|
|
1174
|
+
checkpoint), and so does any out-of-loop caller that needs the same
|
|
1175
|
+
provider without standing up a training client (a rollout-only probe).
|
|
1176
|
+
|
|
1177
|
+
Args:
|
|
1178
|
+
model: The exact model string to sample: a `tinker://` sampler-weights
|
|
1179
|
+
path, a checkpoint ref, or a base model name.
|
|
1180
|
+
base_model: The base model name that names the renderer/tokenizer
|
|
1181
|
+
identity (equal to `model` when sampling a base model directly).
|
|
1182
|
+
|
|
1183
|
+
Returns:
|
|
1184
|
+
The validated provider config.
|
|
1185
|
+
"""
|
|
1186
|
+
return ProviderConfig(kind=ProviderKind.TINKER, model=model, model_type=base_model)
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
class TaskSampler:
|
|
1190
|
+
"""A seeded shuffle-cycle over the train task ids.
|
|
1191
|
+
|
|
1192
|
+
Batches are unique within themselves and the cycle visits every task
|
|
1193
|
+
before repeating any, so coverage stays even. The sequence is a pure
|
|
1194
|
+
function of (task_ids, seed, draw count): a resumed run fast-forwards by
|
|
1195
|
+
the completed step count and continues the exact same batch sequence.
|
|
1196
|
+
|
|
1197
|
+
Args:
|
|
1198
|
+
task_ids: The train split's task ids; must be non-empty and unique.
|
|
1199
|
+
seed: Seed for the shuffle order.
|
|
1200
|
+
|
|
1201
|
+
Raises:
|
|
1202
|
+
ValueError: If `task_ids` is empty or contains duplicates.
|
|
1203
|
+
"""
|
|
1204
|
+
|
|
1205
|
+
def __init__(self, task_ids: Sequence[str], *, seed: int) -> None:
|
|
1206
|
+
ids = list(task_ids)
|
|
1207
|
+
if not ids:
|
|
1208
|
+
raise ValueError("task_ids is empty; a distillation run needs at least one train task")
|
|
1209
|
+
if len(set(ids)) != len(ids):
|
|
1210
|
+
raise ValueError(
|
|
1211
|
+
"task_ids contains duplicates; pass each train task id exactly once "
|
|
1212
|
+
"(attempts per task come from train.group_size, not repeated ids)"
|
|
1213
|
+
)
|
|
1214
|
+
self._ids = ids
|
|
1215
|
+
self._rng = random.Random(seed)
|
|
1216
|
+
self._cycle: list[str] = []
|
|
1217
|
+
self._position = 0
|
|
1218
|
+
self._reshuffle()
|
|
1219
|
+
|
|
1220
|
+
def _reshuffle(self) -> None:
|
|
1221
|
+
self._cycle = list(self._ids)
|
|
1222
|
+
self._rng.shuffle(self._cycle)
|
|
1223
|
+
self._position = 0
|
|
1224
|
+
|
|
1225
|
+
def next_batch(self, n: int) -> list[str]:
|
|
1226
|
+
"""The next batch of at most `n` unique task ids.
|
|
1227
|
+
|
|
1228
|
+
Args:
|
|
1229
|
+
n: Requested batch size; clamped to the split size.
|
|
1230
|
+
|
|
1231
|
+
Returns:
|
|
1232
|
+
`min(n, len(task_ids))` unique task ids in cycle order.
|
|
1233
|
+
|
|
1234
|
+
Raises:
|
|
1235
|
+
ValueError: If `n` is not positive.
|
|
1236
|
+
"""
|
|
1237
|
+
if n < 1:
|
|
1238
|
+
raise ValueError(f"batch size must be >= 1, got {n}")
|
|
1239
|
+
size = min(n, len(self._ids))
|
|
1240
|
+
batch: list[str] = []
|
|
1241
|
+
while len(batch) < size:
|
|
1242
|
+
if self._position >= len(self._cycle):
|
|
1243
|
+
self._reshuffle()
|
|
1244
|
+
candidate = self._cycle[self._position]
|
|
1245
|
+
self._position += 1
|
|
1246
|
+
if candidate not in batch:
|
|
1247
|
+
batch.append(candidate)
|
|
1248
|
+
return batch
|
|
1249
|
+
|
|
1250
|
+
|
|
1251
|
+
class StudentSampler:
|
|
1252
|
+
"""Owns the student's sampler-weights refresh cadence.
|
|
1253
|
+
|
|
1254
|
+
`refresh(step)` saves the training client's current weights under a
|
|
1255
|
+
step-stamped name and swaps in a sampling client for them; the exposed
|
|
1256
|
+
`sampler_path` is the tinker:// path rollout provider configs point at.
|
|
1257
|
+
Refreshing the same step twice is a no-op (the finalize path may land on
|
|
1258
|
+
a step the cadence already refreshed).
|
|
1259
|
+
|
|
1260
|
+
Args:
|
|
1261
|
+
service: The service client sampler clients are created from.
|
|
1262
|
+
training: The training client whose weights are saved.
|
|
1263
|
+
run_name: Stamped into every sampler-weights save name.
|
|
1264
|
+
"""
|
|
1265
|
+
|
|
1266
|
+
def __init__(
|
|
1267
|
+
self,
|
|
1268
|
+
service: DistillServiceClient,
|
|
1269
|
+
training: DistillTrainingClient,
|
|
1270
|
+
run_name: str,
|
|
1271
|
+
) -> None:
|
|
1272
|
+
self._service = service
|
|
1273
|
+
self._training = training
|
|
1274
|
+
self._run_name = run_name
|
|
1275
|
+
self._path: str | None = None
|
|
1276
|
+
self._client: DistillSamplingClient | None = None
|
|
1277
|
+
self._last_step: int | None = None
|
|
1278
|
+
|
|
1279
|
+
def refresh(self, step: int, *, tag: str | None = None) -> str:
|
|
1280
|
+
"""Save current weights for sampling and swap in a fresh client.
|
|
1281
|
+
|
|
1282
|
+
Args:
|
|
1283
|
+
step: The 0-based training step the weights are current FOR (the
|
|
1284
|
+
step about to sample from them); stamps the save name.
|
|
1285
|
+
tag: Optional label forcing a save even when `step` was already
|
|
1286
|
+
refreshed. The post-warmup refresh needs it: step 0 was
|
|
1287
|
+
refreshed before preflight, but warmup then CHANGED the
|
|
1288
|
+
weights, so the dedup must not short-circuit. The tag also
|
|
1289
|
+
stamps the save name so the two step-0 artifacts cannot
|
|
1290
|
+
collide.
|
|
1291
|
+
|
|
1292
|
+
Returns:
|
|
1293
|
+
The new (or, when `step` was already refreshed and no tag forces
|
|
1294
|
+
a save, current) tinker:// sampler path.
|
|
1295
|
+
|
|
1296
|
+
Raises:
|
|
1297
|
+
ValueError: If `step` is negative.
|
|
1298
|
+
"""
|
|
1299
|
+
if step < 0:
|
|
1300
|
+
raise ValueError(f"refresh step must be >= 0, got {step}")
|
|
1301
|
+
if tag is None and self._path is not None and self._last_step == step:
|
|
1302
|
+
return self._path
|
|
1303
|
+
label = f"step-{step:04d}" if tag is None else f"{tag}-step-{step:04d}"
|
|
1304
|
+
path = self._training.save_weights_for_sampler(f"{self._run_name}-{label}")
|
|
1305
|
+
self._client = self._service.create_sampling_client(path)
|
|
1306
|
+
self._path = path
|
|
1307
|
+
self._last_step = step
|
|
1308
|
+
self._warm(step, path)
|
|
1309
|
+
logger.debug("student sampler refreshed for step %d: %s", step, path)
|
|
1310
|
+
return path
|
|
1311
|
+
|
|
1312
|
+
def _warm(self, step: int, path: str) -> None:
|
|
1313
|
+
"""Serve throwaway tokens CONCURRENTLY so the batch never races a cold sampler.
|
|
1314
|
+
|
|
1315
|
+
Freshly published sampler weights are not immediately hot. Launching the whole wave at
|
|
1316
|
+
once means every episode's FIRST call races the same weight load, and the losers exhaust
|
|
1317
|
+
their retries and report `provider_error` at turn 0-2 while the survivors go on to run 24+
|
|
1318
|
+
turns cleanly.
|
|
1319
|
+
|
|
1320
|
+
Two live measurements shaped this, and the second is why the warmup is concurrent rather
|
|
1321
|
+
than a single call:
|
|
1322
|
+
|
|
1323
|
+
1. A 51-episode eval wave lost 7 episodes (~15%) at a mean of 2.0 turns, against 6% for
|
|
1324
|
+
the same model sampled from already-warm BASE weights. A base-weights probe therefore
|
|
1325
|
+
cannot detect this at all.
|
|
1326
|
+
2. After adding a ONE-token serial warmup, a 64-episode training wave still lost 7 of its
|
|
1327
|
+
first 34 episodes (~21%) at a mean of **0.7 turns**. One token wakes the model but does
|
|
1328
|
+
not make it scale: the cost is not a single weight load, it is the sampler's ramp to
|
|
1329
|
+
serving many streams at once, so the warmup has to exercise concurrency too.
|
|
1330
|
+
|
|
1331
|
+
`_WARMUP_STREAMS` concurrent throwaway calls approximate the wave's own fan-out. Failure
|
|
1332
|
+
of any stream is deliberately NOT fatal: a sampler that cannot serve one token will fail
|
|
1333
|
+
loudly in the batch anyway, and aborting a paid run inside a warmup trades a partial batch
|
|
1334
|
+
for no batch.
|
|
1335
|
+
"""
|
|
1336
|
+
client = self._client
|
|
1337
|
+
if client is None: # pragma: no cover - refresh() just assigned it
|
|
1338
|
+
return
|
|
1339
|
+
|
|
1340
|
+
def once() -> None:
|
|
1341
|
+
client.sample(prompt_token_ids=[0], max_tokens=1, temperature=0.0)
|
|
1342
|
+
|
|
1343
|
+
failures: list[BaseException] = []
|
|
1344
|
+
with ThreadPoolExecutor(max_workers=_WARMUP_STREAMS) as pool:
|
|
1345
|
+
for future in [pool.submit(once) for _ in range(_WARMUP_STREAMS)]:
|
|
1346
|
+
error = future.exception()
|
|
1347
|
+
if error is not None:
|
|
1348
|
+
failures.append(error)
|
|
1349
|
+
if failures:
|
|
1350
|
+
first = failures[0]
|
|
1351
|
+
logger.warning(
|
|
1352
|
+
"student sampler warmup for step %d (%s): %d of %d streams did not serve a token "
|
|
1353
|
+
"(first: %s: %s); launching the batch anyway, but expect turn-0 provider_error "
|
|
1354
|
+
"losses if the weights are still loading",
|
|
1355
|
+
step,
|
|
1356
|
+
path,
|
|
1357
|
+
len(failures),
|
|
1358
|
+
_WARMUP_STREAMS,
|
|
1359
|
+
type(first).__name__,
|
|
1360
|
+
first,
|
|
1361
|
+
)
|
|
1362
|
+
|
|
1363
|
+
@property
|
|
1364
|
+
def sampler_path(self) -> str:
|
|
1365
|
+
"""The current tinker:// sampler path."""
|
|
1366
|
+
if self._path is None:
|
|
1367
|
+
raise RuntimeError(
|
|
1368
|
+
"the student sampler has no weights yet; call refresh() before reading sampler_path"
|
|
1369
|
+
)
|
|
1370
|
+
return self._path
|
|
1371
|
+
|
|
1372
|
+
@property
|
|
1373
|
+
def client(self) -> DistillSamplingClient:
|
|
1374
|
+
"""The sampling client for the current weights."""
|
|
1375
|
+
if self._client is None:
|
|
1376
|
+
raise RuntimeError(
|
|
1377
|
+
"the student sampler has no weights yet; call refresh() before reading client"
|
|
1378
|
+
)
|
|
1379
|
+
return self._client
|
|
1380
|
+
|
|
1381
|
+
def provider_config(self, base_model: str) -> ProviderConfig:
|
|
1382
|
+
"""The rollout provider config pointing at the current weights.
|
|
1383
|
+
|
|
1384
|
+
Args:
|
|
1385
|
+
base_model: The student base model name (renderer identity).
|
|
1386
|
+
"""
|
|
1387
|
+
return tinker_provider_config(self.sampler_path, base_model)
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
# -- preflight -----------------------------------------------------------------------------------
|
|
1391
|
+
|
|
1392
|
+
|
|
1393
|
+
def tito_recompute_check(
|
|
1394
|
+
client: DistillSamplingClient,
|
|
1395
|
+
prompt_token_ids: Sequence[int],
|
|
1396
|
+
*,
|
|
1397
|
+
sample_tokens: int = _PREFLIGHT_SAMPLE_TOKENS,
|
|
1398
|
+
temperature: float = 1.0,
|
|
1399
|
+
mean_tolerance: float = DEFAULT_TITO_MEAN_TOLERANCE,
|
|
1400
|
+
max_tolerance: float = DEFAULT_TITO_MAX_TOLERANCE,
|
|
1401
|
+
) -> None:
|
|
1402
|
+
"""The live tokens-in-tokens-out proof: sample, then recompute agreement.
|
|
1403
|
+
|
|
1404
|
+
Samples a short sequence, then asks the same client to `compute_logprobs`
|
|
1405
|
+
on prompt + sampled tokens and requires the recomputed logprobs at the
|
|
1406
|
+
sampled positions to agree with the issued ones. Two bounds apply: the
|
|
1407
|
+
MEAN absolute gap across the sample must stay within `mean_tolerance`
|
|
1408
|
+
(sampler/scorer kernel noise is zero-mean; systematic corruption is not),
|
|
1409
|
+
and no single position may exceed `max_tolerance` (a wrong sampler path,
|
|
1410
|
+
tokenizer drift, or SDK change shifts logprobs by whole nats). Failing
|
|
1411
|
+
either means the sampling and scoring paths do not see the same tokens,
|
|
1412
|
+
which would silently corrupt every training datum.
|
|
1413
|
+
|
|
1414
|
+
Args:
|
|
1415
|
+
client: The student sampling client under test.
|
|
1416
|
+
prompt_token_ids: A non-empty probe prompt.
|
|
1417
|
+
sample_tokens: How many tokens to sample.
|
|
1418
|
+
temperature: Sampling temperature; 1.0 keeps issued logprobs directly
|
|
1419
|
+
comparable to recomputed model logprobs.
|
|
1420
|
+
mean_tolerance: Max acceptable mean |issued - recomputed| over the
|
|
1421
|
+
sampled positions.
|
|
1422
|
+
max_tolerance: Max acceptable |issued - recomputed| at any position.
|
|
1423
|
+
|
|
1424
|
+
Raises:
|
|
1425
|
+
RuntimeError: On empty prompt/sample, missing or misaligned logprobs,
|
|
1426
|
+
or disagreement beyond either bound; the message names the
|
|
1427
|
+
offending statistic and both values.
|
|
1428
|
+
"""
|
|
1429
|
+
prompt = list(prompt_token_ids)
|
|
1430
|
+
if not prompt:
|
|
1431
|
+
raise RuntimeError(
|
|
1432
|
+
"the TITO recompute check needs a non-empty probe prompt; the renderer "
|
|
1433
|
+
"produced no tokens for the preflight message, so check the renderer "
|
|
1434
|
+
"and tokenizer for the student base model"
|
|
1435
|
+
)
|
|
1436
|
+
sequence = client.sample(prompt, max_tokens=sample_tokens, temperature=temperature)
|
|
1437
|
+
sampled = list(sequence.tokens)
|
|
1438
|
+
issued = sequence.logprobs
|
|
1439
|
+
if not sampled:
|
|
1440
|
+
raise RuntimeError(
|
|
1441
|
+
"the TITO recompute check sampled no tokens; the student sampler "
|
|
1442
|
+
"returned an empty sequence for a fresh prompt, so check the sampler "
|
|
1443
|
+
"weights path and the model's availability"
|
|
1444
|
+
)
|
|
1445
|
+
if issued is None or len(issued) != len(sampled):
|
|
1446
|
+
got = "no logprobs" if issued is None else f"{len(issued)} logprobs"
|
|
1447
|
+
raise RuntimeError(
|
|
1448
|
+
f"the TITO recompute check got {got} for {len(sampled)} sampled tokens; "
|
|
1449
|
+
"per-token logprobs are required for tokens-in-tokens-out training, so "
|
|
1450
|
+
"check the tinker SDK version pin"
|
|
1451
|
+
)
|
|
1452
|
+
full = prompt + sampled
|
|
1453
|
+
recomputed = client.compute_logprobs(full)
|
|
1454
|
+
if len(recomputed) != len(full):
|
|
1455
|
+
raise RuntimeError(
|
|
1456
|
+
f"compute_logprobs returned {len(recomputed)} entries for the "
|
|
1457
|
+
f"{len(full)}-token recompute sequence; it must return one entry per "
|
|
1458
|
+
"position, so check the tinker SDK version pin"
|
|
1459
|
+
)
|
|
1460
|
+
gaps: list[float] = []
|
|
1461
|
+
for offset, issued_lp in enumerate(issued):
|
|
1462
|
+
recomputed_lp = recomputed[len(prompt) + offset]
|
|
1463
|
+
if recomputed_lp is None:
|
|
1464
|
+
raise RuntimeError(
|
|
1465
|
+
f"TITO recompute returned no logprob at sampled position {offset} "
|
|
1466
|
+
f"(sampler issued {issued_lp:.4f}). The scoring path cannot see "
|
|
1467
|
+
"the student's own tokens, so training data would be corrupt; "
|
|
1468
|
+
"check that the sampler path and base model match and that the "
|
|
1469
|
+
"pinned tinker SDK is unchanged"
|
|
1470
|
+
)
|
|
1471
|
+
gap = abs(recomputed_lp - issued_lp)
|
|
1472
|
+
if gap > max_tolerance:
|
|
1473
|
+
raise RuntimeError(
|
|
1474
|
+
f"TITO recompute disagreement at sampled position {offset}: the "
|
|
1475
|
+
f"sampler issued logprob {issued_lp:.4f} but compute_logprobs "
|
|
1476
|
+
f"returned {recomputed_lp:.4f} (gap {gap:.4f} > per-position "
|
|
1477
|
+
f"bound {max_tolerance}). A gap this large means the sampling "
|
|
1478
|
+
"and scoring paths disagree on the student's own tokens, so "
|
|
1479
|
+
"training data would be corrupt; check that the sampler path "
|
|
1480
|
+
"and base model match and that the pinned tinker SDK is unchanged"
|
|
1481
|
+
)
|
|
1482
|
+
gaps.append(gap)
|
|
1483
|
+
mean_gap = sum(gaps) / len(gaps)
|
|
1484
|
+
if mean_gap > mean_tolerance:
|
|
1485
|
+
worst = max(range(len(gaps)), key=gaps.__getitem__)
|
|
1486
|
+
raise RuntimeError(
|
|
1487
|
+
f"TITO recompute disagreement: mean |issued - recomputed| logprob "
|
|
1488
|
+
f"gap {mean_gap:.4f} over {len(gaps)} sampled tokens exceeds "
|
|
1489
|
+
f"{mean_tolerance} (worst position {worst}: gap {gaps[worst]:.4f}). "
|
|
1490
|
+
"Sampler/scorer kernel noise is zero-mean, so a systematic gap "
|
|
1491
|
+
"means the paths disagree on the student's own tokens and training "
|
|
1492
|
+
"data would be corrupt; check that the sampler path and base model "
|
|
1493
|
+
"match and that the pinned tinker SDK is unchanged"
|
|
1494
|
+
)
|
|
1495
|
+
logger.info(
|
|
1496
|
+
"TITO recompute check passed: mean gap %.4f, max gap %.4f over %d tokens",
|
|
1497
|
+
mean_gap,
|
|
1498
|
+
max(gaps),
|
|
1499
|
+
len(gaps),
|
|
1500
|
+
)
|
|
1501
|
+
|
|
1502
|
+
|
|
1503
|
+
# -- internal helpers ----------------------------------------------------------------------------
|
|
1504
|
+
|
|
1505
|
+
|
|
1506
|
+
def resume_command(name: str, run_dir: Path) -> str:
|
|
1507
|
+
"""The CLI command that resumes a distillation run.
|
|
1508
|
+
|
|
1509
|
+
The CLI layer (`wmo/cli/harness_distill.py`) reuses this helper so the
|
|
1510
|
+
command printed on a budget abort stays the command that actually works.
|
|
1511
|
+
On resume the run's pinned config is the `config.toml` snapshot inside
|
|
1512
|
+
the run dir. `name` must be the agent string as the user typed it (an
|
|
1513
|
+
@ref included) or the printed command trips the CLI resume conflict
|
|
1514
|
+
check.
|
|
1515
|
+
"""
|
|
1516
|
+
return f"wmo optimize harness {name} harbor --mode distill --run-dir {run_dir} --resume"
|
|
1517
|
+
|
|
1518
|
+
|
|
1519
|
+
def pin_rollout_params(harness: HarnessDoc, cfg: DistillConfig) -> HarnessDoc:
|
|
1520
|
+
"""Pin the config's rollout knobs onto the harness document the trials run.
|
|
1521
|
+
|
|
1522
|
+
The pi runtimes read their sampling temperature, turn cap, and per-call
|
|
1523
|
+
output cap from the document's param surfaces, so `[sampling]` and
|
|
1524
|
+
`[rollout] max_turns` only take effect by being written INTO the document:
|
|
1525
|
+
`sampling.temperature` -> `param:temperature`, `rollout.max_turns` ->
|
|
1526
|
+
`param:max-turns`, `sampling.max_tokens` -> `param:max-output-tokens`.
|
|
1527
|
+
The result is a pure function of (seed document, config), so every session
|
|
1528
|
+
and step of a run derives the identical document (and harbor job identity).
|
|
1529
|
+
|
|
1530
|
+
A temperature other than 1.0 is allowed but warned about: the sampler's
|
|
1531
|
+
issued logprobs are temperature-scaled while the teacher's
|
|
1532
|
+
`compute_logprobs` are not, which biases the reverse-KL advantages and the
|
|
1533
|
+
importance-sampling correction.
|
|
1534
|
+
|
|
1535
|
+
Args:
|
|
1536
|
+
harness: The seed harness document.
|
|
1537
|
+
cfg: The validated run config.
|
|
1538
|
+
|
|
1539
|
+
Returns:
|
|
1540
|
+
A new validated document with the three param surfaces replaced.
|
|
1541
|
+
"""
|
|
1542
|
+
if cfg.sampling.temperature != 1.0:
|
|
1543
|
+
logger.warning(
|
|
1544
|
+
"sampling.temperature = %s: sampler-issued logprobs are temperature-scaled "
|
|
1545
|
+
"but teacher logprobs are not, so reverse-KL advantages are biased; use "
|
|
1546
|
+
"temperature = 1.0 for faithful importance weights",
|
|
1547
|
+
cfg.sampling.temperature,
|
|
1548
|
+
)
|
|
1549
|
+
replacements = {
|
|
1550
|
+
TEMPERATURE_ID: str(cfg.sampling.temperature),
|
|
1551
|
+
MAX_TURNS_ID: str(cfg.rollout.max_turns),
|
|
1552
|
+
MAX_OUTPUT_TOKENS_ID: str(cfg.sampling.max_tokens),
|
|
1553
|
+
}
|
|
1554
|
+
surfaces = [surface for surface in harness.surfaces if surface.id not in replacements]
|
|
1555
|
+
surfaces.extend(
|
|
1556
|
+
Surface(id=surface_id, kind=SurfaceKind.PARAM, content=content)
|
|
1557
|
+
for surface_id, content in replacements.items()
|
|
1558
|
+
)
|
|
1559
|
+
# Reconstruct (not model_copy) so the document re-validates as a whole.
|
|
1560
|
+
return HarnessDoc(name=harness.name, version=harness.version, surfaces=surfaces)
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
def _batch_reverse_kl(
|
|
1564
|
+
datums: Sequence[TrainDatum], rows: Sequence[Sequence[float | None]]
|
|
1565
|
+
) -> float | None:
|
|
1566
|
+
"""The batch's reverse KL per token from realized teacher logprobs.
|
|
1567
|
+
|
|
1568
|
+
`mean(sampled_lp - teacher_lp)` over every scored loss token; None when
|
|
1569
|
+
nothing was scored. Both loss modes feed this the teacher's logprobs of
|
|
1570
|
+
the REALIZED (student-sampled) tokens (`TeacherClient.score` rows, or
|
|
1571
|
+
`TeacherTopkScores.realized`), so the metric means the same thing across
|
|
1572
|
+
modes.
|
|
1573
|
+
|
|
1574
|
+
Args:
|
|
1575
|
+
datums: The step's datums.
|
|
1576
|
+
rows: One per-position realized-logprob row per datum, aligned with
|
|
1577
|
+
`datums`; misaligned rows are skipped (the datum path drops and
|
|
1578
|
+
counts them separately).
|
|
1579
|
+
"""
|
|
1580
|
+
kl_sum = 0.0
|
|
1581
|
+
kl_count = 0
|
|
1582
|
+
for datum, row in zip(datums, rows, strict=True):
|
|
1583
|
+
if len(row) != len(datum.model_input_tokens):
|
|
1584
|
+
continue # misaligned row: the datum builders drop and count it
|
|
1585
|
+
for position, teacher_lp in enumerate(row):
|
|
1586
|
+
if teacher_lp is None:
|
|
1587
|
+
continue
|
|
1588
|
+
kl_sum += datum.sampled_logprobs[position] - teacher_lp
|
|
1589
|
+
kl_count += 1
|
|
1590
|
+
return kl_sum / kl_count if kl_count else None
|
|
1591
|
+
|
|
1592
|
+
|
|
1593
|
+
def _split_cached_prefill(submitted: int, cached: int) -> tuple[int, int]:
|
|
1594
|
+
"""Split one batch's scoring volume into (uncached, cached) billable tokens.
|
|
1595
|
+
|
|
1596
|
+
Pure arithmetic, separated from the charging so the guards can be tested
|
|
1597
|
+
without a loop instance. Both inputs are DELTAS of monotonic counters, and
|
|
1598
|
+
both can arrive nonsensical:
|
|
1599
|
+
|
|
1600
|
+
- `cached` can be negative when the teacher rebuilt its scorer mid-batch
|
|
1601
|
+
(the wedged-session path drops and replaces it, restarting the counter).
|
|
1602
|
+
Left alone that would CREDIT the run for spend it really made.
|
|
1603
|
+
- `cached` can exceed `submitted` for the same reason, or if a response
|
|
1604
|
+
counts cache hits over a prompt the caller did not attribute to this
|
|
1605
|
+
batch, which would bill negative uncached tokens.
|
|
1606
|
+
|
|
1607
|
+
Args:
|
|
1608
|
+
submitted: Tokens submitted for scoring in this batch.
|
|
1609
|
+
cached: Tokens the service reported serving from its prefix cache.
|
|
1610
|
+
|
|
1611
|
+
Returns:
|
|
1612
|
+
`(uncached, cached)`, both non-negative and summing to `max(submitted, 0)`.
|
|
1613
|
+
"""
|
|
1614
|
+
total = max(submitted, 0)
|
|
1615
|
+
billable_cached = min(max(cached, 0), total)
|
|
1616
|
+
return total - billable_cached, billable_cached
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
def _teacher_cached_usage(teacher: TeacherClient) -> int:
|
|
1620
|
+
"""The teacher's cumulative service-cached prefill tokens, or 0 if unknown.
|
|
1621
|
+
|
|
1622
|
+
Args:
|
|
1623
|
+
teacher: The run's teacher client.
|
|
1624
|
+
|
|
1625
|
+
Returns:
|
|
1626
|
+
`cached_usage()` when the teacher reports one, else 0 — which bills every
|
|
1627
|
+
scoring token at the uncached rate, the behaviour the ledger had before
|
|
1628
|
+
the field was readable.
|
|
1629
|
+
"""
|
|
1630
|
+
if isinstance(teacher, CachedUsageTeacher):
|
|
1631
|
+
return teacher.cached_usage()
|
|
1632
|
+
return 0
|
|
1633
|
+
|
|
1634
|
+
|
|
1635
|
+
def _teacher_rows(
|
|
1636
|
+
teacher: TeacherClient, datums: Sequence[TrainDatum]
|
|
1637
|
+
) -> tuple[list[list[float | None]], float | None]:
|
|
1638
|
+
"""Score datums with the teacher and compute the batch's reverse KL.
|
|
1639
|
+
|
|
1640
|
+
The teacher consumes the loop's own datums and returns one per-position
|
|
1641
|
+
row per datum in the compute_logprobs convention (loss positions carry a
|
|
1642
|
+
logprob; everything else, including a position-0 loss token the teacher
|
|
1643
|
+
cannot condition on, stays None and is dropped loudly downstream by
|
|
1644
|
+
`attach_advantages`).
|
|
1645
|
+
|
|
1646
|
+
Args:
|
|
1647
|
+
teacher: The teacher backend.
|
|
1648
|
+
datums: The step's datums.
|
|
1649
|
+
|
|
1650
|
+
Returns:
|
|
1651
|
+
The per-position teacher rows (aligned one to one with `datums`) and
|
|
1652
|
+
the batch's reverse KL per token, `mean(sampled_lp - teacher_lp)` over
|
|
1653
|
+
every scored loss token (None when nothing was scored).
|
|
1654
|
+
"""
|
|
1655
|
+
rows = teacher.score(list(datums))
|
|
1656
|
+
return rows, _batch_reverse_kl(datums, rows)
|
|
1657
|
+
|
|
1658
|
+
|
|
1659
|
+
def _graded_phrase(graded_solve_rate: float, graded_trials: int) -> str:
|
|
1660
|
+
"""One progress-line clause for the graded rate, saying so when there is none.
|
|
1661
|
+
|
|
1662
|
+
A batch with no readable test report has no graded score, and its stats carry 0.0 as a
|
|
1663
|
+
placeholder; printing that as "graded 0.000" would report a null measurement as a total failure.
|
|
1664
|
+
"""
|
|
1665
|
+
if not graded_trials:
|
|
1666
|
+
return "no graded score (no readable test report)"
|
|
1667
|
+
return f"graded {graded_solve_rate:.3f} over {graded_trials} graded trial(s)"
|
|
1668
|
+
|
|
1669
|
+
|
|
1670
|
+
def _measured_graded_rate(report: DistillEvalReport) -> float | None:
|
|
1671
|
+
"""The report's graded solve rate, or None when it measured none.
|
|
1672
|
+
|
|
1673
|
+
None covers both an eval whose trials left no readable test report and a baseline imported from
|
|
1674
|
+
a run that predates the field (`graded_trials == 0`), so neither charts a fabricated 0.0.
|
|
1675
|
+
"""
|
|
1676
|
+
return report.graded_solve_rate if report.graded_trials else None
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
class _RunBudget:
|
|
1680
|
+
"""This session's `BudgetMeter` plus the USD prior sessions already spent.
|
|
1681
|
+
|
|
1682
|
+
The cap is enforced over the TOTAL (prior + session) so a resumed run
|
|
1683
|
+
cannot spend the budget twice; the inner meter never enforces on its own.
|
|
1684
|
+
Every charge is pushed through `on_spend` (the run store's spend ledger)
|
|
1685
|
+
so every recorded charge survives a crash (metrics rows alone would lose
|
|
1686
|
+
everything charged since the last completed step; work still in flight when
|
|
1687
|
+
a session dies is charged only when its batch returns).
|
|
1688
|
+
"""
|
|
1689
|
+
|
|
1690
|
+
def __init__(
|
|
1691
|
+
self,
|
|
1692
|
+
pricing: PricingConfig,
|
|
1693
|
+
max_usd: float | None,
|
|
1694
|
+
prior_usd: float,
|
|
1695
|
+
*,
|
|
1696
|
+
on_spend: Callable[[float], None] | None = None,
|
|
1697
|
+
) -> None:
|
|
1698
|
+
self._meter = BudgetMeter(pricing, max_usd=None)
|
|
1699
|
+
self._max_usd = max_usd
|
|
1700
|
+
self.prior_usd = prior_usd
|
|
1701
|
+
self._on_spend = on_spend
|
|
1702
|
+
|
|
1703
|
+
def charge(self, meter: MeterName, tokens: int) -> None:
|
|
1704
|
+
"""Record actual token usage against one meter and persist the total."""
|
|
1705
|
+
self._meter.charge(meter, tokens)
|
|
1706
|
+
if self._on_spend is not None:
|
|
1707
|
+
self._on_spend(self.total_usd)
|
|
1708
|
+
|
|
1709
|
+
def check(self) -> None:
|
|
1710
|
+
"""Enforce the cap over the total spend.
|
|
1711
|
+
|
|
1712
|
+
Raises:
|
|
1713
|
+
BudgetExhausted: When the cap is set and the total exceeds it.
|
|
1714
|
+
"""
|
|
1715
|
+
if self._max_usd is not None and self.total_usd > self._max_usd:
|
|
1716
|
+
raise BudgetExhausted(self.total_usd, self._max_usd)
|
|
1717
|
+
|
|
1718
|
+
@property
|
|
1719
|
+
def session_usd(self) -> float:
|
|
1720
|
+
"""Priced USD spent by this session only."""
|
|
1721
|
+
return self._meter.spent_usd
|
|
1722
|
+
|
|
1723
|
+
@property
|
|
1724
|
+
def total_usd(self) -> float:
|
|
1725
|
+
"""Priced USD across prior sessions and this one."""
|
|
1726
|
+
return self.prior_usd + self._meter.spent_usd
|
|
1727
|
+
|
|
1728
|
+
def tokens(self, meter: MeterName) -> int:
|
|
1729
|
+
"""This session's tokens charged to one meter."""
|
|
1730
|
+
return self._meter.tokens(meter)
|
|
1731
|
+
|
|
1732
|
+
def lines(self) -> list[CostLine]:
|
|
1733
|
+
"""This session's actuals in the estimate's line shape."""
|
|
1734
|
+
return self._meter.lines()
|
|
1735
|
+
|
|
1736
|
+
|
|
1737
|
+
class _DistillRun:
|
|
1738
|
+
"""One orchestrated distillation run; `run_distillation` drives it."""
|
|
1739
|
+
|
|
1740
|
+
def __init__(
|
|
1741
|
+
self,
|
|
1742
|
+
name: str,
|
|
1743
|
+
cfg: DistillConfig,
|
|
1744
|
+
harness: HarnessDoc,
|
|
1745
|
+
train_task_ids: Sequence[str],
|
|
1746
|
+
holdout_task_ids: Sequence[str],
|
|
1747
|
+
run_dir: Path,
|
|
1748
|
+
*,
|
|
1749
|
+
service: DistillServiceClient,
|
|
1750
|
+
adapter_store: AdapterStore,
|
|
1751
|
+
on_progress: ProgressCallback | None,
|
|
1752
|
+
live_trial_preflight: LiveTrialPreflight | None,
|
|
1753
|
+
tracker: DistillTracker,
|
|
1754
|
+
cli_agent: str | None = None,
|
|
1755
|
+
) -> None:
|
|
1756
|
+
self._name = name
|
|
1757
|
+
# The agent string resume commands print; the run/adapter name strips any
|
|
1758
|
+
# @ref, but a resume must be invoked with the string the run started with.
|
|
1759
|
+
self._cli_agent = cli_agent if cli_agent is not None else name
|
|
1760
|
+
self._cfg = cfg
|
|
1761
|
+
self._harness = pin_rollout_params(harness, cfg)
|
|
1762
|
+
self._train_ids = list(train_task_ids)
|
|
1763
|
+
self._holdout_ids = list(holdout_task_ids)
|
|
1764
|
+
self._run_dir = run_dir
|
|
1765
|
+
self._service = service
|
|
1766
|
+
self._adapters = adapter_store
|
|
1767
|
+
self._on_progress = on_progress
|
|
1768
|
+
self._live_trial_preflight = live_trial_preflight
|
|
1769
|
+
self._tracker = tracker
|
|
1770
|
+
self._store = DistillRunStore(run_dir)
|
|
1771
|
+
self._teacher_identity = cfg.teacher.checkpoint or cfg.teacher.model
|
|
1772
|
+
# Set by _preflight (the renderer needs the training client's tokenizer);
|
|
1773
|
+
# kept for sample-rollout logging across every later batch.
|
|
1774
|
+
self._rendering: ChatRendering | None = None
|
|
1775
|
+
# Set up by execute():
|
|
1776
|
+
self._training: DistillTrainingClient
|
|
1777
|
+
self._teacher: TinkerTeacher
|
|
1778
|
+
self._teacher_client: DistillSamplingClient
|
|
1779
|
+
self._sampler: StudentSampler
|
|
1780
|
+
self._tasks: TaskSampler
|
|
1781
|
+
self._budget: _RunBudget
|
|
1782
|
+
self._prev_tokens: dict[MeterName, int] = dict.fromkeys(METER_NAMES, 0)
|
|
1783
|
+
self._prev_usd = 0.0
|
|
1784
|
+
self._empty_step_streak = 0
|
|
1785
|
+
self._degeneration_streak = 0
|
|
1786
|
+
# Loaded from the run manifest on resume (never re-measured there);
|
|
1787
|
+
# captured at this session's first training step otherwise.
|
|
1788
|
+
self._tripwire_baseline: TripwireBaseline | None = None
|
|
1789
|
+
self._resumed = False
|
|
1790
|
+
|
|
1791
|
+
# -- plumbing --------------------------------------------------------------------------------
|
|
1792
|
+
|
|
1793
|
+
def _emit(self, phase: DistillPhase, message: str, *, step: int | None = None) -> None:
|
|
1794
|
+
logger.info("[%s] %s", phase, message)
|
|
1795
|
+
if self._on_progress is not None:
|
|
1796
|
+
self._on_progress(
|
|
1797
|
+
DistillProgress(
|
|
1798
|
+
phase=phase,
|
|
1799
|
+
message=message,
|
|
1800
|
+
total_steps=self._cfg.train.steps,
|
|
1801
|
+
step=step,
|
|
1802
|
+
spent_usd=max(self._budget.total_usd, 0.0),
|
|
1803
|
+
)
|
|
1804
|
+
)
|
|
1805
|
+
|
|
1806
|
+
def _abort_for_budget(self, exc: BudgetExhausted, *, completed_step: int | None) -> NoReturn:
|
|
1807
|
+
"""Persist what can be persisted, then raise the typed budget error.
|
|
1808
|
+
|
|
1809
|
+
Args:
|
|
1810
|
+
exc: The meter's exhaustion error.
|
|
1811
|
+
completed_step: The last fully completed training step of this
|
|
1812
|
+
session, or None when no step completed (nothing new to
|
|
1813
|
+
checkpoint; recorded artifacts are reused on resume).
|
|
1814
|
+
"""
|
|
1815
|
+
if completed_step is not None:
|
|
1816
|
+
state_path = self._training.save_state()
|
|
1817
|
+
self._store.record_checkpoint(completed_step, state_path, self._sampler.sampler_path)
|
|
1818
|
+
saved = f"training state was saved (checkpoint at step {completed_step})"
|
|
1819
|
+
else:
|
|
1820
|
+
saved = (
|
|
1821
|
+
"no training step completed in this session, so the run resumes "
|
|
1822
|
+
"from its recorded artifacts"
|
|
1823
|
+
)
|
|
1824
|
+
command = resume_command(self._cli_agent, self._run_dir)
|
|
1825
|
+
raise DistillBudgetError(
|
|
1826
|
+
f"budget exhausted: ${exc.spent_usd:.2f} spent against the "
|
|
1827
|
+
f"${exc.max_usd:.2f} cap (budget.max_usd); {saved}. Raise budget.max_usd "
|
|
1828
|
+
f"in {self._store.config_path} and resume with: {command}",
|
|
1829
|
+
resume_command=command,
|
|
1830
|
+
spent_usd=exc.spent_usd,
|
|
1831
|
+
max_usd=exc.max_usd,
|
|
1832
|
+
) from exc
|
|
1833
|
+
|
|
1834
|
+
def _check_empty_batch_streak(self, step: int, trials: int, empty: int, datums: int) -> None:
|
|
1835
|
+
"""Track all-empty training steps and abort after the tolerated streak.
|
|
1836
|
+
|
|
1837
|
+
Args:
|
|
1838
|
+
step: The 0-based training step that just completed its metrics row.
|
|
1839
|
+
trials: The step's trial count.
|
|
1840
|
+
empty: Trials that recorded no token span.
|
|
1841
|
+
datums: Trainable datums the step produced.
|
|
1842
|
+
|
|
1843
|
+
Raises:
|
|
1844
|
+
DistillEmptyBatchError: After `MAX_CONSECUTIVE_EMPTY_STEPS` steps
|
|
1845
|
+
in a row where every trial produced zero spans and no datums
|
|
1846
|
+
(a step with zero trials counts: no trials is at least as dead
|
|
1847
|
+
as all-empty trials); the step's state is checkpointed first
|
|
1848
|
+
and the message carries the exact resume command.
|
|
1849
|
+
"""
|
|
1850
|
+
if not (empty == trials and datums == 0):
|
|
1851
|
+
self._empty_step_streak = 0
|
|
1852
|
+
return
|
|
1853
|
+
self._empty_step_streak += 1
|
|
1854
|
+
logger.warning(
|
|
1855
|
+
"step %d is all-empty (%d trial(s), every one without token spans); "
|
|
1856
|
+
"%d/%d consecutive empty step(s) before the run aborts",
|
|
1857
|
+
step,
|
|
1858
|
+
trials,
|
|
1859
|
+
self._empty_step_streak,
|
|
1860
|
+
MAX_CONSECUTIVE_EMPTY_STEPS,
|
|
1861
|
+
)
|
|
1862
|
+
if self._empty_step_streak < MAX_CONSECUTIVE_EMPTY_STEPS:
|
|
1863
|
+
return
|
|
1864
|
+
state_path = self._training.save_state()
|
|
1865
|
+
self._store.record_checkpoint(step, state_path, self._sampler.sampler_path)
|
|
1866
|
+
command = resume_command(self._cli_agent, self._run_dir)
|
|
1867
|
+
raise DistillEmptyBatchError(
|
|
1868
|
+
f"aborting after {self._empty_step_streak} consecutive training steps in "
|
|
1869
|
+
"which every trial produced zero token spans (0 trainable datums): the "
|
|
1870
|
+
"trials are producing no completions, so training cannot make progress. "
|
|
1871
|
+
"Two causes account for nearly all of these: provider or session failures "
|
|
1872
|
+
"upstream in the rollout trials (see the runner logs for worker completion "
|
|
1873
|
+
"warnings), or, with harbor.backend = 'e2b', trials dying at sandbox "
|
|
1874
|
+
"creation because the E2B account is at its concurrent-sandbox cap (run "
|
|
1875
|
+
"`wmo e2b reap` to see what is holding the slots). Run artifacts were "
|
|
1876
|
+
f"persisted (checkpoint at step {step}), so once the cause is fixed resume "
|
|
1877
|
+
f"with: {command}",
|
|
1878
|
+
resume_command=command,
|
|
1879
|
+
consecutive_steps=self._empty_step_streak,
|
|
1880
|
+
)
|
|
1881
|
+
|
|
1882
|
+
def _arm_tripwire(self, step: int, health: PolicyHealth) -> TripwireBaseline | None:
|
|
1883
|
+
"""Return the run's tripwire baseline, capturing it at the first step.
|
|
1884
|
+
|
|
1885
|
+
Capture happens exactly once per RUN, not per session: a baseline read
|
|
1886
|
+
back from the manifest on resume is kept as is, because re-measuring
|
|
1887
|
+
against a policy that already degenerated is what makes a resumed run's
|
|
1888
|
+
tripwire blind. Capture is deliberately NOT gated on
|
|
1889
|
+
`tripwire.enabled`: the baseline and the per-step ratios cost nothing
|
|
1890
|
+
and stay in `metrics.jsonl` even when the abort is switched off.
|
|
1891
|
+
|
|
1892
|
+
Args:
|
|
1893
|
+
step: The 0-based training step being measured.
|
|
1894
|
+
health: That step's batch-pooled health.
|
|
1895
|
+
|
|
1896
|
+
Returns:
|
|
1897
|
+
The armed baseline, or None when no step has been measurable yet (an
|
|
1898
|
+
all-empty batch cannot serve as a reference); the next step retries.
|
|
1899
|
+
"""
|
|
1900
|
+
if self._tripwire_baseline is not None:
|
|
1901
|
+
return self._tripwire_baseline
|
|
1902
|
+
captured = capture_baseline(step, health)
|
|
1903
|
+
if captured is None:
|
|
1904
|
+
logger.warning(
|
|
1905
|
+
"step %d measured no usable policy baseline (%d episode(s) with spans, "
|
|
1906
|
+
"%d sampled token(s)), so the degeneration tripwire stays unarmed; the "
|
|
1907
|
+
"next step retries",
|
|
1908
|
+
step,
|
|
1909
|
+
health.episodes,
|
|
1910
|
+
health.sampled_tokens,
|
|
1911
|
+
)
|
|
1912
|
+
return None
|
|
1913
|
+
self._tripwire_baseline = captured
|
|
1914
|
+
self._store.write_tripwire_baseline(captured)
|
|
1915
|
+
cfg = self._cfg.tripwire
|
|
1916
|
+
logger.info(
|
|
1917
|
+
"degeneration tripwire armed from step %d: entropy %.4f nats/token, %.0f sampled "
|
|
1918
|
+
"tokens/episode (%d episode(s), %d token(s)). Thresholds are FRACTIONS of these, "
|
|
1919
|
+
"never absolutes: entropy warn %.2f / kill %.2f, length warn %.2f / kill %.2f, "
|
|
1920
|
+
"kill after %d consecutive step(s)%s",
|
|
1921
|
+
captured.step,
|
|
1922
|
+
captured.entropy_per_token,
|
|
1923
|
+
captured.mean_generation_tokens,
|
|
1924
|
+
captured.episodes,
|
|
1925
|
+
captured.sampled_tokens,
|
|
1926
|
+
cfg.entropy_warn_frac,
|
|
1927
|
+
cfg.entropy_kill_frac,
|
|
1928
|
+
cfg.length_warn_frac,
|
|
1929
|
+
cfg.length_kill_frac,
|
|
1930
|
+
cfg.kill_consecutive_steps,
|
|
1931
|
+
"" if cfg.enabled else " (tripwire.enabled = false: metrics only, no abort)",
|
|
1932
|
+
)
|
|
1933
|
+
return captured
|
|
1934
|
+
|
|
1935
|
+
def _check_degeneration(
|
|
1936
|
+
self, step: int, health: PolicyHealth, baseline: TripwireBaseline | None
|
|
1937
|
+
) -> None:
|
|
1938
|
+
"""Warn on a degeneration breach, and abort after a kill-level streak.
|
|
1939
|
+
|
|
1940
|
+
Args:
|
|
1941
|
+
step: The 0-based training step that just completed its metrics row.
|
|
1942
|
+
health: That step's batch-pooled health.
|
|
1943
|
+
baseline: The run's armed baseline, or None when none exists yet.
|
|
1944
|
+
|
|
1945
|
+
Raises:
|
|
1946
|
+
DistillDegenerationError: After `tripwire.kill_consecutive_steps`
|
|
1947
|
+
steps in a row at kill level; the step's state is checkpointed
|
|
1948
|
+
first and the message carries the exact resume command. A step
|
|
1949
|
+
that measured nothing at all breaks neither the streak nor the
|
|
1950
|
+
silence: it is not evidence, and the empty-batch abort owns it.
|
|
1951
|
+
"""
|
|
1952
|
+
cfg = self._cfg.tripwire
|
|
1953
|
+
if not cfg.enabled or baseline is None:
|
|
1954
|
+
return
|
|
1955
|
+
if baseline.step == step:
|
|
1956
|
+
# The baseline step is its own reference (ratio 1.0), so it can only
|
|
1957
|
+
# breach through a misconfigured fraction above 1.0. It never fires.
|
|
1958
|
+
return
|
|
1959
|
+
if health.entropy_per_token is None and health.mean_generation_tokens is None:
|
|
1960
|
+
# An all-empty batch is no evidence either way, so it neither breaches
|
|
1961
|
+
# nor clears a streak (the empty-batch abort is what owns that case).
|
|
1962
|
+
return
|
|
1963
|
+
breaches = evaluate_breaches(cfg, baseline, health)
|
|
1964
|
+
for breach in breaches:
|
|
1965
|
+
logger.warning("degeneration tripwire at step %d: %s", step, breach.describe())
|
|
1966
|
+
kills = [breach for breach in breaches if breach.level == "kill"]
|
|
1967
|
+
if not kills:
|
|
1968
|
+
self._degeneration_streak = 0
|
|
1969
|
+
return
|
|
1970
|
+
self._degeneration_streak += 1
|
|
1971
|
+
if self._degeneration_streak < cfg.kill_consecutive_steps:
|
|
1972
|
+
logger.warning(
|
|
1973
|
+
"step %d is at a degeneration KILL level; %d/%d consecutive step(s) before "
|
|
1974
|
+
"the run aborts (a healthy step resets the streak)",
|
|
1975
|
+
step,
|
|
1976
|
+
self._degeneration_streak,
|
|
1977
|
+
cfg.kill_consecutive_steps,
|
|
1978
|
+
)
|
|
1979
|
+
return
|
|
1980
|
+
state_path = self._training.save_state()
|
|
1981
|
+
self._store.record_checkpoint(step, state_path, self._sampler.sampler_path)
|
|
1982
|
+
command = resume_command(self._cli_agent, self._run_dir)
|
|
1983
|
+
detail = "; ".join(breach.describe() for breach in kills)
|
|
1984
|
+
raise DistillDegenerationError(
|
|
1985
|
+
f"aborting after {self._degeneration_streak} consecutive training steps at a "
|
|
1986
|
+
f"degeneration kill level: {detail}. The student's own sampled tokens collapsed "
|
|
1987
|
+
"against the baseline this run measured at its first training step, which is the "
|
|
1988
|
+
"failure reverse KL alone cannot show (KL falls while the policy degenerates): "
|
|
1989
|
+
"entropy falling is mode collapse, length falling is answers collapsing toward "
|
|
1990
|
+
"empty. Read the step's solve_rate beside it (a real collapse takes the solve rate "
|
|
1991
|
+
"with it, a student that merely got more efficient does not), then lower "
|
|
1992
|
+
"train.learning_rate or restart from an earlier checkpoint before spending more. "
|
|
1993
|
+
"Run artifacts were "
|
|
1994
|
+
f"persisted (checkpoint at step {step}), and the recorded baseline is reused as is "
|
|
1995
|
+
f"on resume, so a resumed run is not re-anchored on the collapse: {command}",
|
|
1996
|
+
resume_command=command,
|
|
1997
|
+
consecutive_steps=self._degeneration_streak,
|
|
1998
|
+
breaches=kills,
|
|
1999
|
+
)
|
|
2000
|
+
|
|
2001
|
+
def _charge_teacher_scoring(self, usage_before: int, cached_before: int) -> None:
|
|
2002
|
+
"""Charge one batch of teacher scoring, splitting off what the service cached.
|
|
2003
|
+
|
|
2004
|
+
The cached split is the SERVICE's own `prompt_cache_hit_tokens`, not a
|
|
2005
|
+
wmo-side guess at which prefixes repeat (that is what `batch_billing`
|
|
2006
|
+
does for rollouts). Before this was read, every scoring token was billed
|
|
2007
|
+
at the uncached rate, which is why the run ledger is a ceiling rather
|
|
2008
|
+
than a bill: the one calibration available put the estimate at \\$1,332
|
|
2009
|
+
against a true \\$1,140.92.
|
|
2010
|
+
|
|
2011
|
+
Falls back to charging everything uncached when the teacher cannot report
|
|
2012
|
+
a cache count, so an injected fake or a future SDK that drops the field
|
|
2013
|
+
can only ever make the ledger conservative, never optimistic.
|
|
2014
|
+
|
|
2015
|
+
Args:
|
|
2016
|
+
usage_before: `teacher.usage()` sampled before the scoring call.
|
|
2017
|
+
cached_before: `cached_usage()` sampled at the same moment.
|
|
2018
|
+
"""
|
|
2019
|
+
uncached, cached = _split_cached_prefill(
|
|
2020
|
+
self._teacher.usage() - usage_before,
|
|
2021
|
+
_teacher_cached_usage(self._teacher) - cached_before,
|
|
2022
|
+
)
|
|
2023
|
+
self._budget.charge("teacher_prefill", uncached)
|
|
2024
|
+
self._budget.charge("teacher_cached_prefill", cached)
|
|
2025
|
+
|
|
2026
|
+
def _charge_rollout_billing(self, billing: SpanBilling, *, teacher: bool) -> None:
|
|
2027
|
+
"""Charge one rollout batch's per-request billing to its model's meters.
|
|
2028
|
+
|
|
2029
|
+
Both models bill the same way (unique tokens at the full prefill
|
|
2030
|
+
rate, the repeated per-request volume at the cached rate, sampled
|
|
2031
|
+
tokens at the sampling rate); only the meter family differs.
|
|
2032
|
+
Teacher-in-harness episodes bill teacher_sample on what they
|
|
2033
|
+
generate, which is what estimate_run_cost projects for them.
|
|
2034
|
+
|
|
2035
|
+
Args:
|
|
2036
|
+
billing: The batch's measured volumes (`batch_billing`).
|
|
2037
|
+
teacher: Charge the teacher meters (teacher-in-harness episodes:
|
|
2038
|
+
warmup collection, the gate's teacher baseline) instead of
|
|
2039
|
+
the student meters.
|
|
2040
|
+
"""
|
|
2041
|
+
if teacher:
|
|
2042
|
+
self._budget.charge("teacher_prefill", billing.unique_tokens)
|
|
2043
|
+
self._budget.charge("teacher_cached_prefill", billing.cached_tokens)
|
|
2044
|
+
self._budget.charge("teacher_sample", billing.sampled_tokens)
|
|
2045
|
+
else:
|
|
2046
|
+
self._budget.charge("student_prefill", billing.unique_tokens)
|
|
2047
|
+
self._budget.charge("student_cached_prefill", billing.cached_tokens)
|
|
2048
|
+
self._budget.charge("student_sample", billing.sampled_tokens)
|
|
2049
|
+
|
|
2050
|
+
def _meter_deltas(self) -> dict[MeterName, int]:
|
|
2051
|
+
"""Per-meter token deltas since the previous metrics row's cursor."""
|
|
2052
|
+
return {
|
|
2053
|
+
meter: self._budget.tokens(meter) - self._prev_tokens[meter] for meter in METER_NAMES
|
|
2054
|
+
}
|
|
2055
|
+
|
|
2056
|
+
def _student_provider(self) -> ProviderConfig:
|
|
2057
|
+
return self._sampler.provider_config(self._cfg.student.base_model)
|
|
2058
|
+
|
|
2059
|
+
def _teacher_provider(self) -> ProviderConfig:
|
|
2060
|
+
return tinker_provider_config(self._teacher_identity, self._cfg.teacher.model)
|
|
2061
|
+
|
|
2062
|
+
def _log_sample_rollouts(
|
|
2063
|
+
self, *, kind: str, name: str, step: int | None, records: Sequence[TrialRecord]
|
|
2064
|
+
) -> None:
|
|
2065
|
+
"""Persist and track one batch's first sample rollouts as readable text.
|
|
2066
|
+
|
|
2067
|
+
The first `train.log_sample_rollouts` span-bearing trials render with
|
|
2068
|
+
the chat template's special tokens kept (`wmo.distill.samples`) and
|
|
2069
|
+
land in `samples/<name>.md` plus the tracker's samples table under
|
|
2070
|
+
`kind` ("train" for training batches, "warmup" for the warmup
|
|
2071
|
+
collection, "eval-<key>" for eval batches; file stems are
|
|
2072
|
+
"step-NNNN", "warmup", and "eval-<key>" respectively). 0 disables,
|
|
2073
|
+
and a batch with no span-bearing trial writes nothing. Skipped
|
|
2074
|
+
defensively when preflight has not built the renderer yet.
|
|
2075
|
+
"""
|
|
2076
|
+
limit = self._cfg.train.log_sample_rollouts
|
|
2077
|
+
if limit == 0 or self._rendering is None:
|
|
2078
|
+
return
|
|
2079
|
+
samples = sample_rollouts(records, self._rendering, limit)
|
|
2080
|
+
if not samples:
|
|
2081
|
+
return
|
|
2082
|
+
self._store.write_samples(name, samples_markdown(samples))
|
|
2083
|
+
self._tracker.log_samples(kind, step, samples)
|
|
2084
|
+
|
|
2085
|
+
# -- preflight -------------------------------------------------------------------------------
|
|
2086
|
+
|
|
2087
|
+
def _preflight(self) -> None:
|
|
2088
|
+
"""Every check that must pass before the run spends real money.
|
|
2089
|
+
|
|
2090
|
+
Order is cheapest first: renderer resolution and the tokenizer
|
|
2091
|
+
fingerprint cost nothing; the pings and the TITO recompute cost a few
|
|
2092
|
+
tokens. The live single-trial pi preflight is the injected
|
|
2093
|
+
`live_trial_preflight` hook (see `LiveTrialPreflight`).
|
|
2094
|
+
"""
|
|
2095
|
+
cfg = self._cfg
|
|
2096
|
+
self._emit("preflight", "running preflight checks before any spend")
|
|
2097
|
+
tokenizer = self._training.get_tokenizer()
|
|
2098
|
+
# The renderer must exist for the base model before any trial runs; the
|
|
2099
|
+
# tokenizer satisfies the renderer's slice at runtime (rendering.py makes
|
|
2100
|
+
# the same cast for the cookbook's loosely typed tokenizer parameter).
|
|
2101
|
+
rendering: ChatRendering = build_renderer(
|
|
2102
|
+
cfg.student.base_model, cast("RendererTokenizer", tokenizer)
|
|
2103
|
+
)
|
|
2104
|
+
# Kept for sample-rollout logging: every batch renders its first few
|
|
2105
|
+
# episodes to readable text via decode_with_specials.
|
|
2106
|
+
self._rendering = rendering
|
|
2107
|
+
if isinstance(self._teacher_client, TokenizerSource):
|
|
2108
|
+
tokenizer_fingerprint_check(
|
|
2109
|
+
cfg.student.base_model,
|
|
2110
|
+
self._teacher_identity,
|
|
2111
|
+
tokenizer,
|
|
2112
|
+
self._teacher_client.get_tokenizer(),
|
|
2113
|
+
)
|
|
2114
|
+
else:
|
|
2115
|
+
logger.info(
|
|
2116
|
+
"teacher sampling client exposes no tokenizer; skipping the "
|
|
2117
|
+
"fingerprint check (injected clients are assumed same-tokenizer)"
|
|
2118
|
+
)
|
|
2119
|
+
prompt_ids = rendering.build_generation_prompt([ChatMessage(role="user", content="ping")])
|
|
2120
|
+
try:
|
|
2121
|
+
sequence = self._sampler.client.sample(prompt_ids, max_tokens=1, temperature=0.0)
|
|
2122
|
+
except Exception as exc: # noqa: BLE001 - re-raised with actionable context
|
|
2123
|
+
raise RuntimeError(
|
|
2124
|
+
f"student preflight ping failed for {self._sampler.sampler_path!r} "
|
|
2125
|
+
f"(base model {cfg.student.base_model!r}): {exc}; check that the "
|
|
2126
|
+
f"base model is still in Tinker's lineup and that "
|
|
2127
|
+
f"{TINKER_API_KEY_ENV} is valid"
|
|
2128
|
+
) from exc
|
|
2129
|
+
if not sequence.tokens:
|
|
2130
|
+
raise RuntimeError(
|
|
2131
|
+
f"student preflight ping for {cfg.student.base_model!r} sampled no "
|
|
2132
|
+
"tokens; the sampler returned an empty sequence, so check the model "
|
|
2133
|
+
"name and the sampler weights path"
|
|
2134
|
+
)
|
|
2135
|
+
verify = self._teacher.verify()
|
|
2136
|
+
if not verify.ok:
|
|
2137
|
+
raise RuntimeError(
|
|
2138
|
+
f"teacher preflight ping failed for {verify.model!r}: {verify.detail}; "
|
|
2139
|
+
"check that the teacher model/checkpoint is still available on Tinker "
|
|
2140
|
+
"and shares the student's tokenizer"
|
|
2141
|
+
)
|
|
2142
|
+
tito_recompute_check(self._sampler.client, prompt_ids)
|
|
2143
|
+
if self._live_trial_preflight is not None:
|
|
2144
|
+
self._live_trial_preflight(self._student_provider())
|
|
2145
|
+
self._emit("preflight", "preflight checks passed")
|
|
2146
|
+
|
|
2147
|
+
# -- evals -----------------------------------------------------------------------------------
|
|
2148
|
+
|
|
2149
|
+
def _load_eval(self, key: str) -> DistillEvalReport | None:
|
|
2150
|
+
path = self._store.evals_dir / f"{key}.json"
|
|
2151
|
+
try:
|
|
2152
|
+
text = path.read_text(encoding="utf-8")
|
|
2153
|
+
except FileNotFoundError:
|
|
2154
|
+
return None
|
|
2155
|
+
try:
|
|
2156
|
+
return DistillEvalReport.model_validate_json(text)
|
|
2157
|
+
except ValidationError as exc:
|
|
2158
|
+
raise ValueError(
|
|
2159
|
+
f"corrupt eval report at {path}: {exc}; delete the file so the "
|
|
2160
|
+
"resumed run re-runs this eval"
|
|
2161
|
+
) from exc
|
|
2162
|
+
|
|
2163
|
+
def _run_eval(
|
|
2164
|
+
self,
|
|
2165
|
+
key: str,
|
|
2166
|
+
task_ids: Sequence[str],
|
|
2167
|
+
attempts: int,
|
|
2168
|
+
provider: ProviderConfig,
|
|
2169
|
+
*,
|
|
2170
|
+
phase: DistillPhase,
|
|
2171
|
+
teacher_metered: bool,
|
|
2172
|
+
completed_step: int | None,
|
|
2173
|
+
) -> DistillEvalReport:
|
|
2174
|
+
"""Run one eval batch as harbor trials and persist its report.
|
|
2175
|
+
|
|
2176
|
+
Args:
|
|
2177
|
+
key: The eval's store key; also names its isolated rollout root.
|
|
2178
|
+
task_ids: Exact task ids to evaluate on.
|
|
2179
|
+
attempts: Attempts per task (the eval's k).
|
|
2180
|
+
provider: The worker provider config for the trials.
|
|
2181
|
+
phase: The progress phase to emit under.
|
|
2182
|
+
teacher_metered: Charge the batch to the teacher meters (sampled
|
|
2183
|
+
tokens at teacher_sample plus per-request prefill; see
|
|
2184
|
+
`_charge_rollout_billing`) instead of the student meters.
|
|
2185
|
+
completed_step: Forwarded to the budget abort for checkpointing.
|
|
2186
|
+
"""
|
|
2187
|
+
cfg = self._cfg
|
|
2188
|
+
self._emit(
|
|
2189
|
+
phase,
|
|
2190
|
+
f"eval {key}: {len(list(task_ids))} task(s) x {attempts} attempt(s) "
|
|
2191
|
+
f"of {provider.model}",
|
|
2192
|
+
step=completed_step,
|
|
2193
|
+
)
|
|
2194
|
+
eval_cfg = cfg.model_copy(
|
|
2195
|
+
update={"train": cfg.train.model_copy(update={"group_size": attempts})}
|
|
2196
|
+
)
|
|
2197
|
+
records, stats = collect_rollouts(
|
|
2198
|
+
0,
|
|
2199
|
+
task_ids,
|
|
2200
|
+
eval_cfg,
|
|
2201
|
+
self._harness,
|
|
2202
|
+
provider,
|
|
2203
|
+
self._run_dir / EVAL_ROLLOUTS_DIR / validate_name(key),
|
|
2204
|
+
)
|
|
2205
|
+
self._charge_rollout_billing(batch_billing(records), teacher=teacher_metered)
|
|
2206
|
+
self._log_sample_rollouts(
|
|
2207
|
+
kind=f"eval-{key}", name=f"eval-{key}", step=completed_step, records=records
|
|
2208
|
+
)
|
|
2209
|
+
if stats.trials and not stats.executed_trials:
|
|
2210
|
+
raise DistillNullEvalError(
|
|
2211
|
+
f"eval {key} is a NULL measurement, not a 0.0: not one of {stats.trials} trial(s) "
|
|
2212
|
+
"carries a verifier reward, so no solve rate exists to record. Read the cells' "
|
|
2213
|
+
f"`infra-failure:` notes under {self._run_dir / EVAL_ROLLOUTS_DIR / key} for the "
|
|
2214
|
+
"exact causes. Trials that never started are almost always the E2B "
|
|
2215
|
+
"concurrent-sandbox account cap (a distill trial holds one sandbox: harbor's "
|
|
2216
|
+
"task environment), so lower train.trial_concurrency (currently "
|
|
2217
|
+
f"{cfg.train.trial_concurrency}), reap orphaned sandboxes, and re-run; trials that "
|
|
2218
|
+
"ran but were never graded (VerifierTimeoutError) usually need a longer "
|
|
2219
|
+
"verifier.override_timeout_sec in the harbor job template. Writing 0.0% here "
|
|
2220
|
+
"would put a null baseline behind gate.require_no_regression"
|
|
2221
|
+
)
|
|
2222
|
+
report = DistillEvalReport(
|
|
2223
|
+
name=key,
|
|
2224
|
+
provider_model=provider.model,
|
|
2225
|
+
base_model=provider.model_type,
|
|
2226
|
+
task_ids=list(task_ids),
|
|
2227
|
+
attempts=attempts,
|
|
2228
|
+
trials=stats.trials,
|
|
2229
|
+
solve_rate=stats.solve_rate,
|
|
2230
|
+
graded_solve_rate=stats.graded_solve_rate,
|
|
2231
|
+
graded_trials=stats.graded_trials,
|
|
2232
|
+
empty_span_trials=stats.empty_span_trials,
|
|
2233
|
+
executed_trials=stats.executed_trials,
|
|
2234
|
+
infra_failed_trials=stats.infra_failed_trials,
|
|
2235
|
+
scaffold_loss_rate=stats.scaffold_loss_rate,
|
|
2236
|
+
stop_reason_counts=dict(stats.stop_reason_counts),
|
|
2237
|
+
)
|
|
2238
|
+
if stats.infra_failed_trials or stats.scaffold_loss_rate:
|
|
2239
|
+
self._emit(
|
|
2240
|
+
phase,
|
|
2241
|
+
f"eval {key}: solve rate {stats.solve_rate:.3f} over "
|
|
2242
|
+
f"{stats.executed_trials}/{stats.trials} executed trial(s) "
|
|
2243
|
+
f"({stats.infra_failed_trials} infra failure(s) excluded), "
|
|
2244
|
+
f"{_graded_phrase(stats.graded_solve_rate, stats.graded_trials)}, scaffold loss "
|
|
2245
|
+
f"{stats.scaffold_loss_rate:.0%} {stats.stop_reason_counts}",
|
|
2246
|
+
step=completed_step,
|
|
2247
|
+
)
|
|
2248
|
+
self._store.write_eval(key, report)
|
|
2249
|
+
self._tracker.log_eval(
|
|
2250
|
+
key,
|
|
2251
|
+
report.solve_rate,
|
|
2252
|
+
completed_step,
|
|
2253
|
+
graded_solve_rate=_measured_graded_rate(report),
|
|
2254
|
+
)
|
|
2255
|
+
try:
|
|
2256
|
+
self._budget.check()
|
|
2257
|
+
except BudgetExhausted as exc:
|
|
2258
|
+
self._abort_for_budget(exc, completed_step=completed_step)
|
|
2259
|
+
return report
|
|
2260
|
+
|
|
2261
|
+
def _eval_or_load(
|
|
2262
|
+
self,
|
|
2263
|
+
key: str,
|
|
2264
|
+
task_ids: Sequence[str],
|
|
2265
|
+
attempts: int,
|
|
2266
|
+
provider: ProviderConfig,
|
|
2267
|
+
*,
|
|
2268
|
+
phase: DistillPhase,
|
|
2269
|
+
teacher_metered: bool,
|
|
2270
|
+
reuse: bool,
|
|
2271
|
+
pin_provider: bool = False,
|
|
2272
|
+
completed_step: int | None = None,
|
|
2273
|
+
baseline_from: str | None = None,
|
|
2274
|
+
baseline_from_field: str = "",
|
|
2275
|
+
) -> DistillEvalReport:
|
|
2276
|
+
"""One baseline eval: this run's recorded copy, a prior run's report, or trials.
|
|
2277
|
+
|
|
2278
|
+
Precedence: a report already recorded under this run's `evals/` wins
|
|
2279
|
+
when `reuse` is set (a resume must not re-import or re-run anything,
|
|
2280
|
+
and an imported baseline was copied there on the first session), then
|
|
2281
|
+
a configured `baseline_from` path imports a prior run's report, and
|
|
2282
|
+
only otherwise do trials actually run.
|
|
2283
|
+
|
|
2284
|
+
A reused report must have been measured at the same `attempts` (the
|
|
2285
|
+
gate compares solve rates, so mixing estimators of different k would
|
|
2286
|
+
silently invalidate the verdict), and with `pin_provider` also under
|
|
2287
|
+
the same provider model (the teacher's identity is stable across
|
|
2288
|
+
sessions; student sampler paths carry a per-session nonce and are
|
|
2289
|
+
intentionally NOT pinned).
|
|
2290
|
+
|
|
2291
|
+
Raises:
|
|
2292
|
+
RuntimeError: If a recorded report conflicts with the current
|
|
2293
|
+
config; the message says which knob changed and how to
|
|
2294
|
+
recover.
|
|
2295
|
+
"""
|
|
2296
|
+
if reuse:
|
|
2297
|
+
existing = self._load_eval(key)
|
|
2298
|
+
if existing is not None:
|
|
2299
|
+
if existing.attempts != attempts:
|
|
2300
|
+
raise RuntimeError(
|
|
2301
|
+
f"recorded eval {key!r} was measured at k={existing.attempts} but the "
|
|
2302
|
+
f"config now asks for k={attempts}; the gate would compare solve "
|
|
2303
|
+
"rates from different estimators. Restore the original gate/eval k "
|
|
2304
|
+
"in the config and resume, or start a fresh run dir"
|
|
2305
|
+
)
|
|
2306
|
+
if pin_provider and existing.provider_model != provider.model:
|
|
2307
|
+
raise RuntimeError(
|
|
2308
|
+
f"recorded eval {key!r} was measured against {existing.provider_model!r} "
|
|
2309
|
+
f"but the config now names {provider.model!r}; a mid-run model swap "
|
|
2310
|
+
"would gate against a stale baseline. Restore the original model in "
|
|
2311
|
+
"the config and resume, or start a fresh run dir"
|
|
2312
|
+
)
|
|
2313
|
+
logger.info("reusing recorded eval %s (solve rate %.3f)", key, existing.solve_rate)
|
|
2314
|
+
return existing
|
|
2315
|
+
if baseline_from is not None:
|
|
2316
|
+
return self._import_baseline(
|
|
2317
|
+
key,
|
|
2318
|
+
baseline_from,
|
|
2319
|
+
baseline_from_field,
|
|
2320
|
+
task_ids,
|
|
2321
|
+
attempts,
|
|
2322
|
+
provider,
|
|
2323
|
+
phase=phase,
|
|
2324
|
+
# The teacher's provider model (its stable identity) must match
|
|
2325
|
+
# across runs; a student provider model is a per-run sampler
|
|
2326
|
+
# path, so student reuse matches on base_model alone.
|
|
2327
|
+
match_provider_model=teacher_metered,
|
|
2328
|
+
)
|
|
2329
|
+
return self._run_eval(
|
|
2330
|
+
key,
|
|
2331
|
+
task_ids,
|
|
2332
|
+
attempts,
|
|
2333
|
+
provider,
|
|
2334
|
+
phase=phase,
|
|
2335
|
+
teacher_metered=teacher_metered,
|
|
2336
|
+
completed_step=completed_step,
|
|
2337
|
+
)
|
|
2338
|
+
|
|
2339
|
+
def _await_deferred_baselines(self) -> None:
|
|
2340
|
+
"""Block until every configured deferred baseline file exists, or time out.
|
|
2341
|
+
|
|
2342
|
+
`eval.defer_baselines` exists so training can start immediately while a
|
|
2343
|
+
SEPARATE process measures the gate references, and the two are only
|
|
2344
|
+
joined here. Nothing sequences them, so "training finished first" is a
|
|
2345
|
+
real outcome rather than a theoretical one: a short training run, or a
|
|
2346
|
+
baseline arm that hit a retry, and the import would fail a run that had
|
|
2347
|
+
already paid for every step.
|
|
2348
|
+
|
|
2349
|
+
Waiting is the right response because the producer is expected to
|
|
2350
|
+
finish and the file is the only thing missing. The wait is bounded so a
|
|
2351
|
+
producer that DIED cannot hang the run forever, and the caller's import
|
|
2352
|
+
then raises its usual message naming the config field. The trained
|
|
2353
|
+
weights are already saved by this point either way, so the worst case
|
|
2354
|
+
is a resume rather than lost training.
|
|
2355
|
+
"""
|
|
2356
|
+
pending = {
|
|
2357
|
+
field: Path(value)
|
|
2358
|
+
for field, value in (
|
|
2359
|
+
("eval.teacher_baseline_from", self._cfg.eval.teacher_baseline_from),
|
|
2360
|
+
("eval.student_baseline_from", self._cfg.eval.student_baseline_from),
|
|
2361
|
+
)
|
|
2362
|
+
if value is not None
|
|
2363
|
+
}
|
|
2364
|
+
missing = {field: path for field, path in pending.items() if not path.is_file()}
|
|
2365
|
+
if not missing:
|
|
2366
|
+
return
|
|
2367
|
+
deadline = time.monotonic() + _DEFERRED_BASELINE_WAIT_S
|
|
2368
|
+
for field, path in missing.items():
|
|
2369
|
+
logger.warning(
|
|
2370
|
+
"deferred baseline %s (%s) is not written yet; waiting up to %.0f min for the "
|
|
2371
|
+
"concurrent baseline run. The final sampler and state are already saved, so a "
|
|
2372
|
+
"timeout costs a resume and not the training.",
|
|
2373
|
+
field,
|
|
2374
|
+
path,
|
|
2375
|
+
_DEFERRED_BASELINE_WAIT_S / 60,
|
|
2376
|
+
)
|
|
2377
|
+
while time.monotonic() < deadline:
|
|
2378
|
+
still_missing = [path for path in missing.values() if not path.is_file()]
|
|
2379
|
+
if not still_missing:
|
|
2380
|
+
logger.info("every deferred baseline landed; importing them now")
|
|
2381
|
+
return
|
|
2382
|
+
time.sleep(_DEFERRED_BASELINE_POLL_S)
|
|
2383
|
+
# Fall through: the caller's import raises the message that names the config field
|
|
2384
|
+
# and the fix, which is more useful than a generic timeout error here.
|
|
2385
|
+
logger.error(
|
|
2386
|
+
"deferred baseline(s) still missing after %.0f min: %s",
|
|
2387
|
+
_DEFERRED_BASELINE_WAIT_S / 60,
|
|
2388
|
+
", ".join(str(path) for path in missing.values() if not path.is_file()),
|
|
2389
|
+
)
|
|
2390
|
+
|
|
2391
|
+
def _import_baseline(
|
|
2392
|
+
self,
|
|
2393
|
+
key: str,
|
|
2394
|
+
source: str,
|
|
2395
|
+
config_field: str,
|
|
2396
|
+
task_ids: Sequence[str],
|
|
2397
|
+
attempts: int,
|
|
2398
|
+
provider: ProviderConfig,
|
|
2399
|
+
*,
|
|
2400
|
+
phase: DistillPhase,
|
|
2401
|
+
match_provider_model: bool,
|
|
2402
|
+
) -> DistillEvalReport:
|
|
2403
|
+
"""Reuse a prior run's recorded baseline eval instead of running trials.
|
|
2404
|
+
|
|
2405
|
+
The report is validated against this run before it is trusted: it must
|
|
2406
|
+
cover exactly this run's holdout task ids, carry at least `attempts`
|
|
2407
|
+
(this run's `gate.k`) attempts per task, and be measured on the same
|
|
2408
|
+
model (the provider model for the teacher, the recorded `base_model`
|
|
2409
|
+
for the student, whose provider model is a per-run sampler path). A
|
|
2410
|
+
valid report is copied into this run's `evals/` with a provenance
|
|
2411
|
+
`source` note and logged and tracked like a freshly run eval; nothing
|
|
2412
|
+
is charged to the budget.
|
|
2413
|
+
|
|
2414
|
+
Args:
|
|
2415
|
+
key: The eval's store key (the baseline's canonical name).
|
|
2416
|
+
source: The prior run's `evals/<key>.json` path from the config.
|
|
2417
|
+
config_field: The config field naming `source`, for error messages.
|
|
2418
|
+
task_ids: This run's holdout task ids.
|
|
2419
|
+
attempts: This run's `gate.k`.
|
|
2420
|
+
provider: The provider config the baseline WOULD have run with.
|
|
2421
|
+
phase: The progress phase to emit under.
|
|
2422
|
+
match_provider_model: Also require the report's `provider_model`
|
|
2423
|
+
to equal the provider's model (the teacher baseline; a
|
|
2424
|
+
teacher's identity is stable across runs).
|
|
2425
|
+
|
|
2426
|
+
Returns:
|
|
2427
|
+
The validated report, re-stamped with this run's provenance note.
|
|
2428
|
+
|
|
2429
|
+
Raises:
|
|
2430
|
+
ValueError: On a missing or unparsable file or any validation
|
|
2431
|
+
mismatch; each message names the config field and the fix.
|
|
2432
|
+
"""
|
|
2433
|
+
path = Path(source)
|
|
2434
|
+
hint = f"or unset {config_field} to run the baseline here"
|
|
2435
|
+
try:
|
|
2436
|
+
text = path.read_text(encoding="utf-8")
|
|
2437
|
+
except FileNotFoundError as exc:
|
|
2438
|
+
raise ValueError(
|
|
2439
|
+
f"{config_field} points at {path}, which does not exist; point it "
|
|
2440
|
+
f"at a prior run's evals/{key}.json {hint}"
|
|
2441
|
+
) from exc
|
|
2442
|
+
try:
|
|
2443
|
+
report = DistillEvalReport.model_validate_json(text)
|
|
2444
|
+
except ValidationError as exc:
|
|
2445
|
+
raise ValueError(
|
|
2446
|
+
f"{config_field} points at {path}, which is not a valid eval "
|
|
2447
|
+
f"report: {exc}; point it at a prior run's evals/{key}.json {hint}"
|
|
2448
|
+
) from exc
|
|
2449
|
+
if set(report.task_ids) != set(task_ids):
|
|
2450
|
+
missing = sorted(set(task_ids) - set(report.task_ids))
|
|
2451
|
+
extra = sorted(set(report.task_ids) - set(task_ids))
|
|
2452
|
+
raise ValueError(
|
|
2453
|
+
f"{config_field}: the report at {path} was measured on a different "
|
|
2454
|
+
f"task set than this run's holdout split (missing: "
|
|
2455
|
+
f"{', '.join(missing) or 'none'}; extra: {', '.join(extra) or 'none'}); "
|
|
2456
|
+
f"a baseline only transfers between runs gating on the identical "
|
|
2457
|
+
f"holdout split, so reuse one that matches {hint}"
|
|
2458
|
+
)
|
|
2459
|
+
if report.attempts < attempts:
|
|
2460
|
+
raise ValueError(
|
|
2461
|
+
f"{config_field}: the report at {path} recorded {report.attempts} "
|
|
2462
|
+
f"attempt(s) per task but this run's gate.k is {attempts}; reuse a "
|
|
2463
|
+
f"baseline measured with at least gate.k attempts {hint}"
|
|
2464
|
+
)
|
|
2465
|
+
if match_provider_model and report.provider_model != provider.model:
|
|
2466
|
+
raise ValueError(
|
|
2467
|
+
f"{config_field}: the report at {path} evaluated "
|
|
2468
|
+
f"{report.provider_model!r} but this run's teacher is "
|
|
2469
|
+
f"{provider.model!r}; reuse a baseline of the same teacher {hint}"
|
|
2470
|
+
)
|
|
2471
|
+
expected_base = provider.model_type or provider.model
|
|
2472
|
+
if report.base_model is None and not match_provider_model:
|
|
2473
|
+
raise ValueError(
|
|
2474
|
+
f"{config_field}: the report at {path} records no base_model (it "
|
|
2475
|
+
f"predates the field), so it cannot be validated against this run's "
|
|
2476
|
+
f"base model {expected_base!r}; re-run that baseline on current code "
|
|
2477
|
+
f"{hint}"
|
|
2478
|
+
)
|
|
2479
|
+
if report.base_model is not None and report.base_model != expected_base:
|
|
2480
|
+
raise ValueError(
|
|
2481
|
+
f"{config_field}: the report at {path} was measured on base model "
|
|
2482
|
+
f"{report.base_model!r} but this run uses {expected_base!r}; a "
|
|
2483
|
+
f"baseline only transfers between runs on the same base model, so "
|
|
2484
|
+
f"reuse one that matches {hint}"
|
|
2485
|
+
)
|
|
2486
|
+
stamped = report.model_copy(
|
|
2487
|
+
update={"name": key, "source": f"reused from {path} via {config_field}"}
|
|
2488
|
+
)
|
|
2489
|
+
self._store.write_eval(key, stamped)
|
|
2490
|
+
self._tracker.log_eval(
|
|
2491
|
+
key, stamped.solve_rate, None, graded_solve_rate=_measured_graded_rate(stamped)
|
|
2492
|
+
)
|
|
2493
|
+
self._emit(
|
|
2494
|
+
phase,
|
|
2495
|
+
f"eval {key}: reused {path} (solve rate {stamped.solve_rate:.3f}, "
|
|
2496
|
+
f"{len(stamped.task_ids)} task(s) x {stamped.attempts} attempt(s)); "
|
|
2497
|
+
"no trials run",
|
|
2498
|
+
)
|
|
2499
|
+
return stamped
|
|
2500
|
+
|
|
2501
|
+
# -- warmup ----------------------------------------------------------------------------------
|
|
2502
|
+
|
|
2503
|
+
def _append_warmup_row(self, warmup_step: int, row: WarmupMetrics) -> None:
|
|
2504
|
+
"""Persist and track one warmup row, advancing the per-row delta cursors."""
|
|
2505
|
+
self._store.append_metrics(warmup_step, row)
|
|
2506
|
+
self._tracker.log_warmup_step(warmup_step, row)
|
|
2507
|
+
self._prev_tokens = {meter: self._budget.tokens(meter) for meter in METER_NAMES}
|
|
2508
|
+
self._prev_usd = self._budget.session_usd
|
|
2509
|
+
|
|
2510
|
+
def _warmup_row(
|
|
2511
|
+
self,
|
|
2512
|
+
*,
|
|
2513
|
+
trials: int,
|
|
2514
|
+
kept_trials: int,
|
|
2515
|
+
solve_rate: float,
|
|
2516
|
+
datums: int,
|
|
2517
|
+
loss_tokens: int,
|
|
2518
|
+
context_tokens: int,
|
|
2519
|
+
learning_rate: float,
|
|
2520
|
+
) -> WarmupMetrics:
|
|
2521
|
+
"""One warmup metrics row carrying the meter deltas since the last row."""
|
|
2522
|
+
deltas = self._meter_deltas()
|
|
2523
|
+
return WarmupMetrics(
|
|
2524
|
+
tasks=len(self._train_ids),
|
|
2525
|
+
trials=trials,
|
|
2526
|
+
kept_trials=kept_trials,
|
|
2527
|
+
solve_rate=solve_rate,
|
|
2528
|
+
datums=datums,
|
|
2529
|
+
loss_tokens=loss_tokens,
|
|
2530
|
+
context_tokens=context_tokens,
|
|
2531
|
+
learning_rate=learning_rate,
|
|
2532
|
+
student_prefill_tokens=deltas["student_prefill"],
|
|
2533
|
+
student_cached_prefill_tokens=deltas["student_cached_prefill"],
|
|
2534
|
+
student_sample_tokens=deltas["student_sample"],
|
|
2535
|
+
student_train_tokens=deltas["student_train"],
|
|
2536
|
+
teacher_prefill_tokens=deltas["teacher_prefill"],
|
|
2537
|
+
teacher_cached_prefill_tokens=deltas["teacher_cached_prefill"],
|
|
2538
|
+
teacher_sample_tokens=deltas["teacher_sample"],
|
|
2539
|
+
usd=max(self._budget.session_usd - self._prev_usd, 0.0),
|
|
2540
|
+
)
|
|
2541
|
+
|
|
2542
|
+
def _load_warmup_trials(self, source_dir: Path) -> tuple[list[TrialRecord], RolloutStats]:
|
|
2543
|
+
"""Load another run's warmup collection instead of collecting rollouts.
|
|
2544
|
+
|
|
2545
|
+
The `warmup.trajectories_from` path: the source run's collection wrote
|
|
2546
|
+
its assembled (unfiltered) trial records to `warmup-trials.json`, so
|
|
2547
|
+
this run reuses them for its own CE passes. The source run paid for
|
|
2548
|
+
the teacher rollouts, so loading charges no meter; the `keep` filter
|
|
2549
|
+
still applies to the loaded records at the call site.
|
|
2550
|
+
|
|
2551
|
+
Args:
|
|
2552
|
+
source_dir: The source run's directory.
|
|
2553
|
+
|
|
2554
|
+
Returns:
|
|
2555
|
+
The manifest's trial records plus their recomputed batch stats.
|
|
2556
|
+
|
|
2557
|
+
Raises:
|
|
2558
|
+
RuntimeError: If the source run has no warmup trial manifest (its
|
|
2559
|
+
warmup collection never completed).
|
|
2560
|
+
ValueError: If the manifest's teacher does not match this run's
|
|
2561
|
+
teacher identity.
|
|
2562
|
+
"""
|
|
2563
|
+
manifest = DistillRunStore(source_dir).read_warmup_trials()
|
|
2564
|
+
if manifest is None:
|
|
2565
|
+
raise RuntimeError(
|
|
2566
|
+
f"warmup.trajectories_from points at {source_dir}, but no warmup trial "
|
|
2567
|
+
f"manifest exists at {DistillRunStore(source_dir).warmup_trials_path}; the "
|
|
2568
|
+
"manifest is written when a run's warmup collection finishes, so point "
|
|
2569
|
+
"trajectories_from at a run dir whose warmup collected teacher rollouts, "
|
|
2570
|
+
"or unset it to collect here"
|
|
2571
|
+
)
|
|
2572
|
+
if manifest.teacher_model != self._teacher_identity:
|
|
2573
|
+
raise ValueError(
|
|
2574
|
+
f"the warmup trials in {source_dir} were sampled by teacher "
|
|
2575
|
+
f"{manifest.teacher_model!r}, but this run's teacher is "
|
|
2576
|
+
f"{self._teacher_identity!r}; warmup must train on THIS teacher's "
|
|
2577
|
+
"trajectories, so point warmup.trajectories_from at a run with a "
|
|
2578
|
+
"matching teacher, or unset it to collect fresh"
|
|
2579
|
+
)
|
|
2580
|
+
# THIS run's output cap, not the source run's: the truncation count is read
|
|
2581
|
+
# beside this run's metrics and must answer "would these turns be cut off here".
|
|
2582
|
+
return list(manifest.records), rollout_stats(
|
|
2583
|
+
manifest.records, max_tokens=self._cfg.sampling.max_tokens
|
|
2584
|
+
)
|
|
2585
|
+
|
|
2586
|
+
def _collect_warmup_trials(self) -> tuple[list[TrialRecord], RolloutStats]:
|
|
2587
|
+
"""Collect the warmup phase's teacher rollouts and persist their manifest.
|
|
2588
|
+
|
|
2589
|
+
Runs the teacher through the pi harness on every train task
|
|
2590
|
+
(`warmup.rollouts_per_task` attempts each, isolated under
|
|
2591
|
+
`warmup-rollouts/`), writes the assembled records to
|
|
2592
|
+
`warmup-trials.json` (so other runs can load this collection via
|
|
2593
|
+
`warmup.trajectories_from`), and charges the teacher meters.
|
|
2594
|
+
"""
|
|
2595
|
+
cfg = self._cfg
|
|
2596
|
+
warmup = cfg.warmup
|
|
2597
|
+
self._emit(
|
|
2598
|
+
"warmup",
|
|
2599
|
+
f"collecting teacher trajectories: {len(self._train_ids)} train task(s) x "
|
|
2600
|
+
f"{warmup.rollouts_per_task} attempt(s) of {self._teacher_identity}",
|
|
2601
|
+
)
|
|
2602
|
+
# The collector reads attempts from train.group_size, the same override
|
|
2603
|
+
# trick _run_eval uses for its k.
|
|
2604
|
+
warmup_cfg = cfg.model_copy(
|
|
2605
|
+
update={"train": cfg.train.model_copy(update={"group_size": warmup.rollouts_per_task})}
|
|
2606
|
+
)
|
|
2607
|
+
records, roll_stats = collect_rollouts(
|
|
2608
|
+
0,
|
|
2609
|
+
self._train_ids,
|
|
2610
|
+
warmup_cfg,
|
|
2611
|
+
self._harness,
|
|
2612
|
+
self._teacher_provider(),
|
|
2613
|
+
self._run_dir / WARMUP_ROLLOUTS_DIR,
|
|
2614
|
+
)
|
|
2615
|
+
# The manifest lands before the keep filter (a loading run may filter
|
|
2616
|
+
# differently) and before the budget check (the collection is complete
|
|
2617
|
+
# evidence even when this run aborts right after paying for it).
|
|
2618
|
+
self._store.write_warmup_trials(
|
|
2619
|
+
WarmupTrialsManifest(teacher_model=self._teacher_identity, records=records)
|
|
2620
|
+
)
|
|
2621
|
+
# Teacher-in-harness billing, same as the gate's teacher baseline:
|
|
2622
|
+
# sampled tokens at teacher_sample, per-request prefill split between
|
|
2623
|
+
# teacher_prefill (unique) and teacher_cached_prefill (repeats).
|
|
2624
|
+
self._charge_rollout_billing(batch_billing(records), teacher=True)
|
|
2625
|
+
try:
|
|
2626
|
+
self._budget.check()
|
|
2627
|
+
except BudgetExhausted as exc:
|
|
2628
|
+
self._abort_for_budget(exc, completed_step=None)
|
|
2629
|
+
return records, roll_stats
|
|
2630
|
+
|
|
2631
|
+
def _warmup(self) -> None:
|
|
2632
|
+
"""The supervised warmup phase: teacher trials, keep-filter, SFT passes.
|
|
2633
|
+
|
|
2634
|
+
The teacher trials come from `_collect_warmup_trials` (fresh rollouts,
|
|
2635
|
+
charged to the teacher meters) or, when `warmup.trajectories_from` is
|
|
2636
|
+
set, from `_load_warmup_trials` (another run's recorded collection,
|
|
2637
|
+
charging nothing). Either way the kept trials merge into cross_entropy
|
|
2638
|
+
datums through the same prefix merge OPD uses (the teacher SAMPLED
|
|
2639
|
+
these tokens, so they are exact sampled ids and need no advantages),
|
|
2640
|
+
and the student trains `warmup.steps` passes. Each pass is one
|
|
2641
|
+
forward_backward over the FULL warmup datum list plus one optim_step:
|
|
2642
|
+
the set is small (train tasks x rollouts_per_task, filtered), so
|
|
2643
|
+
full-batch epochs keep the schedule deterministic and resumable
|
|
2644
|
+
without a datum-level cursor. Zero kept datums degrade the run to
|
|
2645
|
+
pure OPD (warning + one metrics row + the skip recorded), never an
|
|
2646
|
+
abort. Afterwards the state is saved and the sampler force-refreshed
|
|
2647
|
+
so OPD step 0 samples the warmed student, and `warmup.json` marks the
|
|
2648
|
+
phase done for resumes.
|
|
2649
|
+
"""
|
|
2650
|
+
cfg = self._cfg
|
|
2651
|
+
warmup = cfg.warmup
|
|
2652
|
+
learning_rate = (
|
|
2653
|
+
warmup.learning_rate if warmup.learning_rate is not None else cfg.train.learning_rate
|
|
2654
|
+
)
|
|
2655
|
+
if warmup.trajectories_from is not None:
|
|
2656
|
+
source_dir = Path(warmup.trajectories_from)
|
|
2657
|
+
self._emit(
|
|
2658
|
+
"warmup",
|
|
2659
|
+
f"loading teacher trajectories from {source_dir} "
|
|
2660
|
+
"(the collection was paid by the source run; nothing is charged)",
|
|
2661
|
+
)
|
|
2662
|
+
records, roll_stats = self._load_warmup_trials(source_dir)
|
|
2663
|
+
sourced = "loaded"
|
|
2664
|
+
else:
|
|
2665
|
+
records, roll_stats = self._collect_warmup_trials()
|
|
2666
|
+
sourced = "collected"
|
|
2667
|
+
|
|
2668
|
+
kept = [record for record in records if warmup.keep == "all" or record.passed]
|
|
2669
|
+
# Sampled from the KEPT trials: the SFT set is what warmup trains on,
|
|
2670
|
+
# so those are the episodes worth reading.
|
|
2671
|
+
self._log_sample_rollouts(kind="warmup", name="warmup", step=None, records=kept)
|
|
2672
|
+
datums, datum_stats = build_datums(kept, cfg)
|
|
2673
|
+
if not datums:
|
|
2674
|
+
reason = (
|
|
2675
|
+
f"warmup {sourced} {roll_stats.trials} teacher trial(s) but kept "
|
|
2676
|
+
f"{len(kept)} under keep={warmup.keep!r} and built 0 datums"
|
|
2677
|
+
)
|
|
2678
|
+
logger.warning(
|
|
2679
|
+
"%s (teacher solve rate %.2f, %d trial(s) without spans, %d overflow "
|
|
2680
|
+
"drop(s), %d overlong drop(s)); skipping warmup, the run degrades to "
|
|
2681
|
+
"pure on-policy distillation",
|
|
2682
|
+
reason,
|
|
2683
|
+
roll_stats.solve_rate,
|
|
2684
|
+
roll_stats.empty_span_trials,
|
|
2685
|
+
datum_stats.overflow_drops,
|
|
2686
|
+
datum_stats.overlong_drops,
|
|
2687
|
+
)
|
|
2688
|
+
self._append_warmup_row(
|
|
2689
|
+
0,
|
|
2690
|
+
self._warmup_row(
|
|
2691
|
+
trials=roll_stats.trials,
|
|
2692
|
+
kept_trials=len(kept),
|
|
2693
|
+
solve_rate=roll_stats.solve_rate,
|
|
2694
|
+
datums=0,
|
|
2695
|
+
loss_tokens=0,
|
|
2696
|
+
context_tokens=0,
|
|
2697
|
+
learning_rate=learning_rate,
|
|
2698
|
+
),
|
|
2699
|
+
)
|
|
2700
|
+
self._store.write_warmup(
|
|
2701
|
+
WarmupRecord(
|
|
2702
|
+
steps=0,
|
|
2703
|
+
trials=roll_stats.trials,
|
|
2704
|
+
kept_trials=len(kept),
|
|
2705
|
+
datums=0,
|
|
2706
|
+
skipped_reason=reason,
|
|
2707
|
+
)
|
|
2708
|
+
)
|
|
2709
|
+
self._emit("warmup", f"warmup skipped: {reason}")
|
|
2710
|
+
return
|
|
2711
|
+
|
|
2712
|
+
train_tokens = sum(len(datum.model_input_tokens) for datum in datums)
|
|
2713
|
+
self._emit(
|
|
2714
|
+
"warmup",
|
|
2715
|
+
f"training {warmup.steps} cross_entropy pass(es) over {len(datums)} "
|
|
2716
|
+
f"datum(s) from {len(kept)}/{roll_stats.trials} kept teacher trial(s) "
|
|
2717
|
+
f"(lr {learning_rate:g})",
|
|
2718
|
+
)
|
|
2719
|
+
for warmup_step in range(warmup.steps):
|
|
2720
|
+
self._training.forward_backward(datums, loss_fn=CROSS_ENTROPY_LOSS)
|
|
2721
|
+
self._training.optim_step(learning_rate)
|
|
2722
|
+
self._budget.charge("student_train", train_tokens)
|
|
2723
|
+
self._append_warmup_row(
|
|
2724
|
+
warmup_step,
|
|
2725
|
+
self._warmup_row(
|
|
2726
|
+
trials=roll_stats.trials,
|
|
2727
|
+
kept_trials=len(kept),
|
|
2728
|
+
solve_rate=roll_stats.solve_rate,
|
|
2729
|
+
datums=len(datums),
|
|
2730
|
+
loss_tokens=datum_stats.loss_tokens,
|
|
2731
|
+
context_tokens=datum_stats.context_tokens,
|
|
2732
|
+
learning_rate=learning_rate,
|
|
2733
|
+
),
|
|
2734
|
+
)
|
|
2735
|
+
try:
|
|
2736
|
+
self._budget.check()
|
|
2737
|
+
except BudgetExhausted as exc:
|
|
2738
|
+
# No warmup.json yet: an interrupted warmup re-runs whole on
|
|
2739
|
+
# resume (the teacher trials resume trial-level via harbor).
|
|
2740
|
+
self._abort_for_budget(exc, completed_step=None)
|
|
2741
|
+
state_path = self._training.save_state()
|
|
2742
|
+
# The tag forces the save: step 0 was refreshed before preflight, and
|
|
2743
|
+
# warmup changed the weights, so OPD step 0 must sample fresh ones.
|
|
2744
|
+
sampler_path = self._sampler.refresh(0, tag="warmup")
|
|
2745
|
+
self._store.write_warmup(
|
|
2746
|
+
WarmupRecord(
|
|
2747
|
+
steps=warmup.steps,
|
|
2748
|
+
trials=roll_stats.trials,
|
|
2749
|
+
kept_trials=len(kept),
|
|
2750
|
+
datums=len(datums),
|
|
2751
|
+
state_path=state_path,
|
|
2752
|
+
sampler_path=sampler_path,
|
|
2753
|
+
)
|
|
2754
|
+
)
|
|
2755
|
+
self._emit(
|
|
2756
|
+
"warmup",
|
|
2757
|
+
f"warmup complete: {warmup.steps} pass(es) over {len(datums)} datum(s); "
|
|
2758
|
+
f"OPD starts from {sampler_path}",
|
|
2759
|
+
)
|
|
2760
|
+
|
|
2761
|
+
# -- the step loop ---------------------------------------------------------------------------
|
|
2762
|
+
|
|
2763
|
+
def _train_step(self, step: int) -> None:
|
|
2764
|
+
cfg = self._cfg
|
|
2765
|
+
batch = self._tasks.next_batch(cfg.train.tasks_per_batch)
|
|
2766
|
+
self._emit(
|
|
2767
|
+
"rollouts",
|
|
2768
|
+
f"step {step + 1}/{cfg.train.steps}: {len(batch)} task(s) x "
|
|
2769
|
+
f"{cfg.train.group_size} attempt(s) from {self._sampler.sampler_path}",
|
|
2770
|
+
step=step,
|
|
2771
|
+
)
|
|
2772
|
+
sampler_path = self._sampler.sampler_path
|
|
2773
|
+
# Training rollouts may run under a tighter turn cap than evals do (see
|
|
2774
|
+
# `TrainConfig.rollout_max_turns`), via the same model_copy override `_run_eval` uses
|
|
2775
|
+
# for its attempt count. Evals keep `rollout.max_turns` so student-before and
|
|
2776
|
+
# student-after stay like-for-like -- capping the measurement too would make the paired
|
|
2777
|
+
# delta report the config change rather than the training.
|
|
2778
|
+
rollout_cfg = cfg
|
|
2779
|
+
if cfg.train.rollout_max_turns is not None:
|
|
2780
|
+
rollout_cfg = cfg.model_copy(
|
|
2781
|
+
update={
|
|
2782
|
+
"rollout": cfg.rollout.model_copy(
|
|
2783
|
+
update={"max_turns": cfg.train.rollout_max_turns}
|
|
2784
|
+
)
|
|
2785
|
+
}
|
|
2786
|
+
)
|
|
2787
|
+
records, roll_stats = collect_rollouts(
|
|
2788
|
+
step, batch, rollout_cfg, self._harness, self._student_provider(), self._run_dir
|
|
2789
|
+
)
|
|
2790
|
+
self._charge_rollout_billing(batch_billing(records), teacher=False)
|
|
2791
|
+
self._log_sample_rollouts(kind="train", name=f"step-{step:04d}", step=step, records=records)
|
|
2792
|
+
|
|
2793
|
+
# Degeneration signals come from the RAW spans (before any datum drop),
|
|
2794
|
+
# and the baseline is armed here so this step's row already carries its
|
|
2795
|
+
# own ratios; the breach check itself runs after the row is persisted.
|
|
2796
|
+
health = policy_health(records)
|
|
2797
|
+
tripwire_baseline = self._arm_tripwire(step, health)
|
|
2798
|
+
|
|
2799
|
+
datums, datum_stats = build_datums(records, cfg)
|
|
2800
|
+
teacher_usage_before = self._teacher.usage()
|
|
2801
|
+
teacher_cached_before = _teacher_cached_usage(self._teacher)
|
|
2802
|
+
advantage_mean: float | None = None
|
|
2803
|
+
advantage_std: float | None = None
|
|
2804
|
+
clipped_tokens = 0
|
|
2805
|
+
# Token counts of the batch that actually trains, per source datum.
|
|
2806
|
+
# `datum_stats` counts what the teacher was ASKED to score, which is
|
|
2807
|
+
# larger whenever a misaligned teacher row drops a datum; reporting
|
|
2808
|
+
# that as the step's volume would overstate the batch and break
|
|
2809
|
+
# `clipped_tokens / loss_tokens == clip_fraction` (both clip counters
|
|
2810
|
+
# are post-drop). See `StepMetrics` for the two-stage contract.
|
|
2811
|
+
trained_loss_tokens = 0
|
|
2812
|
+
trained_context_tokens = 0
|
|
2813
|
+
if cfg.train.loss == "topk_ce":
|
|
2814
|
+
# One prefill-only teacher request per datum yields the top-k
|
|
2815
|
+
# candidate rows AND the realized logprobs, so the reverse-KL
|
|
2816
|
+
# metric below means exactly what it means in the default mode.
|
|
2817
|
+
try:
|
|
2818
|
+
scores = self._teacher.score_topk(datums, cfg.train.topk)
|
|
2819
|
+
finally:
|
|
2820
|
+
# Charged even when scoring raises mid-batch: the pool joins
|
|
2821
|
+
# every submitted call before propagating, so the whole batch
|
|
2822
|
+
# was billed server-side whether or not a row came back.
|
|
2823
|
+
self._charge_teacher_scoring(teacher_usage_before, teacher_cached_before)
|
|
2824
|
+
reverse_kl = _batch_reverse_kl(datums, scores.realized)
|
|
2825
|
+
trained, topk_stats = build_topk_ce_datums(datums, scores.topk, cfg.train.topk)
|
|
2826
|
+
loss_fn = CROSS_ENTROPY_LOSS
|
|
2827
|
+
mismatch_drops = topk_stats.mismatch_drops
|
|
2828
|
+
trained_loss_tokens = topk_stats.loss_tokens
|
|
2829
|
+
trained_context_tokens = topk_stats.context_tokens
|
|
2830
|
+
else:
|
|
2831
|
+
try:
|
|
2832
|
+
rows, reverse_kl = _teacher_rows(self._teacher, datums)
|
|
2833
|
+
finally:
|
|
2834
|
+
# Same contract as the topk_ce branch above.
|
|
2835
|
+
self._charge_teacher_scoring(teacher_usage_before, teacher_cached_before)
|
|
2836
|
+
trained, adv_stats = attach_advantages(datums, rows, cfg)
|
|
2837
|
+
# importance_sampling and ppo ride identical datums (see
|
|
2838
|
+
# `to_tinker_datums`); only the service-side loss differs.
|
|
2839
|
+
loss_fn = ADVANTAGE_LOSS_BY_MODE[cfg.train.loss]
|
|
2840
|
+
mismatch_drops = adv_stats.mismatch_drops
|
|
2841
|
+
advantage_mean = adv_stats.advantage_mean
|
|
2842
|
+
advantage_std = adv_stats.advantage_std
|
|
2843
|
+
clipped_tokens = adv_stats.clipped_tokens
|
|
2844
|
+
trained_loss_tokens = adv_stats.loss_tokens
|
|
2845
|
+
trained_context_tokens = adv_stats.context_tokens
|
|
2846
|
+
|
|
2847
|
+
# In topk_ce mode this is k x the source CE volume (the k rank
|
|
2848
|
+
# replicas each carry the full sequence), which is exactly what
|
|
2849
|
+
# forward_backward consumes, so the student_train meter stays honest.
|
|
2850
|
+
train_tokens = sum(len(datum.model_input_tokens) for datum in trained)
|
|
2851
|
+
pg_loss: float | None = None
|
|
2852
|
+
grad_norm: float | None = None
|
|
2853
|
+
optimizer_updates = 0
|
|
2854
|
+
if trained:
|
|
2855
|
+
logger.info(
|
|
2856
|
+
"step %d: %d datum(s) into forward_backward under loss_fn %r (train.loss = %r)",
|
|
2857
|
+
step,
|
|
2858
|
+
len(trained),
|
|
2859
|
+
loss_fn,
|
|
2860
|
+
cfg.train.loss,
|
|
2861
|
+
)
|
|
2862
|
+
# Minibatch into `train.num_substeps` optimizer updates. At 1 (the historical
|
|
2863
|
+
# behaviour) this is one forward_backward over everything and a single update -- a
|
|
2864
|
+
# whole batch of agent rollouts, measured at ~$66, buying ONE gradient step. The
|
|
2865
|
+
# rollouts are already paid for by the time we get here, so extra updates over the
|
|
2866
|
+
# same datums cost nothing and are the cheapest learning signal available.
|
|
2867
|
+
#
|
|
2868
|
+
# Shuffled, not chunked in order: datums arrive grouped by episode (one per turn),
|
|
2869
|
+
# so contiguous minibatches would be maximally correlated. Seeded on the step so a
|
|
2870
|
+
# rerun reproduces the same minibatches.
|
|
2871
|
+
#
|
|
2872
|
+
# Updates 2..N are off-policy w.r.t. the weights that sampled the batch, which is
|
|
2873
|
+
# the standard PPO setting and what `loss = "ppo"`'s ratio clip exists for. At
|
|
2874
|
+
# num_substeps = 1 that clip is inert by construction: sampling and training share
|
|
2875
|
+
# the same weights, so the ratio is identically 1.
|
|
2876
|
+
# Shuffle GROUPS, not datums. Under topk_ce one source token becomes k rank
|
|
2877
|
+
# replicas that share a model input and whose weights renormalize to 1 ACROSS the
|
|
2878
|
+
# group, so splitting a group over two updates would apply one token's target
|
|
2879
|
+
# distribution at two different weight states. Grouping by shared model input keeps
|
|
2880
|
+
# replicas together whatever k is, and is a no-op for one-datum-per-source losses.
|
|
2881
|
+
groups: list[list[TrainDatum]] = []
|
|
2882
|
+
for datum in trained:
|
|
2883
|
+
if groups and tuple(groups[-1][0].model_input_tokens) == tuple(
|
|
2884
|
+
datum.model_input_tokens
|
|
2885
|
+
):
|
|
2886
|
+
groups[-1].append(datum)
|
|
2887
|
+
else:
|
|
2888
|
+
groups.append([datum])
|
|
2889
|
+
if cfg.train.num_substeps > 1:
|
|
2890
|
+
# Only reorder when it buys something: at 1 substep the shuffle cannot change
|
|
2891
|
+
# the gradient and would only churn the batch order runs have always had.
|
|
2892
|
+
random.Random(_seed_from_name(f"{self._name}-substep-{step}")).shuffle(groups)
|
|
2893
|
+
substeps = min(cfg.train.num_substeps, len(groups))
|
|
2894
|
+
per = math.ceil(len(groups) / substeps)
|
|
2895
|
+
chunks = [
|
|
2896
|
+
[datum for group in groups[i : i + per] for datum in group]
|
|
2897
|
+
for i in range(0, len(groups), per)
|
|
2898
|
+
]
|
|
2899
|
+
losses: list[float] = []
|
|
2900
|
+
for index, chunk in enumerate(chunks):
|
|
2901
|
+
train_output = self._training.forward_backward(chunk, loss_fn=loss_fn)
|
|
2902
|
+
optim_output = self._training.optim_step(cfg.train.learning_rate)
|
|
2903
|
+
if train_output.loss is not None:
|
|
2904
|
+
losses.append(train_output.loss)
|
|
2905
|
+
grad_norm = optim_output.grad_norm
|
|
2906
|
+
if len(chunks) > 1:
|
|
2907
|
+
logger.info(
|
|
2908
|
+
"step %d substep %d/%d: %d datum(s), loss %s",
|
|
2909
|
+
step,
|
|
2910
|
+
index + 1,
|
|
2911
|
+
len(chunks),
|
|
2912
|
+
len(chunk),
|
|
2913
|
+
"n/a" if train_output.loss is None else f"{train_output.loss:.4f}",
|
|
2914
|
+
)
|
|
2915
|
+
# Mean over substeps: the reported loss stays comparable to the single-update runs.
|
|
2916
|
+
pg_loss = sum(losses) / len(losses) if losses else None
|
|
2917
|
+
optimizer_updates = len(chunks)
|
|
2918
|
+
self._budget.charge("student_train", train_tokens)
|
|
2919
|
+
else:
|
|
2920
|
+
logger.warning(
|
|
2921
|
+
"step %d produced no trainable datums (%d trial(s), %d without spans, "
|
|
2922
|
+
"%d overflow drop(s), %d overlong drop(s), %d teacher mismatch "
|
|
2923
|
+
"drop(s)); skipping the optimizer step",
|
|
2924
|
+
step,
|
|
2925
|
+
roll_stats.trials,
|
|
2926
|
+
roll_stats.empty_span_trials,
|
|
2927
|
+
datum_stats.overflow_drops,
|
|
2928
|
+
datum_stats.overlong_drops,
|
|
2929
|
+
mismatch_drops,
|
|
2930
|
+
)
|
|
2931
|
+
|
|
2932
|
+
deltas = self._meter_deltas()
|
|
2933
|
+
metrics = StepMetrics(
|
|
2934
|
+
tasks=len(batch),
|
|
2935
|
+
trials=roll_stats.trials,
|
|
2936
|
+
solve_rate=roll_stats.solve_rate,
|
|
2937
|
+
graded_solve_rate=roll_stats.graded_solve_rate,
|
|
2938
|
+
graded_trials=roll_stats.graded_trials,
|
|
2939
|
+
raw_solve_rate=roll_stats.raw_solve_rate,
|
|
2940
|
+
executed_trials=roll_stats.executed_trials,
|
|
2941
|
+
infra_failed_trials=roll_stats.infra_failed_trials,
|
|
2942
|
+
scaffold_loss_rate=roll_stats.scaffold_loss_rate,
|
|
2943
|
+
stop_reason_counts=dict(roll_stats.stop_reason_counts),
|
|
2944
|
+
empty_span_trials=roll_stats.empty_span_trials,
|
|
2945
|
+
truncated_spans=roll_stats.truncated_spans,
|
|
2946
|
+
datums=len(trained),
|
|
2947
|
+
fragments=datum_stats.fragments,
|
|
2948
|
+
fragmentation_rate=datum_stats.fragmentation_rate,
|
|
2949
|
+
overflow_drops=datum_stats.overflow_drops,
|
|
2950
|
+
overlong_drops=datum_stats.overlong_drops,
|
|
2951
|
+
mismatch_drops=mismatch_drops,
|
|
2952
|
+
clipped_tokens=clipped_tokens,
|
|
2953
|
+
optimizer_updates=optimizer_updates,
|
|
2954
|
+
loss_tokens=trained_loss_tokens,
|
|
2955
|
+
context_tokens=trained_context_tokens,
|
|
2956
|
+
reverse_kl_per_token=reverse_kl,
|
|
2957
|
+
entropy_per_token=health.entropy_per_token,
|
|
2958
|
+
mean_generation_tokens=health.mean_generation_tokens,
|
|
2959
|
+
entropy_baseline=(
|
|
2960
|
+
tripwire_baseline.entropy_per_token if tripwire_baseline is not None else None
|
|
2961
|
+
),
|
|
2962
|
+
entropy_ratio=metric_ratio(
|
|
2963
|
+
health.entropy_per_token,
|
|
2964
|
+
tripwire_baseline.entropy_per_token if tripwire_baseline is not None else None,
|
|
2965
|
+
),
|
|
2966
|
+
generation_tokens_baseline=(
|
|
2967
|
+
tripwire_baseline.mean_generation_tokens if tripwire_baseline is not None else None
|
|
2968
|
+
),
|
|
2969
|
+
generation_tokens_ratio=metric_ratio(
|
|
2970
|
+
health.mean_generation_tokens,
|
|
2971
|
+
tripwire_baseline.mean_generation_tokens if tripwire_baseline is not None else None,
|
|
2972
|
+
),
|
|
2973
|
+
reward_mean=(
|
|
2974
|
+
sum(record.reward for record in records) / len(records) if records else None
|
|
2975
|
+
),
|
|
2976
|
+
loss=cfg.train.loss,
|
|
2977
|
+
advantage_mean=advantage_mean,
|
|
2978
|
+
advantage_std=advantage_std,
|
|
2979
|
+
clip_fraction=(clipped_tokens / trained_loss_tokens if trained_loss_tokens else 0.0),
|
|
2980
|
+
pg_loss=pg_loss,
|
|
2981
|
+
grad_norm=grad_norm,
|
|
2982
|
+
sampler_path=sampler_path,
|
|
2983
|
+
student_prefill_tokens=deltas["student_prefill"],
|
|
2984
|
+
student_cached_prefill_tokens=deltas["student_cached_prefill"],
|
|
2985
|
+
student_sample_tokens=deltas["student_sample"],
|
|
2986
|
+
student_train_tokens=deltas["student_train"],
|
|
2987
|
+
teacher_prefill_tokens=deltas["teacher_prefill"],
|
|
2988
|
+
teacher_cached_prefill_tokens=deltas["teacher_cached_prefill"],
|
|
2989
|
+
teacher_sample_tokens=deltas["teacher_sample"],
|
|
2990
|
+
usd=max(self._budget.session_usd - self._prev_usd, 0.0),
|
|
2991
|
+
cumulative_usd=max(self._budget.total_usd, 0.0),
|
|
2992
|
+
)
|
|
2993
|
+
self._store.append_metrics(step, metrics)
|
|
2994
|
+
self._tracker.log_step(step, metrics)
|
|
2995
|
+
self._prev_tokens = {meter: self._budget.tokens(meter) for meter in METER_NAMES}
|
|
2996
|
+
self._prev_usd = self._budget.session_usd
|
|
2997
|
+
kl_text = "n/a" if reverse_kl is None else f"{reverse_kl:.4f}"
|
|
2998
|
+
self._emit(
|
|
2999
|
+
"training",
|
|
3000
|
+
f"step {step + 1}/{cfg.train.steps}: solve rate "
|
|
3001
|
+
f"{roll_stats.solve_rate:.2f} ({roll_stats.executed_trials}/{roll_stats.trials} "
|
|
3002
|
+
f"executed), "
|
|
3003
|
+
f"{_graded_phrase(roll_stats.graded_solve_rate, roll_stats.graded_trials)}, "
|
|
3004
|
+
f"scaffold loss {roll_stats.scaffold_loss_rate:.0%}, reverse KL/token "
|
|
3005
|
+
f"{kl_text}, {health_summary(health, tripwire_baseline)}, "
|
|
3006
|
+
f"{len(trained)} datum(s) under {cfg.train.loss}",
|
|
3007
|
+
step=step,
|
|
3008
|
+
)
|
|
3009
|
+
|
|
3010
|
+
try:
|
|
3011
|
+
self._budget.check()
|
|
3012
|
+
except BudgetExhausted as exc:
|
|
3013
|
+
self._abort_for_budget(exc, completed_step=step)
|
|
3014
|
+
|
|
3015
|
+
self._check_empty_batch_streak(
|
|
3016
|
+
step, roll_stats.trials, roll_stats.empty_span_trials, len(trained)
|
|
3017
|
+
)
|
|
3018
|
+
self._check_degeneration(step, health, tripwire_baseline)
|
|
3019
|
+
|
|
3020
|
+
if (step + 1) % cfg.train.sampler_refresh_every == 0:
|
|
3021
|
+
self._sampler.refresh(step + 1)
|
|
3022
|
+
if (step + 1) % cfg.train.save_state_every == 0:
|
|
3023
|
+
state_path = self._training.save_state()
|
|
3024
|
+
self._store.record_checkpoint(step, state_path, self._sampler.sampler_path)
|
|
3025
|
+
if cfg.eval.every > 0 and (step + 1) % cfg.eval.every == 0:
|
|
3026
|
+
subsample = self._interim_eval_tasks()
|
|
3027
|
+
self._run_eval(
|
|
3028
|
+
f"step-{step:04d}",
|
|
3029
|
+
subsample,
|
|
3030
|
+
cfg.eval.k,
|
|
3031
|
+
self._student_provider(),
|
|
3032
|
+
phase="eval",
|
|
3033
|
+
teacher_metered=False,
|
|
3034
|
+
completed_step=step,
|
|
3035
|
+
)
|
|
3036
|
+
|
|
3037
|
+
def _interim_eval_tasks(self) -> list[str]:
|
|
3038
|
+
"""A fixed, seeded train subsample so interim evals compare across steps."""
|
|
3039
|
+
count = min(self._cfg.eval.tasks, len(self._train_ids))
|
|
3040
|
+
rng = random.Random(_seed_from_name(f"{self._name}/interim-eval"))
|
|
3041
|
+
return rng.sample(self._train_ids, count)
|
|
3042
|
+
|
|
3043
|
+
# -- execution -------------------------------------------------------------------------------
|
|
3044
|
+
|
|
3045
|
+
def _open_training_client(self, restore_path: str | None) -> DistillTrainingClient:
|
|
3046
|
+
"""Create the student's training client, restoring state as its first call.
|
|
3047
|
+
|
|
3048
|
+
Tinker accepts LoadWeights only on an uninitialized model, so on
|
|
3049
|
+
resume `load_state` must run before ANYTHING else touches the client:
|
|
3050
|
+
a sampler-weights save, a forward pass, or a preflight sample would
|
|
3051
|
+
make the restore impossible and silently lose every trained step. For
|
|
3052
|
+
the same reason a deadline expiry cannot be retried on the same
|
|
3053
|
+
client, since the abandoned request may have initialized it
|
|
3054
|
+
server-side (a live resume died exactly that way); the retry opens a
|
|
3055
|
+
fresh, still-uninitialized client instead.
|
|
3056
|
+
|
|
3057
|
+
Args:
|
|
3058
|
+
restore_path: The tinker:// state path to restore, or None for a
|
|
3059
|
+
fresh run, which starts from the base model with no restore.
|
|
3060
|
+
|
|
3061
|
+
Returns:
|
|
3062
|
+
The training client, with `restore_path` already loaded when one
|
|
3063
|
+
was given.
|
|
3064
|
+
|
|
3065
|
+
Raises:
|
|
3066
|
+
TinkerDeadlineError: If both restore attempts blow their deadline.
|
|
3067
|
+
"""
|
|
3068
|
+
cfg = self._cfg
|
|
3069
|
+
client = self._service.create_lora_training_client(
|
|
3070
|
+
cfg.student.base_model, cfg.student.lora_rank
|
|
3071
|
+
)
|
|
3072
|
+
if restore_path is None:
|
|
3073
|
+
return client
|
|
3074
|
+
try:
|
|
3075
|
+
client.load_state(restore_path)
|
|
3076
|
+
except TinkerDeadlineError as exc:
|
|
3077
|
+
logger.warning(
|
|
3078
|
+
"load_state timed out; retrying on a FRESH training client, because the "
|
|
3079
|
+
"abandoned request may have initialized this one (tinker then refuses "
|
|
3080
|
+
"LoadWeights): %s",
|
|
3081
|
+
exc,
|
|
3082
|
+
)
|
|
3083
|
+
client = self._service.create_lora_training_client(
|
|
3084
|
+
cfg.student.base_model, cfg.student.lora_rank
|
|
3085
|
+
)
|
|
3086
|
+
client.load_state(restore_path)
|
|
3087
|
+
return client
|
|
3088
|
+
|
|
3089
|
+
def execute(self, *, resume: bool) -> DistillResult:
|
|
3090
|
+
"""Run (or resume) the whole distillation to a gate verdict."""
|
|
3091
|
+
cfg = self._cfg
|
|
3092
|
+
store = self._store
|
|
3093
|
+
self._resumed = resume
|
|
3094
|
+
if resume:
|
|
3095
|
+
if not store.config_path.exists():
|
|
3096
|
+
raise RuntimeError(
|
|
3097
|
+
f"nothing to resume in {self._run_dir}: no config.toml snapshot "
|
|
3098
|
+
"from a prior run; start a fresh run without resume"
|
|
3099
|
+
)
|
|
3100
|
+
if store.gate_path.exists():
|
|
3101
|
+
raise RuntimeError(
|
|
3102
|
+
f"the run in {self._run_dir} already completed: its gate verdict is "
|
|
3103
|
+
f"recorded at {store.gate_path} (see model_card.json for the final "
|
|
3104
|
+
"artifacts). Resuming a finished run would re-spend the holdout eval "
|
|
3105
|
+
"and could promote a duplicate adapter version; start a fresh "
|
|
3106
|
+
"--run-dir to distill again"
|
|
3107
|
+
)
|
|
3108
|
+
latest = store.latest_checkpoint()
|
|
3109
|
+
start_step = latest.step + 1 if latest is not None else 0
|
|
3110
|
+
warmup_record = store.read_warmup()
|
|
3111
|
+
# The spend ledger is written on every charge, so it carries spend
|
|
3112
|
+
# the metrics rows never see (baselines, interim and finalize
|
|
3113
|
+
# evals); summing metrics rows is only the pre-ledger fallback.
|
|
3114
|
+
recorded_spend = store.read_spend()
|
|
3115
|
+
prior_usd = recorded_spend if recorded_spend is not None else store.budget_spent()
|
|
3116
|
+
# Reused exactly as recorded, never re-measured: a fresh baseline
|
|
3117
|
+
# taken after a collapse would treat the collapse as normal and the
|
|
3118
|
+
# tripwire would never fire again. None only when the prior session
|
|
3119
|
+
# never completed a measurable step.
|
|
3120
|
+
self._tripwire_baseline = store.read_tripwire_baseline()
|
|
3121
|
+
if self._tripwire_baseline is not None:
|
|
3122
|
+
logger.info(
|
|
3123
|
+
"reusing the recorded degeneration baseline from step %d (entropy "
|
|
3124
|
+
"%.4f nats/token, %.0f sampled tokens/episode)",
|
|
3125
|
+
self._tripwire_baseline.step,
|
|
3126
|
+
self._tripwire_baseline.entropy_per_token,
|
|
3127
|
+
self._tripwire_baseline.mean_generation_tokens,
|
|
3128
|
+
)
|
|
3129
|
+
else:
|
|
3130
|
+
if store.config_path.exists() or store.last_step() is not None:
|
|
3131
|
+
raise ValueError(
|
|
3132
|
+
f"run dir {self._run_dir} already holds a distillation run; pass "
|
|
3133
|
+
"resume=True to continue it, or choose a fresh run dir"
|
|
3134
|
+
)
|
|
3135
|
+
latest = None
|
|
3136
|
+
start_step = 0
|
|
3137
|
+
warmup_record = None
|
|
3138
|
+
prior_usd = 0.0
|
|
3139
|
+
# The snapshot is refreshed on resume too: the caller's config wins (the
|
|
3140
|
+
# documented budget-abort recovery is editing budget.max_usd and resuming).
|
|
3141
|
+
store.snapshot_config(cfg)
|
|
3142
|
+
|
|
3143
|
+
restore_path: str | None = None
|
|
3144
|
+
if latest is not None:
|
|
3145
|
+
logger.info(
|
|
3146
|
+
"resuming %s from checkpoint step %d (%s)",
|
|
3147
|
+
self._name,
|
|
3148
|
+
latest.step,
|
|
3149
|
+
latest.state_path,
|
|
3150
|
+
)
|
|
3151
|
+
restore_path = latest.state_path
|
|
3152
|
+
elif warmup_record is not None and warmup_record.state_path is not None:
|
|
3153
|
+
# Warmup finished but no OPD step was checkpointed yet: without
|
|
3154
|
+
# this restore, a resumed session would start OPD from the COLD
|
|
3155
|
+
# student and silently lose the warmup it already paid for.
|
|
3156
|
+
logger.info(
|
|
3157
|
+
"resuming %s from the post-warmup state (%s)",
|
|
3158
|
+
self._name,
|
|
3159
|
+
warmup_record.state_path,
|
|
3160
|
+
)
|
|
3161
|
+
restore_path = warmup_record.state_path
|
|
3162
|
+
# The restore is part of opening the client because it must be its
|
|
3163
|
+
# FIRST call (see _open_training_client): the teacher client, the
|
|
3164
|
+
# student sampler refresh, and preflight all come after, so preflight
|
|
3165
|
+
# validates the RESTORED weights rather than the bare base model.
|
|
3166
|
+
self._training = self._open_training_client(restore_path)
|
|
3167
|
+
self._teacher_client = self._service.create_sampling_client(self._teacher_identity)
|
|
3168
|
+
self._teacher = TinkerTeacher(cfg.teacher, sampling_client=self._teacher_client)
|
|
3169
|
+
self._sampler = StudentSampler(self._service, self._training, self._name)
|
|
3170
|
+
self._sampler.refresh(start_step)
|
|
3171
|
+
self._budget = _RunBudget(
|
|
3172
|
+
cfg.pricing, cfg.budget.max_usd, prior_usd, on_spend=store.write_spend
|
|
3173
|
+
)
|
|
3174
|
+
self._tasks = TaskSampler(self._train_ids, seed=_seed_from_name(self._name))
|
|
3175
|
+
for _ in range(start_step):
|
|
3176
|
+
self._tasks.next_batch(cfg.train.tasks_per_batch)
|
|
3177
|
+
|
|
3178
|
+
self._preflight()
|
|
3179
|
+
try:
|
|
3180
|
+
self._budget.check()
|
|
3181
|
+
except BudgetExhausted as exc:
|
|
3182
|
+
self._abort_for_budget(exc, completed_step=None)
|
|
3183
|
+
|
|
3184
|
+
# Baselines feed the GATE only -- nothing in the training loop reads them -- so they are
|
|
3185
|
+
# not on the critical path for starting work. Deferred, they run at finalize instead,
|
|
3186
|
+
# which frees the training loop to start immediately while a SEPARATE process measures
|
|
3187
|
+
# them concurrently. That process must have its own HOME: harbor's task cache is a
|
|
3188
|
+
# hardcoded `~/.cache/harbor` (no env override) and `_copy_task_source_to_target` does an
|
|
3189
|
+
# UNCONDITIONAL rmtree+copytree of the task dir even when the cache is warm, so two
|
|
3190
|
+
# concurrent harbor jobs sharing a HOME delete each other's tasks mid-run.
|
|
3191
|
+
if cfg.eval.defer_baselines:
|
|
3192
|
+
self._deferred_baselines = True
|
|
3193
|
+
for step in range(start_step, cfg.train.steps):
|
|
3194
|
+
self._train_step(step)
|
|
3195
|
+
return self._finalize(None, None)
|
|
3196
|
+
|
|
3197
|
+
teacher_report = self._eval_or_load(
|
|
3198
|
+
TEACHER_BASELINE_EVAL,
|
|
3199
|
+
self._holdout_ids,
|
|
3200
|
+
cfg.gate.k,
|
|
3201
|
+
self._teacher_provider(),
|
|
3202
|
+
phase="baseline",
|
|
3203
|
+
teacher_metered=True,
|
|
3204
|
+
reuse=resume,
|
|
3205
|
+
pin_provider=True,
|
|
3206
|
+
baseline_from=cfg.eval.teacher_baseline_from,
|
|
3207
|
+
baseline_from_field="eval.teacher_baseline_from",
|
|
3208
|
+
)
|
|
3209
|
+
before_report = self._eval_or_load(
|
|
3210
|
+
STUDENT_BEFORE_EVAL,
|
|
3211
|
+
self._holdout_ids,
|
|
3212
|
+
cfg.gate.k,
|
|
3213
|
+
self._student_provider(),
|
|
3214
|
+
phase="baseline",
|
|
3215
|
+
teacher_metered=False,
|
|
3216
|
+
reuse=resume,
|
|
3217
|
+
baseline_from=cfg.eval.student_baseline_from,
|
|
3218
|
+
baseline_from_field="eval.student_baseline_from",
|
|
3219
|
+
)
|
|
3220
|
+
|
|
3221
|
+
if cfg.warmup.steps > 0:
|
|
3222
|
+
if warmup_record is not None:
|
|
3223
|
+
logger.info(
|
|
3224
|
+
"warmup already recorded in %s; skipping (%s)",
|
|
3225
|
+
store.warmup_path,
|
|
3226
|
+
warmup_record.skipped_reason
|
|
3227
|
+
or f"{warmup_record.steps} step(s) over {warmup_record.datums} datum(s)",
|
|
3228
|
+
)
|
|
3229
|
+
elif start_step > 0:
|
|
3230
|
+
logger.info(
|
|
3231
|
+
"resumed past step checkpoints (start step %d) with no warmup "
|
|
3232
|
+
"record; skipping warmup, the student already trained OPD steps",
|
|
3233
|
+
start_step,
|
|
3234
|
+
)
|
|
3235
|
+
else:
|
|
3236
|
+
self._warmup()
|
|
3237
|
+
|
|
3238
|
+
if start_step >= cfg.train.steps:
|
|
3239
|
+
logger.info(
|
|
3240
|
+
"all %d training step(s) already completed; skipping to finalize",
|
|
3241
|
+
cfg.train.steps,
|
|
3242
|
+
)
|
|
3243
|
+
for step in range(start_step, cfg.train.steps):
|
|
3244
|
+
self._train_step(step)
|
|
3245
|
+
|
|
3246
|
+
return self._finalize(teacher_report, before_report)
|
|
3247
|
+
|
|
3248
|
+
def _finalize(
|
|
3249
|
+
self,
|
|
3250
|
+
teacher_report: DistillEvalReport | None,
|
|
3251
|
+
before_report: DistillEvalReport | None,
|
|
3252
|
+
) -> DistillResult:
|
|
3253
|
+
cfg = self._cfg
|
|
3254
|
+
# Save the trained weights BEFORE anything that can fail on an external dependency.
|
|
3255
|
+
# Deferred baselines import a file a CONCURRENT process writes, and that import used to
|
|
3256
|
+
# run first: if the producer had not finished, the run raised having already paid for
|
|
3257
|
+
# every training step, and the final sampler and state were never written. Ordering the
|
|
3258
|
+
# save first means the worst case costs a resume, not the training.
|
|
3259
|
+
self._emit("finalize", "saving final training state and sampler weights")
|
|
3260
|
+
final_sampler = self._sampler.refresh(cfg.train.steps)
|
|
3261
|
+
final_state = self._training.save_state()
|
|
3262
|
+
final_step = max(cfg.train.steps - 1, 0)
|
|
3263
|
+
self._store.record_checkpoint(final_step, final_state, final_sampler)
|
|
3264
|
+
|
|
3265
|
+
if teacher_report is None or before_report is None:
|
|
3266
|
+
# Deferred: measure (or import) the gate references now that training is done. The
|
|
3267
|
+
# concurrent baseline process has usually written its reports long before this, so
|
|
3268
|
+
# `eval.teacher_baseline_from` / `student_baseline_from` normally import rather than
|
|
3269
|
+
# re-measure, and no second harbor job ever runs beside the training one.
|
|
3270
|
+
self._emit("baseline", "measuring deferred gate baselines after training")
|
|
3271
|
+
self._await_deferred_baselines()
|
|
3272
|
+
teacher_report = self._eval_or_load(
|
|
3273
|
+
TEACHER_BASELINE_EVAL,
|
|
3274
|
+
self._holdout_ids,
|
|
3275
|
+
cfg.gate.k,
|
|
3276
|
+
self._teacher_provider(),
|
|
3277
|
+
phase="baseline",
|
|
3278
|
+
teacher_metered=True,
|
|
3279
|
+
reuse=True,
|
|
3280
|
+
pin_provider=True,
|
|
3281
|
+
baseline_from=cfg.eval.teacher_baseline_from,
|
|
3282
|
+
baseline_from_field="eval.teacher_baseline_from",
|
|
3283
|
+
)
|
|
3284
|
+
before_report = self._eval_or_load(
|
|
3285
|
+
STUDENT_BEFORE_EVAL,
|
|
3286
|
+
self._holdout_ids,
|
|
3287
|
+
cfg.gate.k,
|
|
3288
|
+
self._student_provider(),
|
|
3289
|
+
phase="baseline",
|
|
3290
|
+
teacher_metered=False,
|
|
3291
|
+
reuse=True,
|
|
3292
|
+
baseline_from=cfg.eval.student_baseline_from,
|
|
3293
|
+
baseline_from_field="eval.student_baseline_from",
|
|
3294
|
+
)
|
|
3295
|
+
# Reuse a recorded student-after report on resume: a budget abort inside
|
|
3296
|
+
# a prior session's finalize already paid for it, and the resumed weights
|
|
3297
|
+
# restore the same final checkpoint it measured.
|
|
3298
|
+
after_report = self._eval_or_load(
|
|
3299
|
+
STUDENT_AFTER_EVAL,
|
|
3300
|
+
self._holdout_ids,
|
|
3301
|
+
cfg.gate.k,
|
|
3302
|
+
self._student_provider(),
|
|
3303
|
+
phase="eval",
|
|
3304
|
+
teacher_metered=False,
|
|
3305
|
+
reuse=self._resumed,
|
|
3306
|
+
completed_step=final_step,
|
|
3307
|
+
)
|
|
3308
|
+
record = gate_distillation(
|
|
3309
|
+
teacher_report.solve_rate,
|
|
3310
|
+
before_report.solve_rate,
|
|
3311
|
+
after_report.solve_rate,
|
|
3312
|
+
cfg.gate,
|
|
3313
|
+
)
|
|
3314
|
+
self._store.write_gate(record)
|
|
3315
|
+
card = DistillModelCard(
|
|
3316
|
+
base_model=cfg.student.base_model,
|
|
3317
|
+
lora_rank=cfg.student.lora_rank,
|
|
3318
|
+
teacher_model=self._teacher_identity,
|
|
3319
|
+
sampler_path=final_sampler,
|
|
3320
|
+
state_path=final_state,
|
|
3321
|
+
steps_completed=cfg.train.steps,
|
|
3322
|
+
gate=record,
|
|
3323
|
+
)
|
|
3324
|
+
self._store.write_model_card(card)
|
|
3325
|
+
version: int | None = None
|
|
3326
|
+
if record.accepted:
|
|
3327
|
+
version = self._adapters.save_version(self._name, card)
|
|
3328
|
+
self._store.write_handoff(
|
|
3329
|
+
build_handoff_toml(final_sampler, base_model=cfg.student.base_model)
|
|
3330
|
+
)
|
|
3331
|
+
logger.info("adapter %s v%d promoted: %s", self._name, version, record.reason)
|
|
3332
|
+
else:
|
|
3333
|
+
logger.warning("adapter %s not promoted: %s", self._name, record.reason)
|
|
3334
|
+
self._emit("gate", record.reason)
|
|
3335
|
+
self._tracker.log_summary(
|
|
3336
|
+
gate_accepted=record.accepted,
|
|
3337
|
+
gate_reason=record.reason,
|
|
3338
|
+
teacher_solve_rate=record.teacher_solve_rate,
|
|
3339
|
+
student_before_solve_rate=record.student_before_solve_rate,
|
|
3340
|
+
student_after_solve_rate=record.student_after_solve_rate,
|
|
3341
|
+
total_usd=self._budget.total_usd,
|
|
3342
|
+
steps_completed=cfg.train.steps,
|
|
3343
|
+
)
|
|
3344
|
+
spend = SpendSummary(
|
|
3345
|
+
lines=self._budget.lines(),
|
|
3346
|
+
session_usd=self._budget.session_usd,
|
|
3347
|
+
prior_usd=self._budget.prior_usd,
|
|
3348
|
+
total_usd=self._budget.total_usd,
|
|
3349
|
+
)
|
|
3350
|
+
return DistillResult(
|
|
3351
|
+
name=self._name,
|
|
3352
|
+
run_dir=str(self._run_dir),
|
|
3353
|
+
steps_completed=cfg.train.steps,
|
|
3354
|
+
final_sampler_path=final_sampler,
|
|
3355
|
+
final_state_path=final_state,
|
|
3356
|
+
gate=record,
|
|
3357
|
+
adapter_version=version,
|
|
3358
|
+
spend=spend,
|
|
3359
|
+
)
|
|
3360
|
+
|
|
3361
|
+
|
|
3362
|
+
def run_distillation(
|
|
3363
|
+
name: str,
|
|
3364
|
+
cfg: DistillConfig,
|
|
3365
|
+
harness: HarnessDoc,
|
|
3366
|
+
train_task_ids: Sequence[str],
|
|
3367
|
+
holdout_task_ids: Sequence[str],
|
|
3368
|
+
run_dir: Path,
|
|
3369
|
+
*,
|
|
3370
|
+
resume: bool = False,
|
|
3371
|
+
on_progress: ProgressCallback | None = None,
|
|
3372
|
+
service_client: DistillServiceClient | None = None,
|
|
3373
|
+
adapter_store: AdapterStore | None = None,
|
|
3374
|
+
live_trial_preflight: LiveTrialPreflight | None = None,
|
|
3375
|
+
tracker: DistillTracker | None = None,
|
|
3376
|
+
cli_agent: str | None = None,
|
|
3377
|
+
) -> DistillResult:
|
|
3378
|
+
"""Run one on-policy distillation end to end (preflight to gate verdict).
|
|
3379
|
+
|
|
3380
|
+
The flow: preflight (renderer resolution, tokenizer fingerprint, one-token
|
|
3381
|
+
student and teacher pings, the sample-then-recompute TITO proof, and the
|
|
3382
|
+
optional live-trial hook), holdout baselines (teacher-in-harness and
|
|
3383
|
+
student-before, each importable from a prior run's report via
|
|
3384
|
+
`eval.teacher_baseline_from` / `eval.student_baseline_from` instead of
|
|
3385
|
+
running trials), the optional supervised warmup when `warmup.steps > 0`
|
|
3386
|
+
(teacher rollouts on the train split, or another run's recorded collection
|
|
3387
|
+
when `warmup.trajectories_from` is set; the `warmup.keep`-filtered trials
|
|
3388
|
+
merge into cross_entropy datums for `warmup.steps` full-batch passes, then
|
|
3389
|
+
a state save and a forced sampler refresh so OPD starts from the warmed
|
|
3390
|
+
student; zero kept trials skip the phase with a warning instead of
|
|
3391
|
+
aborting), the
|
|
3392
|
+
training step loop (harbor rollouts, prefix-merge datums, teacher scoring,
|
|
3393
|
+
reverse-KL advantages, importance_sampling forward/backward and one
|
|
3394
|
+
optimizer step, metrics row, budget enforcement, then the sampler-refresh
|
|
3395
|
+
/ save-state / interim-eval cadences), and finally the student-after
|
|
3396
|
+
holdout eval feeding `gate_distillation`. An accepted gate saves the
|
|
3397
|
+
adapter version (champion alias) and writes the serving handoff into the
|
|
3398
|
+
run dir.
|
|
3399
|
+
|
|
3400
|
+
Args:
|
|
3401
|
+
name: The adapter/run name (a safe single path segment).
|
|
3402
|
+
cfg: The validated run config; snapshotted into the run dir.
|
|
3403
|
+
harness: The pinned document whose hash keys every trial's harbor job
|
|
3404
|
+
dir; the rollout agent itself is harbor's terminus-2.
|
|
3405
|
+
train_task_ids: The train split's task ids (unique, non-empty).
|
|
3406
|
+
holdout_task_ids: The holdout split's task ids (unique, non-empty,
|
|
3407
|
+
disjoint from the train split); baselines and the gate run here.
|
|
3408
|
+
run_dir: The run's artifact directory (`DistillRunStore` layout plus
|
|
3409
|
+
the rollout collector's per-step dirs and per-eval
|
|
3410
|
+
`eval-rollouts/<name>/` roots).
|
|
3411
|
+
resume: Continue an aborted run in `run_dir`: restores the latest
|
|
3412
|
+
checkpoint via `load_state` (or the recorded post-warmup state
|
|
3413
|
+
when no step checkpoint exists yet), continues the step count,
|
|
3414
|
+
restores prior USD spend from the run store's spend ledger,
|
|
3415
|
+
reuses recorded baseline evals, and never re-runs a warmup whose
|
|
3416
|
+
`warmup.json` record exists. The passed `cfg` wins over the old
|
|
3417
|
+
snapshot (the budget-abort recovery is editing `budget.max_usd`
|
|
3418
|
+
and resuming).
|
|
3419
|
+
on_progress: Optional callback receiving typed `DistillProgress`
|
|
3420
|
+
events as phases advance.
|
|
3421
|
+
service_client: Injectable Tinker service surface; tests pass fakes
|
|
3422
|
+
built on `wmo.distill.fake_tinker`. None builds the real SDK
|
|
3423
|
+
client lazily (requires the distill extra and TINKER_API_KEY).
|
|
3424
|
+
adapter_store: Where accepted adapters are versioned; defaults to the
|
|
3425
|
+
project-local `.wmo` store.
|
|
3426
|
+
live_trial_preflight: The Phase 6 live single-trial pi preflight hook
|
|
3427
|
+
(see `LiveTrialPreflight`); None skips it.
|
|
3428
|
+
tracker: Injectable run tracker; None builds one from the config's
|
|
3429
|
+
`[wandb]` section via `build_tracker` (the no-op `NullTracker`
|
|
3430
|
+
unless `wandb.enabled` is set). `finish()` is guaranteed on both
|
|
3431
|
+
the finalize and budget-abort paths.
|
|
3432
|
+
cli_agent: The agent string as the user typed it (it may carry an
|
|
3433
|
+
`@ref`, which `name` strips); resume commands print this string
|
|
3434
|
+
so the printed command passes the CLI's resume conflict check.
|
|
3435
|
+
None falls back to `name`.
|
|
3436
|
+
|
|
3437
|
+
Returns:
|
|
3438
|
+
The `DistillResult`: gate record, final sampler/state paths, run dir,
|
|
3439
|
+
adapter version (None when rejected), and the spend summary.
|
|
3440
|
+
|
|
3441
|
+
Raises:
|
|
3442
|
+
ValueError: On invalid name/splits, a fresh run pointed at a used
|
|
3443
|
+
run dir, a `eval.*_baseline_from` report that fails validation
|
|
3444
|
+
(wrong task set, too few attempts, or a different model), or
|
|
3445
|
+
wandb tracking enabled without credentials.
|
|
3446
|
+
RuntimeError: On preflight failures (each message says what to fix),
|
|
3447
|
+
or resume with nothing to resume.
|
|
3448
|
+
ImportError: If no service client is injected and the tinker SDK is
|
|
3449
|
+
not installed, or wandb tracking is enabled without the wandb SDK.
|
|
3450
|
+
DistillBudgetError: When `budget.max_usd` is exhausted; state is
|
|
3451
|
+
persisted and the error carries the exact resume command.
|
|
3452
|
+
DistillEmptyBatchError: When `MAX_CONSECUTIVE_EMPTY_STEPS` training
|
|
3453
|
+
steps in a row produce only span-less trials (the student
|
|
3454
|
+
provider is producing no completions); state is persisted and
|
|
3455
|
+
the error carries the exact resume command.
|
|
3456
|
+
"""
|
|
3457
|
+
validate_name(name)
|
|
3458
|
+
train_ids = list(train_task_ids)
|
|
3459
|
+
holdout_ids = list(holdout_task_ids)
|
|
3460
|
+
if not train_ids or len(set(train_ids)) != len(train_ids):
|
|
3461
|
+
raise ValueError(
|
|
3462
|
+
"train_task_ids must be non-empty and unique; pass the train split's exact task ids"
|
|
3463
|
+
)
|
|
3464
|
+
if not holdout_ids or len(set(holdout_ids)) != len(holdout_ids):
|
|
3465
|
+
raise ValueError(
|
|
3466
|
+
"holdout_task_ids must be non-empty and unique; the baselines and the "
|
|
3467
|
+
"promotion gate are measured on the holdout split"
|
|
3468
|
+
)
|
|
3469
|
+
overlap = sorted(set(train_ids) & set(holdout_ids))
|
|
3470
|
+
if overlap:
|
|
3471
|
+
raise ValueError(
|
|
3472
|
+
f"task id(s) {', '.join(overlap)} appear in BOTH splits; the gate is "
|
|
3473
|
+
"only meaningful on tasks the student never trained on, so make the "
|
|
3474
|
+
"splits disjoint"
|
|
3475
|
+
)
|
|
3476
|
+
service = service_client if service_client is not None else _build_sdk_service_client()
|
|
3477
|
+
# Built (and so credential-checked) before any spend: a misconfigured
|
|
3478
|
+
# tracker must fail fast, not after paid baselines.
|
|
3479
|
+
resolved_tracker = tracker if tracker is not None else build_tracker(cfg, run_dir, name)
|
|
3480
|
+
run = _DistillRun(
|
|
3481
|
+
name,
|
|
3482
|
+
cfg,
|
|
3483
|
+
harness,
|
|
3484
|
+
train_ids,
|
|
3485
|
+
holdout_ids,
|
|
3486
|
+
run_dir,
|
|
3487
|
+
service=service,
|
|
3488
|
+
adapter_store=adapter_store if adapter_store is not None else AdapterStore(),
|
|
3489
|
+
on_progress=on_progress,
|
|
3490
|
+
live_trial_preflight=live_trial_preflight,
|
|
3491
|
+
tracker=resolved_tracker,
|
|
3492
|
+
cli_agent=cli_agent,
|
|
3493
|
+
)
|
|
3494
|
+
try:
|
|
3495
|
+
return run.execute(resume=resume)
|
|
3496
|
+
finally:
|
|
3497
|
+
# Both terminal paths (finalize and the budget abort) close the
|
|
3498
|
+
# tracking run, so a resumed session starts a fresh wandb run.
|
|
3499
|
+
resolved_tracker.finish()
|