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/store.py
ADDED
|
@@ -0,0 +1,829 @@
|
|
|
1
|
+
"""Distillation artifacts on disk: the per-run directory and the adapter store.
|
|
2
|
+
|
|
3
|
+
`DistillRunStore` owns one run's artifact directory. Everything a run produces
|
|
4
|
+
or needs to resume lives under it:
|
|
5
|
+
|
|
6
|
+
<run_dir>/
|
|
7
|
+
config.toml # exact snapshot of the DistillConfig the run started with
|
|
8
|
+
metrics.jsonl # one JSON row per warmup/training step, appended as steps finish
|
|
9
|
+
spend.json # cumulative priced USD across every session, updated per charge
|
|
10
|
+
warmup.json # the WarmupRecord marker; its existence means warmup is done
|
|
11
|
+
warmup-trials.json # the warmup collection's TrialRecords (loadable by other runs)
|
|
12
|
+
evals/<name>.json # interim and final eval payloads, keyed by eval name
|
|
13
|
+
samples/<name>.md # per-batch sample episode rollouts (step-NNNN, warmup, eval-<name>)
|
|
14
|
+
checkpoints.json # manifest of saved tinker:// state + sampler paths, plus the
|
|
15
|
+
# degeneration tripwires' baseline (it must survive --resume)
|
|
16
|
+
gate.json # the DistillGateRecord verdict
|
|
17
|
+
model_card.json # the run's DistillModelCard
|
|
18
|
+
handoff.toml # the [models.agent] serving snippet for the user
|
|
19
|
+
harbor/step-NNNN/ # per-step harbor jobs dirs (written by the rollout collector;
|
|
20
|
+
# each trial dir's result.json carries that trial's token spans)
|
|
21
|
+
warmup-rollouts/ # the warmup phase's isolated rollout root (teacher trials)
|
|
22
|
+
|
|
23
|
+
`AdapterStore` mirrors `wmo/harness/store.py`'s `HarnessStore` idiom for the
|
|
24
|
+
trained adapters themselves: `.wmo/adapters/<name>/` accumulates immutable
|
|
25
|
+
`vN/model_card.json` versions, and movable aliases in `aliases.toml` mark
|
|
26
|
+
deployment state (promotion and rollback are re-pointing, never rewriting).
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
import tomllib
|
|
34
|
+
from datetime import UTC, datetime
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
import tomli_w
|
|
38
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError
|
|
39
|
+
|
|
40
|
+
from wmo.config.store import validate_name
|
|
41
|
+
from wmo.core.types import JsonObject
|
|
42
|
+
from wmo.distill.config import DistillConfig, load_distill_config, snapshot_toml
|
|
43
|
+
from wmo.distill.gate import DistillGateRecord
|
|
44
|
+
from wmo.distill.tokens import TrialRecord
|
|
45
|
+
from wmo.distill.tripwire import TripwireBaseline
|
|
46
|
+
|
|
47
|
+
logger = logging.getLogger(__name__)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def write_text_atomic(path: Path, text: str) -> None:
|
|
51
|
+
"""Write `text` to `path` via a same-directory tmp file plus atomic replace.
|
|
52
|
+
|
|
53
|
+
Every durable file in the run dir goes through this, including the ones
|
|
54
|
+
other modules own (`wmo.distill.tracking`'s wandb run record): a torn
|
|
55
|
+
write to checkpoints.json (or the config snapshot, warmup record, gate
|
|
56
|
+
verdict, wandb run id) would otherwise corrupt the resume path exactly
|
|
57
|
+
when a crash made resume necessary.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
path: The destination file; its parent directory must exist.
|
|
61
|
+
text: The complete file contents, written as UTF-8.
|
|
62
|
+
"""
|
|
63
|
+
tmp_path = path.with_name(path.name + ".tmp")
|
|
64
|
+
tmp_path.write_text(text, encoding="utf-8")
|
|
65
|
+
tmp_path.replace(path)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
ADAPTERS_DIR = "adapters"
|
|
69
|
+
CHAMPION_ALIAS = "champion"
|
|
70
|
+
|
|
71
|
+
# Tinker's OpenAI-compatible serving endpoint: the production service base URL
|
|
72
|
+
# plus the /oai/api/v1 OpenAI-compat prefix (a bare /v1 returns 404; verified
|
|
73
|
+
# live 2026-07-23 with a completion from a trained adapter). The endpoint is
|
|
74
|
+
# beta, so runs may override it in config; artifacts record the value used.
|
|
75
|
+
DEFAULT_TINKER_OPENAI_ENDPOINT = (
|
|
76
|
+
"https://tinker.thinkingmachines.dev/services/tinker-prod/oai/api/v1"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
_ALIASES_FILE = "aliases.toml"
|
|
80
|
+
_CARD_FILE = "model_card.json"
|
|
81
|
+
|
|
82
|
+
_CONFIG_FILE = "config.toml"
|
|
83
|
+
_METRICS_FILE = "metrics.jsonl"
|
|
84
|
+
_SPEND_FILE = "spend.json"
|
|
85
|
+
_WARMUP_FILE = "warmup.json"
|
|
86
|
+
_WARMUP_TRIALS_FILE = "warmup-trials.json"
|
|
87
|
+
_CHECKPOINTS_FILE = "checkpoints.json"
|
|
88
|
+
_GATE_FILE = "gate.json"
|
|
89
|
+
_MODEL_CARD_FILE = "model_card.json"
|
|
90
|
+
_HANDOFF_FILE = "handoff.toml"
|
|
91
|
+
_EVALS_DIR = "evals"
|
|
92
|
+
_SAMPLES_DIR = "samples"
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _utc_now_iso() -> str:
|
|
96
|
+
return datetime.now(UTC).isoformat()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class SpendLedger(BaseModel):
|
|
100
|
+
"""The spend.json shape: cumulative priced USD across every session of the run."""
|
|
101
|
+
|
|
102
|
+
model_config = ConfigDict(extra="forbid")
|
|
103
|
+
|
|
104
|
+
total_usd: float = Field(ge=0.0)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
class WarmupRecord(BaseModel):
|
|
108
|
+
"""The warmup.json shape: proof one session finished (or skipped) warmup.
|
|
109
|
+
|
|
110
|
+
The record exists exactly when the warmup phase reached its terminal
|
|
111
|
+
outcome, so a resumed run never re-runs it. It is NOT a checkpoint
|
|
112
|
+
manifest entry: checkpoint steps drive the resume step count, and warmup
|
|
113
|
+
precedes step 0. `state_path` lets a resume that has no step checkpoint
|
|
114
|
+
yet restore the warmed weights instead of starting cold.
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
model_config = ConfigDict(extra="forbid")
|
|
118
|
+
|
|
119
|
+
steps: int = Field(ge=0)
|
|
120
|
+
"""Warmup optimizer steps actually applied (0 when the phase was skipped)."""
|
|
121
|
+
|
|
122
|
+
trials: int = Field(ge=0)
|
|
123
|
+
"""Teacher trials collected on the train split."""
|
|
124
|
+
|
|
125
|
+
kept_trials: int = Field(ge=0)
|
|
126
|
+
"""Trials that survived the `warmup.keep` filter."""
|
|
127
|
+
|
|
128
|
+
datums: int = Field(ge=0)
|
|
129
|
+
"""SFT datums the kept trials merged into."""
|
|
130
|
+
|
|
131
|
+
state_path: str | None = None
|
|
132
|
+
"""The post-warmup tinker:// training-state path; None when skipped."""
|
|
133
|
+
|
|
134
|
+
sampler_path: str | None = None
|
|
135
|
+
"""The post-warmup tinker:// sampler-weights path; None when skipped."""
|
|
136
|
+
|
|
137
|
+
skipped_reason: str | None = None
|
|
138
|
+
"""Why the phase trained nothing (e.g. zero passing trials); None when it ran."""
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class WarmupTrialsManifest(BaseModel):
|
|
142
|
+
"""The warmup-trials.json shape: the warmup collection's assembled trials.
|
|
143
|
+
|
|
144
|
+
Written when the warmup phase's teacher rollouts finish assembling, BEFORE
|
|
145
|
+
the `warmup.keep` filter, so another run loading the collection through
|
|
146
|
+
`warmup.trajectories_from` may filter differently. The teacher identity is
|
|
147
|
+
recorded so a loading run can refuse trajectories another teacher sampled.
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
model_config = ConfigDict(extra="forbid")
|
|
151
|
+
|
|
152
|
+
teacher_model: str = Field(min_length=1)
|
|
153
|
+
"""The teacher identity (checkpoint or model ref) that sampled the trials."""
|
|
154
|
+
|
|
155
|
+
records: list[TrialRecord]
|
|
156
|
+
"""Every assembled trial record from the collection, unfiltered."""
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
class CheckpointRecord(BaseModel):
|
|
160
|
+
"""One saved training checkpoint: the exact tinker:// artifacts for a step."""
|
|
161
|
+
|
|
162
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
163
|
+
|
|
164
|
+
step: int = Field(ge=0)
|
|
165
|
+
"""0-based training step the checkpoint was saved after."""
|
|
166
|
+
|
|
167
|
+
state_path: str = Field(min_length=1)
|
|
168
|
+
"""The tinker:// training-state path (feeds load_state on resume)."""
|
|
169
|
+
|
|
170
|
+
sampler_path: str = Field(min_length=1)
|
|
171
|
+
"""The tinker:// sampler-weights path current at save time."""
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class CheckpointManifest(BaseModel):
|
|
175
|
+
"""The checkpoints.json shape: every saved checkpoint, plus resume-scoped state."""
|
|
176
|
+
|
|
177
|
+
model_config = ConfigDict(extra="forbid")
|
|
178
|
+
|
|
179
|
+
checkpoints: list[CheckpointRecord] = Field(default_factory=list)
|
|
180
|
+
|
|
181
|
+
tripwire_baseline: TripwireBaseline | None = None
|
|
182
|
+
"""The degeneration tripwires' reference, measured at the run's first
|
|
183
|
+
training step (None before that step, or on manifests predating the field).
|
|
184
|
+
|
|
185
|
+
It rides the run manifest rather than a metrics row because it must survive
|
|
186
|
+
`--resume`: a resumed session that re-measures its baseline would anchor on
|
|
187
|
+
an already-degenerated policy, making the collapse the new normal and the
|
|
188
|
+
tripwire blind. See `wmo.distill.tripwire.TripwireBaseline`."""
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class DistillModelCard(BaseModel):
|
|
192
|
+
"""The durable record of one distilled adapter: exact refs plus provenance.
|
|
193
|
+
|
|
194
|
+
Tinker's model lineup churns, so the card pins the exact base model,
|
|
195
|
+
teacher, and tinker:// artifact paths rather than relying on names staying
|
|
196
|
+
resolvable. `name` and `version` are stamped by `AdapterStore.save_version`.
|
|
197
|
+
"""
|
|
198
|
+
|
|
199
|
+
model_config = ConfigDict(extra="forbid")
|
|
200
|
+
|
|
201
|
+
name: str = ""
|
|
202
|
+
version: int = 0
|
|
203
|
+
base_model: str = Field(min_length=1)
|
|
204
|
+
lora_rank: int = Field(ge=1)
|
|
205
|
+
teacher_model: str = Field(min_length=1)
|
|
206
|
+
sampler_path: str = Field(min_length=1)
|
|
207
|
+
"""The tinker:// sampler-weights path serving this adapter."""
|
|
208
|
+
|
|
209
|
+
state_path: str | None = None
|
|
210
|
+
"""The tinker:// training-state path, for training this adapter further."""
|
|
211
|
+
|
|
212
|
+
steps_completed: int = Field(ge=0)
|
|
213
|
+
created_at: str = Field(default_factory=_utc_now_iso)
|
|
214
|
+
gate: DistillGateRecord | None = None
|
|
215
|
+
"""The promotion verdict that admitted this version, when one was run."""
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
class DistillRunStore:
|
|
219
|
+
"""One distillation run's artifact directory (layout in the module docstring)."""
|
|
220
|
+
|
|
221
|
+
def __init__(self, run_dir: str | Path) -> None:
|
|
222
|
+
self.run_dir = Path(run_dir)
|
|
223
|
+
|
|
224
|
+
@property
|
|
225
|
+
def config_path(self) -> Path:
|
|
226
|
+
return self.run_dir / _CONFIG_FILE
|
|
227
|
+
|
|
228
|
+
@property
|
|
229
|
+
def metrics_path(self) -> Path:
|
|
230
|
+
return self.run_dir / _METRICS_FILE
|
|
231
|
+
|
|
232
|
+
@property
|
|
233
|
+
def spend_path(self) -> Path:
|
|
234
|
+
return self.run_dir / _SPEND_FILE
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def warmup_path(self) -> Path:
|
|
238
|
+
return self.run_dir / _WARMUP_FILE
|
|
239
|
+
|
|
240
|
+
@property
|
|
241
|
+
def warmup_trials_path(self) -> Path:
|
|
242
|
+
return self.run_dir / _WARMUP_TRIALS_FILE
|
|
243
|
+
|
|
244
|
+
@property
|
|
245
|
+
def checkpoints_path(self) -> Path:
|
|
246
|
+
return self.run_dir / _CHECKPOINTS_FILE
|
|
247
|
+
|
|
248
|
+
@property
|
|
249
|
+
def gate_path(self) -> Path:
|
|
250
|
+
return self.run_dir / _GATE_FILE
|
|
251
|
+
|
|
252
|
+
@property
|
|
253
|
+
def model_card_path(self) -> Path:
|
|
254
|
+
return self.run_dir / _MODEL_CARD_FILE
|
|
255
|
+
|
|
256
|
+
@property
|
|
257
|
+
def handoff_path(self) -> Path:
|
|
258
|
+
return self.run_dir / _HANDOFF_FILE
|
|
259
|
+
|
|
260
|
+
@property
|
|
261
|
+
def evals_dir(self) -> Path:
|
|
262
|
+
return self.run_dir / _EVALS_DIR
|
|
263
|
+
|
|
264
|
+
@property
|
|
265
|
+
def samples_dir(self) -> Path:
|
|
266
|
+
return self.run_dir / _SAMPLES_DIR
|
|
267
|
+
|
|
268
|
+
# -- config snapshot -----------------------------------------------------------------------
|
|
269
|
+
|
|
270
|
+
def snapshot_config(self, cfg: DistillConfig) -> Path:
|
|
271
|
+
"""Write the exact config the run started with to `config.toml`."""
|
|
272
|
+
self.run_dir.mkdir(parents=True, exist_ok=True)
|
|
273
|
+
write_text_atomic(self.config_path, snapshot_toml(cfg))
|
|
274
|
+
return self.config_path
|
|
275
|
+
|
|
276
|
+
def load_config(self) -> DistillConfig:
|
|
277
|
+
"""Read the snapshotted config back (the resume path's source of truth)."""
|
|
278
|
+
return load_distill_config(self.config_path)
|
|
279
|
+
|
|
280
|
+
# -- metrics ---------------------------------------------------------------------------------
|
|
281
|
+
|
|
282
|
+
def append_metrics(self, step: int, row: BaseModel) -> None:
|
|
283
|
+
"""Append one training step's metrics row to `metrics.jsonl`.
|
|
284
|
+
|
|
285
|
+
Args:
|
|
286
|
+
step: 0-based training step the row belongs to; written as the
|
|
287
|
+
row's `step` key.
|
|
288
|
+
row: The step's metrics as a pydantic model; its JSON-mode dump
|
|
289
|
+
becomes the rest of the row.
|
|
290
|
+
|
|
291
|
+
Raises:
|
|
292
|
+
ValueError: If `step` is negative, or the row itself carries a
|
|
293
|
+
conflicting `step` field.
|
|
294
|
+
"""
|
|
295
|
+
if step < 0:
|
|
296
|
+
raise ValueError(f"metrics step must be >= 0, got {step}")
|
|
297
|
+
data = row.model_dump(mode="json")
|
|
298
|
+
if "step" in data and data["step"] != step:
|
|
299
|
+
raise ValueError(
|
|
300
|
+
f"metrics row carries step {data['step']!r} but was appended as step {step}; "
|
|
301
|
+
"drop the row's own step field or pass the matching step"
|
|
302
|
+
)
|
|
303
|
+
payload: JsonObject = {"step": step, **data}
|
|
304
|
+
self.run_dir.mkdir(parents=True, exist_ok=True)
|
|
305
|
+
with self.metrics_path.open("a", encoding="utf-8") as handle:
|
|
306
|
+
handle.write(json.dumps(payload) + "\n")
|
|
307
|
+
|
|
308
|
+
def read_metrics(self) -> list[JsonObject]:
|
|
309
|
+
"""Read every metrics row back, in append order.
|
|
310
|
+
|
|
311
|
+
Returns:
|
|
312
|
+
One JSON object per non-empty line; an empty list when no metrics
|
|
313
|
+
were written yet.
|
|
314
|
+
|
|
315
|
+
Raises:
|
|
316
|
+
ValueError: If a line is not a JSON object; the message names the
|
|
317
|
+
line so the corrupt row can be inspected or removed.
|
|
318
|
+
"""
|
|
319
|
+
try:
|
|
320
|
+
text = self.metrics_path.read_text(encoding="utf-8")
|
|
321
|
+
except FileNotFoundError:
|
|
322
|
+
return []
|
|
323
|
+
rows: list[JsonObject] = []
|
|
324
|
+
for line_number, line in enumerate(text.splitlines(), 1):
|
|
325
|
+
if not line.strip():
|
|
326
|
+
continue
|
|
327
|
+
try:
|
|
328
|
+
parsed = json.loads(line)
|
|
329
|
+
except json.JSONDecodeError as exc:
|
|
330
|
+
raise ValueError(
|
|
331
|
+
f"corrupt metrics row on line {line_number} of {self.metrics_path}: {exc}; "
|
|
332
|
+
"remove the broken line (each line must be one JSON object) and retry"
|
|
333
|
+
) from exc
|
|
334
|
+
if not isinstance(parsed, dict):
|
|
335
|
+
raise ValueError(
|
|
336
|
+
f"corrupt metrics row on line {line_number} of {self.metrics_path}: "
|
|
337
|
+
"expected a JSON object; remove the broken line and retry"
|
|
338
|
+
)
|
|
339
|
+
rows.append(parsed)
|
|
340
|
+
return rows
|
|
341
|
+
|
|
342
|
+
def last_step(self) -> int | None:
|
|
343
|
+
"""The highest step recorded in `metrics.jsonl`, or None when empty."""
|
|
344
|
+
steps = [row["step"] for row in self.read_metrics() if isinstance(row.get("step"), int)]
|
|
345
|
+
return max(steps) if steps else None
|
|
346
|
+
|
|
347
|
+
def budget_spent(self) -> float:
|
|
348
|
+
"""Total USD recorded across metrics rows (their `usd` keys).
|
|
349
|
+
|
|
350
|
+
This is only the resume fallback for run dirs that predate the spend
|
|
351
|
+
ledger (`read_spend` returning None): metrics rows land once per
|
|
352
|
+
completed step, so spend since the last row (baseline evals before
|
|
353
|
+
step 0, an interim eval after its step's row, the finalize
|
|
354
|
+
student-after eval) is invisible here, and a resumed session that
|
|
355
|
+
re-appends rows for re-run steps double-counts them (conservative
|
|
356
|
+
direction). Rows without a numeric `usd` key contribute nothing; a
|
|
357
|
+
fresh run reports 0.0.
|
|
358
|
+
"""
|
|
359
|
+
total = 0.0
|
|
360
|
+
for row in self.read_metrics():
|
|
361
|
+
usd = row.get("usd")
|
|
362
|
+
if isinstance(usd, int | float) and not isinstance(usd, bool):
|
|
363
|
+
total += float(usd)
|
|
364
|
+
return total
|
|
365
|
+
|
|
366
|
+
# -- spend ledger ----------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
def write_spend(self, total_usd: float) -> None:
|
|
369
|
+
"""Persist the run's cumulative priced spend to `spend.json` (atomically).
|
|
370
|
+
|
|
371
|
+
The loop calls this on EVERY budget charge, so the ledger (not the
|
|
372
|
+
metrics rows, which only land when a training step completes) is the
|
|
373
|
+
resume path's source of truth for prior spend. Without it, everything
|
|
374
|
+
charged since the last metrics row (holdout baselines before step 0,
|
|
375
|
+
an interim eval after its step's row, the finalize student-after eval)
|
|
376
|
+
would be forgotten and a resumed run could spend `budget.max_usd`
|
|
377
|
+
again.
|
|
378
|
+
|
|
379
|
+
Args:
|
|
380
|
+
total_usd: Cumulative priced USD across every session of the run.
|
|
381
|
+
|
|
382
|
+
Raises:
|
|
383
|
+
ValueError: If `total_usd` is negative.
|
|
384
|
+
"""
|
|
385
|
+
if total_usd < 0:
|
|
386
|
+
raise ValueError(f"cumulative spend must be >= 0, got {total_usd}")
|
|
387
|
+
self.run_dir.mkdir(parents=True, exist_ok=True)
|
|
388
|
+
payload = SpendLedger(total_usd=total_usd).model_dump_json(indent=2)
|
|
389
|
+
# The ledger is rewritten on every charge, and a torn write would
|
|
390
|
+
# block the next resume with a corrupt-ledger error.
|
|
391
|
+
write_text_atomic(self.spend_path, payload)
|
|
392
|
+
|
|
393
|
+
def read_spend(self) -> float | None:
|
|
394
|
+
"""The ledger's cumulative spend, or None when no ledger exists yet.
|
|
395
|
+
|
|
396
|
+
None means either a fresh run dir or one from before the ledger
|
|
397
|
+
existed; resume falls back to `budget_spent()` in that case.
|
|
398
|
+
|
|
399
|
+
Raises:
|
|
400
|
+
ValueError: If the ledger file exists but does not parse; the
|
|
401
|
+
message says how to recover.
|
|
402
|
+
"""
|
|
403
|
+
try:
|
|
404
|
+
text = self.spend_path.read_text(encoding="utf-8")
|
|
405
|
+
except FileNotFoundError:
|
|
406
|
+
return None
|
|
407
|
+
try:
|
|
408
|
+
return SpendLedger.model_validate_json(text).total_usd
|
|
409
|
+
except ValidationError as exc:
|
|
410
|
+
raise ValueError(
|
|
411
|
+
f"corrupt spend ledger at {self.spend_path}: {exc}; delete the file to "
|
|
412
|
+
"fall back to summing metrics rows (which may miss eval spend, so "
|
|
413
|
+
"consider lowering budget.max_usd accordingly) and resume"
|
|
414
|
+
) from exc
|
|
415
|
+
|
|
416
|
+
# -- warmup ----------------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
def write_warmup(self, record: WarmupRecord) -> Path:
|
|
419
|
+
"""Persist the warmup phase's terminal record to `warmup.json`.
|
|
420
|
+
|
|
421
|
+
Written exactly once per run, when the phase finished training or
|
|
422
|
+
decided to skip; a resumed session that finds the file never re-runs
|
|
423
|
+
warmup.
|
|
424
|
+
|
|
425
|
+
Args:
|
|
426
|
+
record: The phase's outcome.
|
|
427
|
+
|
|
428
|
+
Returns:
|
|
429
|
+
The written path.
|
|
430
|
+
"""
|
|
431
|
+
self.run_dir.mkdir(parents=True, exist_ok=True)
|
|
432
|
+
write_text_atomic(self.warmup_path, record.model_dump_json(indent=2))
|
|
433
|
+
return self.warmup_path
|
|
434
|
+
|
|
435
|
+
def read_warmup(self) -> WarmupRecord | None:
|
|
436
|
+
"""The recorded warmup outcome, or None when the phase never finished.
|
|
437
|
+
|
|
438
|
+
Raises:
|
|
439
|
+
ValueError: If the file exists but does not parse; the message
|
|
440
|
+
says how to recover.
|
|
441
|
+
"""
|
|
442
|
+
try:
|
|
443
|
+
text = self.warmup_path.read_text(encoding="utf-8")
|
|
444
|
+
except FileNotFoundError:
|
|
445
|
+
return None
|
|
446
|
+
try:
|
|
447
|
+
return WarmupRecord.model_validate_json(text)
|
|
448
|
+
except ValidationError as exc:
|
|
449
|
+
raise ValueError(
|
|
450
|
+
f"corrupt warmup record at {self.warmup_path}: {exc}; delete the file "
|
|
451
|
+
"so the resumed run re-runs the warmup phase (already-collected teacher "
|
|
452
|
+
"trials under warmup-rollouts/ resume trial-level through harbor)"
|
|
453
|
+
) from exc
|
|
454
|
+
|
|
455
|
+
def write_warmup_trials(self, manifest: WarmupTrialsManifest) -> Path:
|
|
456
|
+
"""Persist the warmup collection's trial manifest to `warmup-trials.json`.
|
|
457
|
+
|
|
458
|
+
Written when the warmup teacher rollouts finish assembling (before the
|
|
459
|
+
keep filter), so another run can reuse the collection through
|
|
460
|
+
`warmup.trajectories_from` instead of paying for its own.
|
|
461
|
+
|
|
462
|
+
Args:
|
|
463
|
+
manifest: The collection's teacher identity and trial records.
|
|
464
|
+
|
|
465
|
+
Returns:
|
|
466
|
+
The written path.
|
|
467
|
+
"""
|
|
468
|
+
self.run_dir.mkdir(parents=True, exist_ok=True)
|
|
469
|
+
write_text_atomic(self.warmup_trials_path, manifest.model_dump_json(indent=2))
|
|
470
|
+
return self.warmup_trials_path
|
|
471
|
+
|
|
472
|
+
def read_warmup_trials(self) -> WarmupTrialsManifest | None:
|
|
473
|
+
"""The recorded warmup trial manifest, or None when none was written.
|
|
474
|
+
|
|
475
|
+
Raises:
|
|
476
|
+
ValueError: If the file exists but does not parse; the message
|
|
477
|
+
says how to recover.
|
|
478
|
+
"""
|
|
479
|
+
try:
|
|
480
|
+
text = self.warmup_trials_path.read_text(encoding="utf-8")
|
|
481
|
+
except FileNotFoundError:
|
|
482
|
+
return None
|
|
483
|
+
try:
|
|
484
|
+
return WarmupTrialsManifest.model_validate_json(text)
|
|
485
|
+
except ValidationError as exc:
|
|
486
|
+
raise ValueError(
|
|
487
|
+
f"corrupt warmup trial manifest at {self.warmup_trials_path}: {exc}; "
|
|
488
|
+
"re-run the source run's warmup collection to rewrite it, or point "
|
|
489
|
+
"warmup.trajectories_from elsewhere"
|
|
490
|
+
) from exc
|
|
491
|
+
|
|
492
|
+
# -- evals -----------------------------------------------------------------------------------
|
|
493
|
+
|
|
494
|
+
def write_eval(self, name: str, payload: BaseModel) -> Path:
|
|
495
|
+
"""Write one eval payload to `evals/<name>.json`, replacing any prior one.
|
|
496
|
+
|
|
497
|
+
Args:
|
|
498
|
+
name: The eval's key (e.g. "baseline-teacher", "step-0010"); must
|
|
499
|
+
be a safe single path segment.
|
|
500
|
+
payload: The eval report model to persist.
|
|
501
|
+
|
|
502
|
+
Returns:
|
|
503
|
+
The written path.
|
|
504
|
+
"""
|
|
505
|
+
self.evals_dir.mkdir(parents=True, exist_ok=True)
|
|
506
|
+
path = self.evals_dir / f"{validate_name(name)}.json"
|
|
507
|
+
write_text_atomic(path, payload.model_dump_json(indent=2))
|
|
508
|
+
return path
|
|
509
|
+
|
|
510
|
+
# -- sample rollouts -------------------------------------------------------------------------
|
|
511
|
+
|
|
512
|
+
def write_samples(self, name: str, text: str) -> Path:
|
|
513
|
+
"""Write one batch's rendered sample rollouts to `samples/<name>.md`.
|
|
514
|
+
|
|
515
|
+
One file per batch, replacing any prior one (a resumed session that
|
|
516
|
+
re-runs a step rewrites its samples): `step-NNNN` for training
|
|
517
|
+
batches, `warmup` for the warmup collection, `eval-<name>` for eval
|
|
518
|
+
batches.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
name: The batch's file stem; must be a safe single path segment.
|
|
522
|
+
text: The assembled document (`wmo.distill.samples.samples_markdown`).
|
|
523
|
+
|
|
524
|
+
Returns:
|
|
525
|
+
The written path.
|
|
526
|
+
"""
|
|
527
|
+
self.samples_dir.mkdir(parents=True, exist_ok=True)
|
|
528
|
+
path = self.samples_dir / f"{validate_name(name)}.md"
|
|
529
|
+
write_text_atomic(path, text)
|
|
530
|
+
return path
|
|
531
|
+
|
|
532
|
+
# -- checkpoints -----------------------------------------------------------------------------
|
|
533
|
+
|
|
534
|
+
def record_checkpoint(self, step: int, state_path: str, sampler_path: str) -> CheckpointRecord:
|
|
535
|
+
"""Record a saved checkpoint in `checkpoints.json`.
|
|
536
|
+
|
|
537
|
+
A record for the same step replaces the earlier one (a resumed run
|
|
538
|
+
re-saves its cadence checkpoints); distinct steps append.
|
|
539
|
+
|
|
540
|
+
Args:
|
|
541
|
+
step: 0-based training step the checkpoint was saved after.
|
|
542
|
+
state_path: The tinker:// path save_state returned.
|
|
543
|
+
sampler_path: The tinker:// sampler-weights path current at save.
|
|
544
|
+
|
|
545
|
+
Returns:
|
|
546
|
+
The recorded checkpoint.
|
|
547
|
+
"""
|
|
548
|
+
record = CheckpointRecord(step=step, state_path=state_path, sampler_path=sampler_path)
|
|
549
|
+
manifest = self._read_manifest()
|
|
550
|
+
manifest.checkpoints = [c for c in manifest.checkpoints if c.step != step]
|
|
551
|
+
manifest.checkpoints.append(record)
|
|
552
|
+
self.run_dir.mkdir(parents=True, exist_ok=True)
|
|
553
|
+
write_text_atomic(self.checkpoints_path, manifest.model_dump_json(indent=2))
|
|
554
|
+
return record
|
|
555
|
+
|
|
556
|
+
def write_tripwire_baseline(self, baseline: TripwireBaseline) -> Path:
|
|
557
|
+
"""Persist the degeneration tripwires' baseline into `checkpoints.json`.
|
|
558
|
+
|
|
559
|
+
Written the moment the baseline is measured (the run's first training
|
|
560
|
+
step), not at the next checkpoint cadence: a session that dies in
|
|
561
|
+
between must not lose it, or the resumed session would re-baseline
|
|
562
|
+
against whatever policy it restores. `record_checkpoint` reads the
|
|
563
|
+
manifest before rewriting it, so the two never clobber each other.
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
baseline: The measured baseline.
|
|
567
|
+
|
|
568
|
+
Returns:
|
|
569
|
+
The manifest path.
|
|
570
|
+
"""
|
|
571
|
+
manifest = self._read_manifest()
|
|
572
|
+
manifest.tripwire_baseline = baseline
|
|
573
|
+
self.run_dir.mkdir(parents=True, exist_ok=True)
|
|
574
|
+
write_text_atomic(self.checkpoints_path, manifest.model_dump_json(indent=2))
|
|
575
|
+
return self.checkpoints_path
|
|
576
|
+
|
|
577
|
+
def read_tripwire_baseline(self) -> TripwireBaseline | None:
|
|
578
|
+
"""The recorded tripwire baseline, or None when none was measured yet."""
|
|
579
|
+
return self._read_manifest().tripwire_baseline
|
|
580
|
+
|
|
581
|
+
def checkpoints(self) -> list[CheckpointRecord]:
|
|
582
|
+
"""Every recorded checkpoint, in record order."""
|
|
583
|
+
return list(self._read_manifest().checkpoints)
|
|
584
|
+
|
|
585
|
+
def latest_checkpoint(self) -> CheckpointRecord | None:
|
|
586
|
+
"""The highest-step checkpoint (the one `--resume` restores), or None."""
|
|
587
|
+
recorded = self._read_manifest().checkpoints
|
|
588
|
+
return max(recorded, key=lambda record: record.step) if recorded else None
|
|
589
|
+
|
|
590
|
+
def _read_manifest(self) -> CheckpointManifest:
|
|
591
|
+
try:
|
|
592
|
+
text = self.checkpoints_path.read_text(encoding="utf-8")
|
|
593
|
+
except FileNotFoundError:
|
|
594
|
+
return CheckpointManifest()
|
|
595
|
+
try:
|
|
596
|
+
return CheckpointManifest.model_validate_json(text)
|
|
597
|
+
except ValidationError as exc:
|
|
598
|
+
raise ValueError(
|
|
599
|
+
f"corrupt checkpoint manifest at {self.checkpoints_path}: {exc}; "
|
|
600
|
+
"restore it from the run's backup or delete it and re-save a checkpoint "
|
|
601
|
+
"(deleting loses resume points)"
|
|
602
|
+
) from exc
|
|
603
|
+
|
|
604
|
+
# -- terminal artifacts ----------------------------------------------------------------------
|
|
605
|
+
|
|
606
|
+
def write_gate(self, record: DistillGateRecord) -> Path:
|
|
607
|
+
"""Persist the promotion verdict to `gate.json`."""
|
|
608
|
+
self.run_dir.mkdir(parents=True, exist_ok=True)
|
|
609
|
+
write_text_atomic(self.gate_path, record.model_dump_json(indent=2))
|
|
610
|
+
return self.gate_path
|
|
611
|
+
|
|
612
|
+
def write_model_card(self, card: DistillModelCard) -> Path:
|
|
613
|
+
"""Persist the run's model card to `model_card.json`."""
|
|
614
|
+
self.run_dir.mkdir(parents=True, exist_ok=True)
|
|
615
|
+
write_text_atomic(self.model_card_path, card.model_dump_json(indent=2))
|
|
616
|
+
return self.model_card_path
|
|
617
|
+
|
|
618
|
+
def write_handoff(self, toml_text: str) -> Path:
|
|
619
|
+
"""Persist the serving handoff snippet to `handoff.toml`.
|
|
620
|
+
|
|
621
|
+
Args:
|
|
622
|
+
toml_text: The snippet, normally from `build_handoff_toml`.
|
|
623
|
+
|
|
624
|
+
Returns:
|
|
625
|
+
The written path.
|
|
626
|
+
|
|
627
|
+
Raises:
|
|
628
|
+
ValueError: If the text is not valid TOML (the snippet must paste
|
|
629
|
+
cleanly into a settings file); build it via `build_handoff_toml`.
|
|
630
|
+
"""
|
|
631
|
+
try:
|
|
632
|
+
tomllib.loads(toml_text)
|
|
633
|
+
except tomllib.TOMLDecodeError as exc:
|
|
634
|
+
raise ValueError(
|
|
635
|
+
f"handoff snippet is not valid TOML ({exc}); build it with "
|
|
636
|
+
"build_handoff_toml so it pastes cleanly into a settings file"
|
|
637
|
+
) from exc
|
|
638
|
+
self.run_dir.mkdir(parents=True, exist_ok=True)
|
|
639
|
+
write_text_atomic(self.handoff_path, toml_text)
|
|
640
|
+
return self.handoff_path
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
class AdapterStore:
|
|
644
|
+
"""Named, versioned distilled adapters under `<root>/adapters/<name>/`.
|
|
645
|
+
|
|
646
|
+
Mirrors `HarnessStore`: versions are append-only `vN/` directories (each
|
|
647
|
+
holding one `model_card.json`), and deployment state lives in movable
|
|
648
|
+
aliases, so promotion and rollback never rewrite an artifact.
|
|
649
|
+
"""
|
|
650
|
+
|
|
651
|
+
def __init__(self, root: str | Path = ".wmo") -> None:
|
|
652
|
+
self.root = Path(root)
|
|
653
|
+
|
|
654
|
+
@property
|
|
655
|
+
def adapters_dir(self) -> Path:
|
|
656
|
+
return self.root / ADAPTERS_DIR
|
|
657
|
+
|
|
658
|
+
def dir_for(self, name: str) -> Path:
|
|
659
|
+
return self.adapters_dir / validate_name(name)
|
|
660
|
+
|
|
661
|
+
# -- enumeration -----------------------------------------------------------------------------
|
|
662
|
+
|
|
663
|
+
def list_names(self) -> list[str]:
|
|
664
|
+
if not self.adapters_dir.exists():
|
|
665
|
+
return []
|
|
666
|
+
return sorted(
|
|
667
|
+
d.name for d in self.adapters_dir.iterdir() if d.is_dir() and self.versions(d.name)
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
def versions(self, name: str) -> list[int]:
|
|
671
|
+
directory = self.dir_for(name)
|
|
672
|
+
if not directory.exists():
|
|
673
|
+
return []
|
|
674
|
+
found: list[int] = []
|
|
675
|
+
for child in directory.iterdir():
|
|
676
|
+
if child.is_dir() and child.name.startswith("v") and child.name[1:].isdigit():
|
|
677
|
+
found.append(int(child.name[1:]))
|
|
678
|
+
return sorted(found)
|
|
679
|
+
|
|
680
|
+
def exists(self, name: str) -> bool:
|
|
681
|
+
return bool(self.versions(name))
|
|
682
|
+
|
|
683
|
+
# -- aliases ---------------------------------------------------------------------------------
|
|
684
|
+
|
|
685
|
+
def aliases(self, name: str) -> dict[str, int]:
|
|
686
|
+
path = self.dir_for(name) / _ALIASES_FILE
|
|
687
|
+
if not path.exists():
|
|
688
|
+
return {}
|
|
689
|
+
data = tomllib.loads(path.read_text(encoding="utf-8")).get("aliases", {})
|
|
690
|
+
return {k: v for k, v in data.items() if isinstance(v, int)}
|
|
691
|
+
|
|
692
|
+
def set_alias(self, name: str, alias: str, version: int) -> None:
|
|
693
|
+
"""Point `alias` at `version` (moving it if it exists). Rollback is re-pointing."""
|
|
694
|
+
if version not in self.versions(name):
|
|
695
|
+
raise ValueError(f"adapter {name!r} has no version v{version}")
|
|
696
|
+
current = self.aliases(name)
|
|
697
|
+
current[alias] = version
|
|
698
|
+
path = self.dir_for(name) / _ALIASES_FILE
|
|
699
|
+
write_text_atomic(path, tomli_w.dumps({"aliases": current}))
|
|
700
|
+
|
|
701
|
+
# -- load / save -----------------------------------------------------------------------------
|
|
702
|
+
|
|
703
|
+
def resolve_version(self, name: str, ref: str | None = None) -> int:
|
|
704
|
+
"""Resolve a version ref: `None` -> champion alias, else latest; `"vN"`/`"N"`; an alias."""
|
|
705
|
+
available = self.versions(name)
|
|
706
|
+
if not available:
|
|
707
|
+
raise FileNotFoundError(
|
|
708
|
+
f"no adapter named {name!r} under {self.adapters_dir} "
|
|
709
|
+
f"(have: {', '.join(self.list_names()) or 'none'})"
|
|
710
|
+
)
|
|
711
|
+
aliases = self.aliases(name)
|
|
712
|
+
if ref is None:
|
|
713
|
+
return aliases.get(CHAMPION_ALIAS, available[-1])
|
|
714
|
+
normalized = ref.removeprefix("v")
|
|
715
|
+
if normalized.isdigit():
|
|
716
|
+
version = int(normalized)
|
|
717
|
+
if version not in available:
|
|
718
|
+
raise ValueError(f"adapter {name!r} has no version v{version}")
|
|
719
|
+
return version
|
|
720
|
+
if ref in aliases:
|
|
721
|
+
return aliases[ref]
|
|
722
|
+
raise ValueError(f"adapter {name!r} has no version or alias {ref!r}")
|
|
723
|
+
|
|
724
|
+
def resolve(self, name: str, ref: str | None = None) -> DistillModelCard:
|
|
725
|
+
"""Load the model card a ref resolves to (see `resolve_version` for refs).
|
|
726
|
+
|
|
727
|
+
Args:
|
|
728
|
+
name: The adapter name.
|
|
729
|
+
ref: A version (`"vN"`/`"N"`), an alias (e.g. "champion"), or None
|
|
730
|
+
for the champion alias falling back to latest.
|
|
731
|
+
|
|
732
|
+
Returns:
|
|
733
|
+
The version's model card, stamped with its name and version.
|
|
734
|
+
"""
|
|
735
|
+
version = self.resolve_version(name, ref)
|
|
736
|
+
path = self.dir_for(name) / f"v{version}" / _CARD_FILE
|
|
737
|
+
try:
|
|
738
|
+
text = path.read_text(encoding="utf-8")
|
|
739
|
+
except FileNotFoundError as exc:
|
|
740
|
+
raise FileNotFoundError(
|
|
741
|
+
f"adapter {name!r} v{version} has no {_CARD_FILE} at {path}; the version "
|
|
742
|
+
"directory is corrupt, so re-save the adapter or remove the broken version"
|
|
743
|
+
) from exc
|
|
744
|
+
card = DistillModelCard.model_validate_json(text)
|
|
745
|
+
return card.model_copy(update={"name": name, "version": version})
|
|
746
|
+
|
|
747
|
+
def save_version(
|
|
748
|
+
self, name: str, card: DistillModelCard, *, alias: str | None = CHAMPION_ALIAS
|
|
749
|
+
) -> int:
|
|
750
|
+
"""Write `card` as the next version of `name`; optionally point `alias` at it.
|
|
751
|
+
|
|
752
|
+
Versions are append-only: this never touches an existing version
|
|
753
|
+
directory.
|
|
754
|
+
|
|
755
|
+
Args:
|
|
756
|
+
name: The adapter name (a safe single path segment).
|
|
757
|
+
card: The model card to persist; its `name` and `version` fields
|
|
758
|
+
are stamped, not read.
|
|
759
|
+
alias: The alias to move to the new version; "champion" by
|
|
760
|
+
default, None to save without moving any alias.
|
|
761
|
+
|
|
762
|
+
Returns:
|
|
763
|
+
The new version number.
|
|
764
|
+
"""
|
|
765
|
+
validate_name(name)
|
|
766
|
+
version = (self.versions(name)[-1] + 1) if self.exists(name) else 1
|
|
767
|
+
stamped = card.model_copy(update={"name": name, "version": version})
|
|
768
|
+
directory = self.dir_for(name) / f"v{version}"
|
|
769
|
+
directory.mkdir(parents=True, exist_ok=False) # append-only: collision is a bug
|
|
770
|
+
write_text_atomic(directory / _CARD_FILE, stamped.model_dump_json(indent=2))
|
|
771
|
+
if alias is not None:
|
|
772
|
+
self.set_alias(name, alias, version)
|
|
773
|
+
return version
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def build_handoff_toml(sampler_path: str, *, base_model: str, endpoint: str | None = None) -> str:
|
|
777
|
+
"""Build the `[models.agent]` snippet pointing an agent at the distilled student.
|
|
778
|
+
|
|
779
|
+
The snippet targets wmo's openai provider with a custom endpoint, which is
|
|
780
|
+
how any OpenAI-compatible server is addressed; Tinker's serving endpoint
|
|
781
|
+
authenticates with the Tinker API key via `WMO_ENDPOINT_API_KEY` (the
|
|
782
|
+
openai provider never sends `OPENAI_API_KEY` to a custom endpoint).
|
|
783
|
+
|
|
784
|
+
Args:
|
|
785
|
+
sampler_path: The final tinker:// sampler-weights path to serve.
|
|
786
|
+
base_model: The student's base model name, written as `model_type` so
|
|
787
|
+
capability resolution keys on the model family, not the raw
|
|
788
|
+
weights path (the same shape the CLI's `--promote` writes).
|
|
789
|
+
endpoint: The OpenAI-compatible base URL; defaults to
|
|
790
|
+
`DEFAULT_TINKER_OPENAI_ENDPOINT`.
|
|
791
|
+
|
|
792
|
+
Returns:
|
|
793
|
+
A valid TOML document: comment lines explaining auth, then the
|
|
794
|
+
`[models.agent]` table.
|
|
795
|
+
|
|
796
|
+
Raises:
|
|
797
|
+
ValueError: If the sampler path is not a tinker:// path, or a value
|
|
798
|
+
cannot be embedded in a basic TOML string.
|
|
799
|
+
"""
|
|
800
|
+
if not sampler_path.startswith("tinker://"):
|
|
801
|
+
raise ValueError(
|
|
802
|
+
f"sampler path {sampler_path!r} is not a tinker:// weights path; pass the "
|
|
803
|
+
"path returned by save_weights_for_sampler (recorded in the run's "
|
|
804
|
+
"checkpoints.json and model card)"
|
|
805
|
+
)
|
|
806
|
+
resolved_endpoint = endpoint if endpoint is not None else DEFAULT_TINKER_OPENAI_ENDPOINT
|
|
807
|
+
values = (
|
|
808
|
+
("sampler path", sampler_path),
|
|
809
|
+
("base model", base_model),
|
|
810
|
+
("endpoint", resolved_endpoint),
|
|
811
|
+
)
|
|
812
|
+
for label, value in values:
|
|
813
|
+
if any(ch in value for ch in ('"', "\\", "\n")):
|
|
814
|
+
raise ValueError(
|
|
815
|
+
f"{label} {value!r} contains a quote, backslash, or newline and cannot be "
|
|
816
|
+
"embedded in the handoff snippet; check the value for corruption"
|
|
817
|
+
)
|
|
818
|
+
return (
|
|
819
|
+
"# Distilled student handoff: point your agent at the trained adapter through\n"
|
|
820
|
+
"# Tinker's OpenAI-compatible serving endpoint.\n"
|
|
821
|
+
"# Auth: set WMO_ENDPOINT_API_KEY to your Tinker API key (the TINKER_API_KEY\n"
|
|
822
|
+
"# value); wmo sends WMO_ENDPOINT_API_KEY, never OPENAI_API_KEY, to custom\n"
|
|
823
|
+
"# endpoints.\n"
|
|
824
|
+
"[models.agent]\n"
|
|
825
|
+
'provider = "openai"\n'
|
|
826
|
+
f'model = "{sampler_path}"\n'
|
|
827
|
+
f'model_type = "{base_model}"\n'
|
|
828
|
+
f'endpoint = "{resolved_endpoint}"\n'
|
|
829
|
+
)
|