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/rollouts.py
ADDED
|
@@ -0,0 +1,726 @@
|
|
|
1
|
+
"""Collect one distillation training step's rollouts as real harbor trials.
|
|
2
|
+
|
|
3
|
+
`collect_rollouts` turns one train batch (task ids x group size) into scored
|
|
4
|
+
harbor trials of **harbor's own terminus-2 agent** sampling the Tinker
|
|
5
|
+
student, and joins each trial's exact token spans back into `TrialRecord`s.
|
|
6
|
+
|
|
7
|
+
Terminus-2, not the WMO pi bridge, because the scaffold is not what this run
|
|
8
|
+
is measuring: on the same TerminalBench-2 tasks pi took 2-3x the turns
|
|
9
|
+
terminus-2's published p50 of 10 (p50 21-30 across four models) with 0%
|
|
10
|
+
wasted turns, so the gap is scaffold efficiency, not a bug, and it drove
|
|
11
|
+
39-59% of the harness loss. Terminus-2 supports this natively:
|
|
12
|
+
`llm_backend="tinker"` builds `harbor.llms.tinker.TinkerLLM`, and
|
|
13
|
+
`collect_rollout_details=True` records per-turn `prompt_token_ids`,
|
|
14
|
+
`completion_token_ids` and `logprobs` that harbor persists into each trial's
|
|
15
|
+
`result.json` (`agent_result.rollout_details`). Those ids ARE the training
|
|
16
|
+
targets; nothing is re-encoded from text.
|
|
17
|
+
|
|
18
|
+
Three consequences of that agent swap are load-bearing here:
|
|
19
|
+
|
|
20
|
+
- **No token sink directory.** Spans come from harbor's own per-trial
|
|
21
|
+
`result.json`, which the scorer's entry prune deletes together with the
|
|
22
|
+
trial it belongs to, so there is nothing left to keep outside the trial dir.
|
|
23
|
+
- **Terminus-2 runs IN-PROCESS**, driving the task environment over tmux, so
|
|
24
|
+
a trial holds one sandbox (the task environment), not two, and the Tinker
|
|
25
|
+
API key must be present in THIS process rather than forwarded into a
|
|
26
|
+
harness sandbox.
|
|
27
|
+
- **Terminus-2 has no wall clock of its own**, so `rollout.episode_timeout_s`
|
|
28
|
+
is applied as harbor's agent-phase timeout
|
|
29
|
+
(`AgentConfig.override_timeout_sec`) instead of an agent kwarg.
|
|
30
|
+
|
|
31
|
+
Two directory choices are unchanged and still load-bearing:
|
|
32
|
+
|
|
33
|
+
- The harbor jobs dir is FRESH per training step (`run_dir/harbor/step-NNNN`):
|
|
34
|
+
harbor job dirs are keyed by the candidate doc hash only, so reusing one
|
|
35
|
+
jobs dir across steps would resume step N's completed trials as step N+1's
|
|
36
|
+
"results", carrying tokens sampled from the previous weights.
|
|
37
|
+
- A job dir left by a PREVIOUS SESSION under different sampler weights is
|
|
38
|
+
wiped before scoring (`_wipe_stale_policy_dir`): sampler paths carry a
|
|
39
|
+
per-session nonce, so such a dir can never satisfy the scorer's strict
|
|
40
|
+
job-config resume check, and its trials sampled a policy this session did
|
|
41
|
+
not restore. Wiping makes a crash-resumed step re-run whole from the
|
|
42
|
+
current weights. A dir whose recorded provider matches (the teacher's
|
|
43
|
+
stable identity) is kept for harbor's native trial-level resume.
|
|
44
|
+
|
|
45
|
+
The harbor SDK is an optional extra imported lazily here, the same contract
|
|
46
|
+
as the CLI's harbor commands; `import wmo.distill.rollouts` succeeds without
|
|
47
|
+
it.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
from __future__ import annotations
|
|
51
|
+
|
|
52
|
+
import asyncio
|
|
53
|
+
import json
|
|
54
|
+
import logging
|
|
55
|
+
import shutil
|
|
56
|
+
from collections.abc import Callable, Sequence
|
|
57
|
+
from pathlib import Path
|
|
58
|
+
from typing import TYPE_CHECKING
|
|
59
|
+
|
|
60
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
61
|
+
|
|
62
|
+
from wmo.core.types import JsonObject
|
|
63
|
+
from wmo.distill.config import DistillConfig
|
|
64
|
+
from wmo.distill.tokens import TrialRecord, assemble_harbor_trial_records
|
|
65
|
+
from wmo.harness.doc import HarnessDoc
|
|
66
|
+
from wmo.harness.runtime import StopReason
|
|
67
|
+
from wmo.providers.base import ProviderConfig, ProviderKind
|
|
68
|
+
|
|
69
|
+
if TYPE_CHECKING:
|
|
70
|
+
from harbor.models.job.config import JobConfig
|
|
71
|
+
|
|
72
|
+
logger = logging.getLogger(__name__)
|
|
73
|
+
|
|
74
|
+
MISSING_HARBOR_EXTRA = (
|
|
75
|
+
"the harbor SDK is not installed; distillation rollouts run as real harbor trials. "
|
|
76
|
+
"Run `uv sync --extra harbor` (or `pip install 'world-model-optimizer[harbor]'`) and retry"
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
HARBOR_TERMINUS_2_AGENT_IMPORT_PATH = "harbor.agents.terminus_2.terminus_2:Terminus2"
|
|
80
|
+
"""Harbor's own terminus-2 agent, the rollout agent for distillation.
|
|
81
|
+
|
|
82
|
+
Imported by string through harbor's agent factory (never at module scope here), so
|
|
83
|
+
`import wmo.distill.rollouts` keeps working without the harbor extra."""
|
|
84
|
+
|
|
85
|
+
E2B_SANDBOXES_PER_TRIAL = 1
|
|
86
|
+
"""Concurrent E2B sandboxes one `harbor.backend = "e2b"` trial holds at once.
|
|
87
|
+
|
|
88
|
+
One, not two: terminus-2 runs inside the harbor process and drives the task environment over
|
|
89
|
+
tmux, so the only sandbox a trial holds is harbor's task environment. (The pi bridge needed a
|
|
90
|
+
second, pooled sandbox to host the harness process; that path is no longer used for
|
|
91
|
+
distillation rollouts.) Capacity planning (`wmo.cli.harness_distill`) multiplies by this."""
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _recorded_provider_config(config_path: Path) -> JsonObject | None:
|
|
95
|
+
"""The provider config a persisted harbor job config ran with, or None.
|
|
96
|
+
|
|
97
|
+
None means the file is unreadable or not shaped like a scorer-produced
|
|
98
|
+
JobConfig dump; callers leave those dirs alone so the scorer can raise its
|
|
99
|
+
own actionable error instead of evidence being destroyed silently.
|
|
100
|
+
"""
|
|
101
|
+
try:
|
|
102
|
+
payload = json.loads(config_path.read_text(encoding="utf-8"))
|
|
103
|
+
except (OSError, json.JSONDecodeError):
|
|
104
|
+
return None
|
|
105
|
+
if not isinstance(payload, dict):
|
|
106
|
+
return None
|
|
107
|
+
agents = payload.get("agents")
|
|
108
|
+
if not isinstance(agents, list) or not agents or not isinstance(agents[0], dict):
|
|
109
|
+
return None
|
|
110
|
+
kwargs = agents[0].get("kwargs")
|
|
111
|
+
if not isinstance(kwargs, dict):
|
|
112
|
+
return None
|
|
113
|
+
recorded = kwargs.get("provider_config")
|
|
114
|
+
return recorded if isinstance(recorded, dict) else None
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _wipe_stale_policy_dir(candidate_dir: Path, provider_config: ProviderConfig) -> bool:
|
|
118
|
+
"""Wipe a candidate job dir recorded under different provider weights.
|
|
119
|
+
|
|
120
|
+
Sampler paths carry a per-session nonce, so a job dir left by a previous
|
|
121
|
+
session (a crash mid-batch, then `--resume`) can never satisfy the
|
|
122
|
+
scorer's strict job-config resume check, and its completed trials sampled
|
|
123
|
+
a policy this session did not restore, so resuming them would mix
|
|
124
|
+
policies. Deleting the dir makes the batch re-run whole from the current
|
|
125
|
+
weights: correct, at the price of re-running that batch's completed
|
|
126
|
+
trials. The trials' recorded token spans go with it, which is right -
|
|
127
|
+
they were sampled from the policy being discarded.
|
|
128
|
+
|
|
129
|
+
A recorded provider that MATCHES the current one (the teacher's stable
|
|
130
|
+
identity, whose baseline eval legitimately resumes across sessions) is
|
|
131
|
+
left for harbor's native trial-level resume, as is an unreadable config
|
|
132
|
+
(the scorer raises its own actionable error for those).
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
candidate_dir: The scorer's deterministic per-candidate job dir.
|
|
136
|
+
provider_config: The provider the batch is about to sample.
|
|
137
|
+
|
|
138
|
+
Returns:
|
|
139
|
+
True when the directory was wiped.
|
|
140
|
+
"""
|
|
141
|
+
recorded = _recorded_provider_config(candidate_dir / "config.json")
|
|
142
|
+
if recorded is None or recorded == provider_config.model_dump(mode="json"):
|
|
143
|
+
return False
|
|
144
|
+
logger.warning(
|
|
145
|
+
"harbor job dir %s was produced under provider %r, not the current %r; "
|
|
146
|
+
"wiping it so the batch re-runs from the current weights instead of "
|
|
147
|
+
"resuming another policy's trials",
|
|
148
|
+
candidate_dir,
|
|
149
|
+
recorded.get("model"),
|
|
150
|
+
provider_config.model,
|
|
151
|
+
)
|
|
152
|
+
shutil.rmtree(candidate_dir)
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def terminus_2_agent_kwargs(cfg: DistillConfig, provider_config: ProviderConfig) -> JsonObject:
|
|
157
|
+
"""The harbor `AgentConfig.kwargs` that make terminus-2 sample the Tinker student.
|
|
158
|
+
|
|
159
|
+
Every value here is either a WMO config knob or an invariant the
|
|
160
|
+
tokens-in-tokens-out contract depends on:
|
|
161
|
+
|
|
162
|
+
- `llm_backend="tinker"` selects `harbor.llms.tinker.TinkerLLM`, and
|
|
163
|
+
`llm_kwargs.model_path` points it at the EXACT weights being sampled
|
|
164
|
+
(`provider_config.model`, a `tinker://.../sampler_weights/...` path).
|
|
165
|
+
The base model that names the renderer and tokenizer travels separately
|
|
166
|
+
as harbor's `AgentConfig.model_name` (`provider_config.model_type`),
|
|
167
|
+
because `TinkerLLM(model_name=...)` means the base model, not the
|
|
168
|
+
checkpoint. A provider whose `model` IS its `model_type` (the teacher
|
|
169
|
+
sampling a base model directly) sends no `model_path` at all.
|
|
170
|
+
- `collect_rollout_details=True` is what records `prompt_token_ids`,
|
|
171
|
+
`completion_token_ids` and `logprobs` per turn. Without it a trial
|
|
172
|
+
produces rewards and no training data at all.
|
|
173
|
+
- `enable_summarize` follows `rollout.compaction`, which defaults False and
|
|
174
|
+
which `RolloutConfig` only allows to be true under a history-editing
|
|
175
|
+
renderer. Off, a context overflow raises and the trial FAILS on a
|
|
176
|
+
recorded `ContextLengthExceededError` -- it does not score zero, it
|
|
177
|
+
leaves the denominator. On, terminus-2 compacts and the episode
|
|
178
|
+
continues; under a strip-history renderer that costs nothing structural,
|
|
179
|
+
because every real turn is still recorded (the summarization subagents
|
|
180
|
+
bypass the `Chat` entirely) and the episode was already one datum per
|
|
181
|
+
turn. See `RolloutConfig.compaction` for why this is not the blanket
|
|
182
|
+
rejection it used to be.
|
|
183
|
+
- `max_turns` is terminus-2's `max_episodes`, and
|
|
184
|
+
`llm_kwargs.context_limit` / `max_tokens` are the context and output
|
|
185
|
+
budgets the agent measures itself against.
|
|
186
|
+
- `llm_kwargs.renderer_name` is sent only when `rollout.renderers` names
|
|
187
|
+
one for THIS provider's base model, so a run whose teacher rollouts use
|
|
188
|
+
a different base model than the student still gets each model's own
|
|
189
|
+
renderer. The wmo verbatim renderers are registered with the cookbook
|
|
190
|
+
here, because this is the one call site every rollout path shares, and
|
|
191
|
+
terminus-2 resolves the name from that registry in this process.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
cfg: The validated run config.
|
|
195
|
+
provider_config: The provider the batch samples; must be the tinker
|
|
196
|
+
kind, since only Tinker sampling records token ids and logprobs.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
The kwargs dict, safe to pass as the scorer's `extra_agent_kwargs`
|
|
200
|
+
(it collides with none of the scorer-owned keys).
|
|
201
|
+
|
|
202
|
+
Raises:
|
|
203
|
+
ValueError: If the provider is not the tinker kind.
|
|
204
|
+
"""
|
|
205
|
+
if provider_config.kind is not ProviderKind.TINKER:
|
|
206
|
+
raise ValueError(
|
|
207
|
+
"distillation rollouts must sample through Tinker so the student's exact token "
|
|
208
|
+
f"spans are recorded, got provider kind {provider_config.kind.value!r}; configure "
|
|
209
|
+
"the worker provider with kind 'tinker'"
|
|
210
|
+
)
|
|
211
|
+
llm_kwargs: JsonObject = {
|
|
212
|
+
"max_tokens": cfg.sampling.max_tokens,
|
|
213
|
+
"context_limit": cfg.rollout.context_budget_tokens,
|
|
214
|
+
"output_limit": cfg.sampling.max_tokens,
|
|
215
|
+
}
|
|
216
|
+
if provider_config.model != provider_config.model_type:
|
|
217
|
+
llm_kwargs["model_path"] = provider_config.model
|
|
218
|
+
renderer_name = cfg.rollout.renderers.get(provider_config.model_type)
|
|
219
|
+
if renderer_name is not None:
|
|
220
|
+
llm_kwargs["renderer_name"] = renderer_name
|
|
221
|
+
# Terminus-2 resolves `renderer_name` through the cookbook's GLOBAL registry, inside the
|
|
222
|
+
# TinkerLLM it builds in this process. This is the single place every rollout path (training,
|
|
223
|
+
# warmup collection, eval waves) passes through, so registering the wmo verbatim renderers
|
|
224
|
+
# here is what makes a `[rollout.renderers]` entry naming one resolvable. The import is
|
|
225
|
+
# deferred because wmo.distill.renderers subclasses cookbook classes at module scope, and
|
|
226
|
+
# `import wmo.distill.rollouts` must keep working without the distill extra (the CLI imports
|
|
227
|
+
# it eagerly). Registration is idempotent.
|
|
228
|
+
from wmo.distill.renderers import register_wmo_renderers
|
|
229
|
+
|
|
230
|
+
register_wmo_renderers()
|
|
231
|
+
_share_harbor_tinker_service_client()
|
|
232
|
+
return {
|
|
233
|
+
"llm_backend": "tinker",
|
|
234
|
+
"llm_kwargs": llm_kwargs,
|
|
235
|
+
"collect_rollout_details": True,
|
|
236
|
+
"temperature": cfg.sampling.temperature,
|
|
237
|
+
"max_turns": cfg.rollout.max_turns,
|
|
238
|
+
"enable_summarize": cfg.rollout.compaction,
|
|
239
|
+
# The cap is deliberate here, so terminus-2's "consider removing this limit" warning is
|
|
240
|
+
# noise on every trial.
|
|
241
|
+
"suppress_max_turns_warning": True,
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
_HARBOR_TINKER_CLIENT_SHARED = False
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _share_harbor_tinker_service_client() -> None:
|
|
249
|
+
"""Make harbor's `TinkerLLM` reuse wmo's process-wide `ServiceClient`.
|
|
250
|
+
|
|
251
|
+
Harbor's `TinkerLLM._ensure_client` does `tinker.ServiceClient()` per
|
|
252
|
+
instance and never closes it, and terminus-2 builds one `TinkerLLM` per
|
|
253
|
+
TRIAL. The SDK's service client starts a heartbeat task that strongly
|
|
254
|
+
references its holder, so each one pins a live server-side session for the
|
|
255
|
+
rest of the process -- the exact leak `wmo.providers.tinker.
|
|
256
|
+
shared_service_client` exists to prevent for wmo's own call paths. Harbor's
|
|
257
|
+
class was the one path that still bypassed it.
|
|
258
|
+
|
|
259
|
+
Measured cost of not doing this, on a 64-episode-per-step run: step 0 clean,
|
|
260
|
+
step 1 three failures, step 2 THIRTY-ONE trials rejected with
|
|
261
|
+
`400 Too many active sessions. Please ensure you are not creating extra
|
|
262
|
+
ServiceClient objects`, halving that step's datums. The cap is ~240
|
|
263
|
+
sessions and each step burns 64, so the failure arrives on schedule and
|
|
264
|
+
worsens every step.
|
|
265
|
+
|
|
266
|
+
Only the client construction changes; the sampling-client selection below
|
|
267
|
+
it is harbor's own logic, reproduced exactly. Idempotent, and a no-op if
|
|
268
|
+
harbor's internals move -- a rollout that samples successfully matters more
|
|
269
|
+
than this optimization, so a shape mismatch leaves the original in place.
|
|
270
|
+
"""
|
|
271
|
+
global _HARBOR_TINKER_CLIENT_SHARED
|
|
272
|
+
if _HARBOR_TINKER_CLIENT_SHARED:
|
|
273
|
+
return
|
|
274
|
+
from harbor.llms.tinker import TinkerLLM
|
|
275
|
+
|
|
276
|
+
from wmo.providers import tinker as tinker_provider
|
|
277
|
+
|
|
278
|
+
if not hasattr(TinkerLLM, "_ensure_client"): # pragma: no cover - upstream moved
|
|
279
|
+
logger.warning(
|
|
280
|
+
"harbor's TinkerLLM has no _ensure_client; leaving its service-client "
|
|
281
|
+
"construction alone. Watch for '400 Too many active sessions' errors"
|
|
282
|
+
)
|
|
283
|
+
_HARBOR_TINKER_CLIENT_SHARED = True
|
|
284
|
+
return
|
|
285
|
+
|
|
286
|
+
async def _ensure_client_shared(self: object) -> object:
|
|
287
|
+
"""Harbor's `_ensure_client`, but on the shared service client."""
|
|
288
|
+
existing = getattr(self, "_sampling_client", None)
|
|
289
|
+
if existing is not None:
|
|
290
|
+
return existing
|
|
291
|
+
# Resolved through the MODULE, not captured at patch time, so
|
|
292
|
+
# `rebuild_shared_service_client()` (the wedge-recovery path) is honoured.
|
|
293
|
+
service = tinker_provider.shared_service_client()
|
|
294
|
+
self._service_client = service # ty: ignore[unresolved-attribute]
|
|
295
|
+
model_path = getattr(self, "_model_path", None)
|
|
296
|
+
if model_path:
|
|
297
|
+
client = await service.create_sampling_client_async(model_path=model_path)
|
|
298
|
+
else:
|
|
299
|
+
client = await service.create_sampling_client_async(
|
|
300
|
+
base_model=self._model_name # ty: ignore[unresolved-attribute]
|
|
301
|
+
)
|
|
302
|
+
self._sampling_client = client # ty: ignore[unresolved-attribute]
|
|
303
|
+
return client
|
|
304
|
+
|
|
305
|
+
TinkerLLM._ensure_client = _ensure_client_shared # ty: ignore[invalid-assignment]
|
|
306
|
+
_HARBOR_TINKER_CLIENT_SHARED = True
|
|
307
|
+
logger.info("harbor TinkerLLM patched to share wmo's process-wide tinker ServiceClient")
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
class RolloutStats(BaseModel):
|
|
311
|
+
"""Aggregate health metrics for one collected rollout batch."""
|
|
312
|
+
|
|
313
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
314
|
+
|
|
315
|
+
trials: int = Field(ge=0)
|
|
316
|
+
trials_with_spans: int = Field(ge=0)
|
|
317
|
+
solve_rate: float = Field(ge=0.0, le=1.0)
|
|
318
|
+
"""Fraction of EXECUTED trials whose verifier reward passed (metrics/gating signal).
|
|
319
|
+
|
|
320
|
+
Infrastructure failures are excluded from the denominator: a trial with no verifier evidence
|
|
321
|
+
behind its stand-in 0.0 is an UNKNOWN outcome, and counting it as a task failure biases this
|
|
322
|
+
rate in whichever direction the failures fell. Both directions have been measured: three Super
|
|
323
|
+
`student-before` baselines were reported as 0.0% from 51/51 rate-limited trials, and 2 of 48
|
|
324
|
+
TerminalBench-2 probe trials whose verifier timed out on submitted work held a probe at 20.8%
|
|
325
|
+
when its gradeable denominator was 46. 0.0 when nothing executed (`executed_trials == 0`),
|
|
326
|
+
which callers must treat as a null measurement rather than a score."""
|
|
327
|
+
|
|
328
|
+
graded_solve_rate: float = Field(default=0.0, ge=0.0, le=1.0)
|
|
329
|
+
"""Mean GRADED test-pass score over the trials that have one: the power metric beside
|
|
330
|
+
`solve_rate`.
|
|
331
|
+
|
|
332
|
+
Same trials, read at test resolution instead of the benchmark's one bit (see
|
|
333
|
+
`wmo.harness.scoring.GradedTests`). `solve_rate` stays the headline because binary IS the
|
|
334
|
+
benchmark's definition of success; this exists because a small binary holdout cannot resolve a
|
|
335
|
+
real effect. On the 48-episode TerminalBench-2 probe it reads 0.319 against a 0.217 binary solve
|
|
336
|
+
rate, and it moves 2 of 12 tasks off a flat 0.00 where no improvement could ever have shown up.
|
|
337
|
+
|
|
338
|
+
Denominator: trials that are NOT `infra_failed` AND carry a readable test report
|
|
339
|
+
(`graded_trials`). A verifier that timed out wrote no report either, so such a trial is excluded
|
|
340
|
+
here exactly as it is from `solve_rate`, never averaged in as 0.0. 0.0 when there are none,
|
|
341
|
+
which callers must read as a null measurement, not a score.
|
|
342
|
+
|
|
343
|
+
Coarse, not continuous: the probe's tasks carried 1 to 6 tests, so most trial scores are 0, 1/2,
|
|
344
|
+
or 1, and a single-test task is exactly as binary as its reward."""
|
|
345
|
+
|
|
346
|
+
graded_trials: int = Field(default=0, ge=0)
|
|
347
|
+
"""Gradeable trials that carried a readable test report: the `graded_solve_rate` denominator.
|
|
348
|
+
|
|
349
|
+
Below `executed_trials` means some graded trials produced a reward but no parseable test report,
|
|
350
|
+
so the two rates ran over different trial sets; the gap is what says so instead of a fabricated
|
|
351
|
+
0.0 hiding it."""
|
|
352
|
+
|
|
353
|
+
raw_solve_rate: float = Field(ge=0.0, le=1.0)
|
|
354
|
+
"""Passing trials over ALL trials, infra failures included.
|
|
355
|
+
|
|
356
|
+
What the training path actually optimizes against (`missing_reward="zero"` makes a dead trial a
|
|
357
|
+
failed trial for advantage estimation), kept alongside `solve_rate` so the two can be compared
|
|
358
|
+
instead of one silently standing in for the other."""
|
|
359
|
+
|
|
360
|
+
executed_trials: int = Field(ge=0)
|
|
361
|
+
"""Trials that produced verifier evidence (`trials` minus `infra_failed_trials`)."""
|
|
362
|
+
|
|
363
|
+
infra_failed_trials: int = Field(ge=0)
|
|
364
|
+
"""Trials with no verifier evidence, so no measured outcome.
|
|
365
|
+
|
|
366
|
+
One count over two causes, which share this denominator treatment but not their diagnosis:
|
|
367
|
+
the agent never ran (sandbox creation, rate limit, transport death) or the agent ran and was
|
|
368
|
+
never graded (verifier timeout, unwritten/unparseable reward file). The per-cell note the
|
|
369
|
+
scorer writes (`infra-failure: <exception type>; ...`) is what tells them apart."""
|
|
370
|
+
|
|
371
|
+
empty_span_trials: int = Field(ge=0)
|
|
372
|
+
"""Trials that recorded no token span (died before the first completion)."""
|
|
373
|
+
|
|
374
|
+
truncated_spans: int = Field(default=0, ge=0)
|
|
375
|
+
"""Turns that sampled the full output cap, so the model was cut off mid-answer.
|
|
376
|
+
|
|
377
|
+
Nothing upstream reports this. Harbor's `TinkerLLM` means to
|
|
378
|
+
(`harbor/llms/tinker.py:243` raises `OutputLengthExceededError` when a
|
|
379
|
+
response hit `max_tokens`), but it gates on `not parse_success`, and
|
|
380
|
+
`parse_success` is a `ParseTermination` StrEnum whose members are ALL
|
|
381
|
+
non-empty strings and therefore ALL truthy. The guard can never fire, so a
|
|
382
|
+
turn cut off at the cap flows on as an ordinary turn: the agent reads a
|
|
383
|
+
half-written action, the parser reports an error, and the episode continues
|
|
384
|
+
as if the model had simply answered badly.
|
|
385
|
+
|
|
386
|
+
A truncated turn also has no stop token, so it cannot be replayed verbatim
|
|
387
|
+
(`wmo.distill.renderers`) and shows up downstream as a prefix break, which
|
|
388
|
+
means a fragmented episode and multiplied teacher-scoring cost. Counted per
|
|
389
|
+
SPAN, since one bad turn does not spoil the episode's other turns; a rising
|
|
390
|
+
count means `sampling.max_tokens` is too low for the reasoning these tasks
|
|
391
|
+
provoke, not that the model got worse.
|
|
392
|
+
|
|
393
|
+
0 on records assembled before this field existed."""
|
|
394
|
+
|
|
395
|
+
truncated_span_trials: int = Field(default=0, ge=0)
|
|
396
|
+
"""Trials carrying at least one `truncated_spans` turn: the episode-level view."""
|
|
397
|
+
|
|
398
|
+
stop_reason_counts: dict[str, int] = Field(default_factory=dict)
|
|
399
|
+
"""How many trials ended on each recorded stop reason (`"unknown"` when no trace was readable).
|
|
400
|
+
|
|
401
|
+
The per-reason breakdown behind `scaffold_loss_rate`: `max_turns` (turn cap), `budget` (wall
|
|
402
|
+
clock), `no_tool_call`, `output_truncated`, `unparsed_tool_call`, `provider_error`, and
|
|
403
|
+
`submitted`."""
|
|
404
|
+
|
|
405
|
+
scaffold_loss_rate: float = Field(ge=0.0, le=1.0)
|
|
406
|
+
"""Share of trials WITH A READABLE STOP REASON that never reached an explicit `submit`.
|
|
407
|
+
|
|
408
|
+
The headline number of the pi/Nemotron-3 scaffold audit: it was 88.8% for Super and 92.2% for
|
|
409
|
+
Ultra, every one of those trials scored reward 0, and nothing in the metrics surfaced it. An
|
|
410
|
+
episode the harness cut off measures where the guillotine fell, not what the model can do, so
|
|
411
|
+
this belongs beside every solve rate. 0.0 when no trial reported a stop reason.
|
|
412
|
+
|
|
413
|
+
Deliberately NOT the `executed` denominator that `solve_rate` uses, because the two answer
|
|
414
|
+
different questions. "Did the harness cut this episode off?" is answered by the stop reason
|
|
415
|
+
alone and needs no verifier; "did the model solve it?" needs a grade. An episode that reached
|
|
416
|
+
`submit` and then had its VERIFIER time out is a scaffold SUCCESS whose task outcome is
|
|
417
|
+
unknown, so it belongs in this denominator (as a non-loss) while being excluded from
|
|
418
|
+
`solve_rate`. Sharing one denominator conflated them and inflated this rate: on the 48-episode
|
|
419
|
+
probe it read 15.22% over 46 gradeable trials when the true scaffold loss was 14.58% (7 of 48
|
|
420
|
+
episodes reported a non-submit stop reason). A trial with no readable trace at all has no
|
|
421
|
+
stop reason and is excluded from both."""
|
|
422
|
+
|
|
423
|
+
# TODO: surface TokenRecorder.fallback_count (incremental-prompt re-renders) here;
|
|
424
|
+
# it needs the per-trial span sink format to carry the counter across the
|
|
425
|
+
# agent-process boundary, so it is not trivially wireable today.
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
UNKNOWN_STOP_REASON = "unknown"
|
|
429
|
+
"""Stop-reason bucket for a trial whose run trace was missing or unreadable."""
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def rollout_stats(records: Sequence[TrialRecord], *, max_tokens: int) -> RolloutStats:
|
|
433
|
+
"""The batch health stats over a set of assembled trial records.
|
|
434
|
+
|
|
435
|
+
A pure function of the records and the output cap they sampled under, so a
|
|
436
|
+
batch loaded back from persisted trial records (the warmup-trials manifest)
|
|
437
|
+
reports the same stats its original collection did.
|
|
438
|
+
|
|
439
|
+
Args:
|
|
440
|
+
records: The batch's trial records.
|
|
441
|
+
max_tokens: The per-turn output cap the batch sampled under
|
|
442
|
+
(`sampling.max_tokens`); a span that reached it was cut off, which
|
|
443
|
+
nothing upstream reports (see `RolloutStats.truncated_spans`). Pass
|
|
444
|
+
THIS run's cap when re-reading another run's records: the count
|
|
445
|
+
then answers "would these turns be truncated here", which is what
|
|
446
|
+
the reader of this run's metrics needs.
|
|
447
|
+
|
|
448
|
+
Returns:
|
|
449
|
+
The aggregate stats. An empty batch, and a batch where every trial was
|
|
450
|
+
an infrastructure failure, both report a 0.0 solve rate over zero
|
|
451
|
+
executed trials: those are null measurements, and the counts are what
|
|
452
|
+
distinguish them. `scaffold_loss_rate` runs over its own denominator
|
|
453
|
+
(trials that reported a stop reason), so a batch whose agents all ran
|
|
454
|
+
but whose verifiers all failed still reports a real scaffold rate.
|
|
455
|
+
`graded_solve_rate` runs over `graded_trials` (gradeable trials with a
|
|
456
|
+
readable test report) and is 0.0 over zero of them, likewise a null
|
|
457
|
+
measurement rather than a score.
|
|
458
|
+
"""
|
|
459
|
+
with_spans = sum(1 for record in records if record.spans)
|
|
460
|
+
truncated_per_record = [
|
|
461
|
+
sum(1 for span in record.spans if len(span.sampled_token_ids) >= max_tokens)
|
|
462
|
+
for record in records
|
|
463
|
+
]
|
|
464
|
+
executed = [record for record in records if not record.infra_failed]
|
|
465
|
+
# The graded rate's own denominator: gradeable trials whose verifier also left a readable test
|
|
466
|
+
# report. A missing report is an absent measurement, so it is excluded rather than scored 0.0,
|
|
467
|
+
# the same rule that keeps an ungradeable trial out of `solve_rate`.
|
|
468
|
+
graded = [record.graded_score for record in executed if record.graded_score is not None]
|
|
469
|
+
counts: dict[str, int] = {}
|
|
470
|
+
for record in records:
|
|
471
|
+
key = record.stop_reason or UNKNOWN_STOP_REASON
|
|
472
|
+
counts[key] = counts.get(key, 0) + 1
|
|
473
|
+
# The scaffold question ("did the harness cut this off?") is answered by the stop reason and
|
|
474
|
+
# needs no verifier, so it gets its own denominator: every trial that reported one. Sharing
|
|
475
|
+
# `executed` with solve_rate dropped submit-then-ungradeable episodes out of a rate they
|
|
476
|
+
# belong in as successes, which inflated it.
|
|
477
|
+
with_stop_reason = [record for record in records if record.stop_reason]
|
|
478
|
+
submitted = sum(
|
|
479
|
+
1 for record in with_stop_reason if record.stop_reason == StopReason.SUBMITTED.value
|
|
480
|
+
)
|
|
481
|
+
return RolloutStats(
|
|
482
|
+
trials=len(records),
|
|
483
|
+
trials_with_spans=with_spans,
|
|
484
|
+
solve_rate=(
|
|
485
|
+
sum(1 for record in executed if record.passed) / len(executed) if executed else 0.0
|
|
486
|
+
),
|
|
487
|
+
graded_solve_rate=(sum(graded) / len(graded) if graded else 0.0),
|
|
488
|
+
graded_trials=len(graded),
|
|
489
|
+
raw_solve_rate=(
|
|
490
|
+
sum(1 for record in records if record.passed) / len(records) if records else 0.0
|
|
491
|
+
),
|
|
492
|
+
executed_trials=len(executed),
|
|
493
|
+
infra_failed_trials=len(records) - len(executed),
|
|
494
|
+
empty_span_trials=len(records) - with_spans,
|
|
495
|
+
truncated_spans=sum(truncated_per_record),
|
|
496
|
+
truncated_span_trials=sum(1 for count in truncated_per_record if count),
|
|
497
|
+
stop_reason_counts=dict(sorted(counts.items())),
|
|
498
|
+
scaffold_loss_rate=(
|
|
499
|
+
(len(with_stop_reason) - submitted) / len(with_stop_reason) if with_stop_reason else 0.0
|
|
500
|
+
),
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
def _with_agent_wall_budget(template: JobConfig, episode_timeout_s: float) -> JobConfig:
|
|
505
|
+
"""Return the job template with the episode wall budget as harbor's agent timeout.
|
|
506
|
+
|
|
507
|
+
Terminus-2 has no wall clock of its own (the WMO pi bridge enforced
|
|
508
|
+
`episode_timeout_sec` itself), so the budget has to be harbor's: harbor
|
|
509
|
+
runs `agent.run()` under `asyncio.wait_for(..., agent_timeout_sec)`,
|
|
510
|
+
derived from `AgentConfig.override_timeout_sec` when set and the task's
|
|
511
|
+
own declared agent timeout otherwise. Overriding it here makes every
|
|
512
|
+
rollout and eval wave honor `rollout.episode_timeout_s` instead of
|
|
513
|
+
whatever each task happens to declare.
|
|
514
|
+
|
|
515
|
+
`max_timeout_sec` is cleared alongside it, because harbor resolves the
|
|
516
|
+
effective budget as `min(base, max) * multiplier` and a template-provided
|
|
517
|
+
ceiling would silently shorten a deliberately raised budget.
|
|
518
|
+
|
|
519
|
+
The scorer's template validation permits both fields (it owns agent
|
|
520
|
+
identity, model, skills, env and kwargs, not the timeouts), so this is a
|
|
521
|
+
supported use of the seam rather than a bypass.
|
|
522
|
+
|
|
523
|
+
Args:
|
|
524
|
+
template: The validated harbor `JobConfig`.
|
|
525
|
+
episode_timeout_s: The per-episode wall budget in seconds.
|
|
526
|
+
|
|
527
|
+
Returns:
|
|
528
|
+
A revalidated `JobConfig` whose template agents carry the budget.
|
|
529
|
+
"""
|
|
530
|
+
from harbor.models.job.config import JobConfig
|
|
531
|
+
from harbor.models.trial.config import AgentConfig
|
|
532
|
+
|
|
533
|
+
agents = []
|
|
534
|
+
for agent in template.agents:
|
|
535
|
+
agent_fields = {name: getattr(agent, name) for name in AgentConfig.model_fields}
|
|
536
|
+
agent_fields["override_timeout_sec"] = episode_timeout_s
|
|
537
|
+
agent_fields["max_timeout_sec"] = None
|
|
538
|
+
agents.append(AgentConfig(**agent_fields))
|
|
539
|
+
job_fields = {name: getattr(template, name) for name in JobConfig.model_fields}
|
|
540
|
+
job_fields["agents"] = agents
|
|
541
|
+
return JobConfig(**job_fields)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def collect_rollouts(
|
|
545
|
+
step_index: int,
|
|
546
|
+
task_ids: Sequence[str],
|
|
547
|
+
cfg: DistillConfig,
|
|
548
|
+
harness: HarnessDoc,
|
|
549
|
+
provider_config: ProviderConfig,
|
|
550
|
+
run_dir: Path,
|
|
551
|
+
*,
|
|
552
|
+
should_cancel: Callable[[], bool] | None = None,
|
|
553
|
+
) -> tuple[list[TrialRecord], RolloutStats]:
|
|
554
|
+
"""Run one train batch of harbor trials and join rewards with token spans.
|
|
555
|
+
|
|
556
|
+
Each task in `task_ids` runs `cfg.train.group_size` attempts (the
|
|
557
|
+
on-policy group), every trial running harbor's terminus-2 agent against
|
|
558
|
+
the Tinker student, with the per-turn token ids harbor records in the
|
|
559
|
+
trial's `result.json` joined back as the trial's spans.
|
|
560
|
+
|
|
561
|
+
This is a synchronous entry point, mirroring how the CLI drives the
|
|
562
|
+
scorer: the async `HarborScorer.create` runs on its own loop and the
|
|
563
|
+
(task x attempts) batch runs inside the blocking `score` call.
|
|
564
|
+
|
|
565
|
+
Args:
|
|
566
|
+
step_index: 0-based training step; keys the fresh jobs dir.
|
|
567
|
+
task_ids: Exact task ids for this batch (a subset of the train split).
|
|
568
|
+
cfg: The validated run config (harbor template/backend/reward key,
|
|
569
|
+
group size, trial concurrency).
|
|
570
|
+
harness: The document whose hash keys this candidate's harbor job dir.
|
|
571
|
+
Terminus-2 runs its own scaffold, so the document no longer
|
|
572
|
+
steers the agent; the rollout knobs pinned into it
|
|
573
|
+
(`pin_rollout_params`) still change the job identity, which is
|
|
574
|
+
what keeps a config change from resuming another config's trials.
|
|
575
|
+
provider_config: The student provider config; `model` must point at
|
|
576
|
+
the CURRENT sampler weights (`tinker://` path) and `model_type`
|
|
577
|
+
at the base model naming the renderer/tokenizer.
|
|
578
|
+
run_dir: The distillation run directory.
|
|
579
|
+
should_cancel: Optional cooperative cancellation poll, forwarded to
|
|
580
|
+
the harbor runner.
|
|
581
|
+
|
|
582
|
+
Returns:
|
|
583
|
+
The `TrialRecord`s (one per task x attempt, in report order) and the
|
|
584
|
+
batch stats. A trial whose `result.json` records no rollout details
|
|
585
|
+
is kept with empty spans and counted in `empty_span_trials`, never
|
|
586
|
+
dropped: its reward is real batch signal and dropping it would
|
|
587
|
+
silently bias the solve rate.
|
|
588
|
+
|
|
589
|
+
Raises:
|
|
590
|
+
ValueError: If `step_index` is negative, the harbor job template
|
|
591
|
+
cannot be loaded/validated, or the provider is not the tinker kind.
|
|
592
|
+
ImportError: If the harbor extra is not installed.
|
|
593
|
+
"""
|
|
594
|
+
if step_index < 0:
|
|
595
|
+
raise ValueError(f"step_index must be >= 0, got {step_index}")
|
|
596
|
+
try:
|
|
597
|
+
import yaml
|
|
598
|
+
from harbor.models.job.config import JobConfig
|
|
599
|
+
|
|
600
|
+
from wmo.evals.harbor.scorer import HarborScorer
|
|
601
|
+
except ImportError as error:
|
|
602
|
+
raise ImportError(MISSING_HARBOR_EXTRA) from error
|
|
603
|
+
|
|
604
|
+
step_name = f"step-{step_index:04d}"
|
|
605
|
+
jobs_dir = run_dir / "harbor" / step_name
|
|
606
|
+
|
|
607
|
+
template_path = Path(cfg.harbor.job_template)
|
|
608
|
+
try:
|
|
609
|
+
raw = yaml.safe_load(template_path.read_text(encoding="utf-8"))
|
|
610
|
+
except (OSError, yaml.YAMLError) as error:
|
|
611
|
+
raise ValueError(
|
|
612
|
+
f"cannot load the harbor job template from {template_path}: {error}; point "
|
|
613
|
+
"harbor.job_template at a harbor JobConfig YAML/JSON file"
|
|
614
|
+
) from error
|
|
615
|
+
if not isinstance(raw, dict):
|
|
616
|
+
raise ValueError(f"the harbor job template in {template_path} must be a mapping")
|
|
617
|
+
try:
|
|
618
|
+
template = JobConfig.model_validate({**raw, "jobs_dir": str(jobs_dir)})
|
|
619
|
+
except ValueError as error:
|
|
620
|
+
raise ValueError(
|
|
621
|
+
f"invalid harbor job template {template_path}: {error}; fix the template "
|
|
622
|
+
"so it validates as a harbor JobConfig"
|
|
623
|
+
) from error
|
|
624
|
+
template = _with_agent_wall_budget(template, cfg.rollout.episode_timeout_s)
|
|
625
|
+
|
|
626
|
+
backend = cfg.harbor.backend
|
|
627
|
+
scorer = asyncio.run(
|
|
628
|
+
HarborScorer.create(
|
|
629
|
+
template,
|
|
630
|
+
task_ids,
|
|
631
|
+
provider_config=provider_config,
|
|
632
|
+
reward_key=cfg.harbor.reward_key,
|
|
633
|
+
attempts=cfg.train.group_size,
|
|
634
|
+
task_environment="e2b" if backend == "e2b" else "docker",
|
|
635
|
+
harness_backend=backend,
|
|
636
|
+
# Terminus-2 runs in this process, but the scorer's local backend still pins
|
|
637
|
+
# concurrency to 1 (that guard belongs to the pi runner's shared runner dir);
|
|
638
|
+
# e2b parallelizes up to the configured trial concurrency.
|
|
639
|
+
agent_concurrency=1 if backend == "local" else cfg.train.trial_concurrency,
|
|
640
|
+
harbor_retries=cfg.harbor.retries,
|
|
641
|
+
agent_import_path=HARBOR_TERMINUS_2_AGENT_IMPORT_PATH,
|
|
642
|
+
extra_agent_kwargs=terminus_2_agent_kwargs(cfg, provider_config),
|
|
643
|
+
# TinkerLLM reads the base model, not the checkpoint, to pick its renderer and
|
|
644
|
+
# tokenizer; the checkpoint travels as llm_kwargs.model_path.
|
|
645
|
+
agent_model_name=provider_config.model_type,
|
|
646
|
+
# Kept for the scorer's own bookkeeping and the pi bridge; terminus-2 takes its
|
|
647
|
+
# wall budget from harbor's agent-phase timeout (set on the template above) and
|
|
648
|
+
# its context budget from llm_kwargs.context_limit.
|
|
649
|
+
episode_timeout_s=cfg.rollout.episode_timeout_s,
|
|
650
|
+
context_window=cfg.rollout.context_budget_tokens,
|
|
651
|
+
# A trial with no verifier evidence (the agent never ran, or the verifier never
|
|
652
|
+
# graded it) keeps a stand-in 0.0 so advantage estimation stays defined, and is
|
|
653
|
+
# flagged `infra_failed` so it never enters a reported solve rate. Never a reason
|
|
654
|
+
# to abort the run.
|
|
655
|
+
missing_reward="zero",
|
|
656
|
+
)
|
|
657
|
+
)
|
|
658
|
+
_wipe_stale_policy_dir(scorer.candidate_job_dir(harness), provider_config)
|
|
659
|
+
logger.info(
|
|
660
|
+
"collecting rollouts for %s: %d task(s) x %d attempt(s), backend %s -> %s",
|
|
661
|
+
step_name,
|
|
662
|
+
len(task_ids),
|
|
663
|
+
cfg.train.group_size,
|
|
664
|
+
backend,
|
|
665
|
+
jobs_dir,
|
|
666
|
+
)
|
|
667
|
+
report = scorer.score(harness, should_cancel=should_cancel)
|
|
668
|
+
records = assemble_harbor_trial_records(report.cells, max_turns=cfg.rollout.max_turns)
|
|
669
|
+
stats = rollout_stats(records, max_tokens=cfg.sampling.max_tokens)
|
|
670
|
+
if stats.truncated_spans:
|
|
671
|
+
logger.warning(
|
|
672
|
+
"%d turn(s) across %d/%d trial(s) in %s sampled the full sampling.max_tokens = %d "
|
|
673
|
+
"and were cut off mid-answer; harbor cannot report this (its OutputLengthExceededError "
|
|
674
|
+
"guard is unreachable), so the agent read a half-written action and the episode "
|
|
675
|
+
"continued as if the model had answered badly. A truncated turn also cannot be "
|
|
676
|
+
"replayed verbatim, so it fragments the episode; raise sampling.max_tokens",
|
|
677
|
+
stats.truncated_spans,
|
|
678
|
+
stats.truncated_span_trials,
|
|
679
|
+
stats.trials,
|
|
680
|
+
step_name,
|
|
681
|
+
cfg.sampling.max_tokens,
|
|
682
|
+
)
|
|
683
|
+
if stats.empty_span_trials:
|
|
684
|
+
logger.warning(
|
|
685
|
+
"%d/%d trial(s) in %s recorded no token spans in their result.json (the agent "
|
|
686
|
+
"died before its first student completion, or harbor logged no rollout details); "
|
|
687
|
+
"they carry reward signal but no training data",
|
|
688
|
+
stats.empty_span_trials,
|
|
689
|
+
stats.trials,
|
|
690
|
+
step_name,
|
|
691
|
+
)
|
|
692
|
+
if stats.infra_failed_trials:
|
|
693
|
+
logger.warning(
|
|
694
|
+
"%d/%d trial(s) in %s never produced verifier evidence (no sandbox, a rate limit, a "
|
|
695
|
+
"dead transport, or a VERIFIER that never graded the work); they are EXCLUDED from "
|
|
696
|
+
"the reported solve rate (%d executed) and carry a stand-in 0.0 reward for advantage "
|
|
697
|
+
"estimation only; grep the cells' `infra-failure:` notes for the exact causes",
|
|
698
|
+
stats.infra_failed_trials,
|
|
699
|
+
stats.trials,
|
|
700
|
+
step_name,
|
|
701
|
+
stats.executed_trials,
|
|
702
|
+
)
|
|
703
|
+
if stats.graded_trials < stats.executed_trials:
|
|
704
|
+
logger.warning(
|
|
705
|
+
"%d/%d gradeable trial(s) in %s carry a verifier reward but no readable CTRF test "
|
|
706
|
+
"report, so they are EXCLUDED from the graded solve rate (%.3f over %d trial(s)) "
|
|
707
|
+
"instead of counted as 0.0; the binary solve rate still covers all %d",
|
|
708
|
+
stats.executed_trials - stats.graded_trials,
|
|
709
|
+
stats.executed_trials,
|
|
710
|
+
step_name,
|
|
711
|
+
stats.graded_solve_rate,
|
|
712
|
+
stats.graded_trials,
|
|
713
|
+
stats.executed_trials,
|
|
714
|
+
)
|
|
715
|
+
if stats.scaffold_loss_rate > 0:
|
|
716
|
+
logger.warning(
|
|
717
|
+
"scaffold loss rate %.1f%% in %s: %d/%d executed trial(s) never reached an "
|
|
718
|
+
"explicit submit (stop reasons %s); those rewards measure where the harness cut "
|
|
719
|
+
"the episode off, not model capability",
|
|
720
|
+
100.0 * stats.scaffold_loss_rate,
|
|
721
|
+
step_name,
|
|
722
|
+
round(stats.scaffold_loss_rate * stats.executed_trials),
|
|
723
|
+
stats.executed_trials,
|
|
724
|
+
stats.stop_reason_counts,
|
|
725
|
+
)
|
|
726
|
+
return records, stats
|