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/data.py
ADDED
|
@@ -0,0 +1,921 @@
|
|
|
1
|
+
"""Prefix-merge datum builder: harbor trials to advantage-weighted training data.
|
|
2
|
+
|
|
3
|
+
`build_datums` turns each trial's recorded `TokenSpan`s into `TrainDatum`s
|
|
4
|
+
with the tinker-cookbook's `trajectory_to_data` merge semantics: as long as
|
|
5
|
+
each call's prompt extends the accumulated episode tokens (previous prompt
|
|
6
|
+
plus previous sampled tokens) verbatim as a list prefix, the whole episode is
|
|
7
|
+
ONE datum, so every context token is prefilled exactly once. A call whose
|
|
8
|
+
prompt is not such an extension starts a new datum (a fragment), which is
|
|
9
|
+
correct but re-prefills shared context; the fragmentation rate is therefore a
|
|
10
|
+
first-class cost metric, not a curiosity.
|
|
11
|
+
|
|
12
|
+
Alignment is sacred: episodes are never truncated (that would desynchronize
|
|
13
|
+
tokens from their sampled logprobs), only dropped whole and counted, both for
|
|
14
|
+
context overflow (any call measured over `rollout.context_budget_tokens`, or
|
|
15
|
+
a run trace marked with the overflow stop reason) and for merged lengths over
|
|
16
|
+
`train.max_datum_tokens`.
|
|
17
|
+
|
|
18
|
+
`attach_advantages` fills the reverse-KL advantages from teacher logprobs,
|
|
19
|
+
and `to_tinker_datums` converts to real `tinker.Datum`s in the shifted
|
|
20
|
+
next-token layout the cookbook uses (one wire format for both
|
|
21
|
+
advantage-weighted losses, `importance_sampling` and `ppo`);
|
|
22
|
+
`to_tinker_sft_datums` is the
|
|
23
|
+
cross_entropy sibling for the supervised warmup phase (teacher-sampled
|
|
24
|
+
trajectories need no advantages, only the loss-mask weights). For the
|
|
25
|
+
`topk_ce` loss, `build_topk_ce_datums` turns teacher top-k candidate rows
|
|
26
|
+
into rank-aligned replica datums (see its docstring for why replication is
|
|
27
|
+
per RANK, not per position) and `to_tinker_topk_ce_datums` is its one-call
|
|
28
|
+
wire conversion through the same pinned cross_entropy keyset. The tinker SDK
|
|
29
|
+
is an optional extra and is imported lazily inside those converters only,
|
|
30
|
+
mirroring the provider's contract; everything else here runs without it.
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
from __future__ import annotations
|
|
34
|
+
|
|
35
|
+
import logging
|
|
36
|
+
import math
|
|
37
|
+
from collections.abc import Sequence
|
|
38
|
+
from typing import TYPE_CHECKING
|
|
39
|
+
|
|
40
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
41
|
+
|
|
42
|
+
from wmo.distill.config import DistillConfig
|
|
43
|
+
from wmo.distill.tokens import TrialRecord
|
|
44
|
+
from wmo.providers.tinker import TokenSpan
|
|
45
|
+
|
|
46
|
+
if TYPE_CHECKING:
|
|
47
|
+
import tinker
|
|
48
|
+
|
|
49
|
+
logger = logging.getLogger(__name__)
|
|
50
|
+
|
|
51
|
+
MISSING_TINKER_EXTRA = (
|
|
52
|
+
"the tinker SDK is not installed; run `uv sync --extra distill` to convert "
|
|
53
|
+
"training datums for a real Tinker training client"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
TopkCandidates = list[tuple[int, float]]
|
|
57
|
+
"""One position's teacher top-k: (token_id, logprob) pairs, best first."""
|
|
58
|
+
|
|
59
|
+
CONTEXT_OVERFLOW_STOP_REASON = "context_overflow"
|
|
60
|
+
"""A run-trace stop reason marking an episode that outgrew the context budget.
|
|
61
|
+
|
|
62
|
+
Overflowed episodes are dropped from training: their final turns were shaped
|
|
63
|
+
by the budget cutoff rather than the policy, and their cost profile is exactly
|
|
64
|
+
the blowup the prefix property exists to prevent. The enforcement itself is
|
|
65
|
+
measured from the recorded spans (any call whose prompt plus sampled tokens
|
|
66
|
+
exceeds `rollout.context_budget_tokens`); the stop-reason marker is honored
|
|
67
|
+
additionally for runtimes that end episodes at the budget themselves.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class TrainDatum(BaseModel):
|
|
72
|
+
"""One merged training sequence in the UNshifted layout, fully aligned.
|
|
73
|
+
|
|
74
|
+
`model_input_tokens` is the complete episode-fragment token sequence:
|
|
75
|
+
context deltas (prompt tokens the agent appended between completions)
|
|
76
|
+
interleaved with sampled spans, in order. The three per-token lists are
|
|
77
|
+
aligned one to one with it; `to_tinker_datums` produces the shifted
|
|
78
|
+
next-token layout, so this model stays trivially inspectable.
|
|
79
|
+
"""
|
|
80
|
+
|
|
81
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
82
|
+
|
|
83
|
+
trial_name: str = Field(min_length=1)
|
|
84
|
+
"""The harbor trial this datum came from, for loud per-datum diagnostics."""
|
|
85
|
+
|
|
86
|
+
fragment_index: int = Field(ge=0)
|
|
87
|
+
"""0-based index of this datum within its episode; > 0 means a prefix break."""
|
|
88
|
+
|
|
89
|
+
model_input_tokens: list[int]
|
|
90
|
+
"""The full unshifted token sequence (context deltas plus sampled spans)."""
|
|
91
|
+
|
|
92
|
+
loss_mask: list[float]
|
|
93
|
+
"""Per-token loss mask: 1.0 on sampled tokens, 0.0 on context tokens."""
|
|
94
|
+
|
|
95
|
+
sampled_logprobs: list[float]
|
|
96
|
+
"""Sampler-assigned logprob per token; 0.0 at mask-0 (context) positions."""
|
|
97
|
+
|
|
98
|
+
advantages: list[float] = Field(default_factory=list)
|
|
99
|
+
"""Per-token advantages; empty until `attach_advantages` fills them."""
|
|
100
|
+
|
|
101
|
+
target_tokens: list[int] = Field(default_factory=list)
|
|
102
|
+
"""Per-position training targets when they differ from the sequence's own
|
|
103
|
+
tokens; empty for ordinary datums (targets are the next tokens of
|
|
104
|
+
`model_input_tokens`). Set only on topk-CE replicas, where each loss
|
|
105
|
+
position's target is a teacher candidate rather than the realized token
|
|
106
|
+
(`build_topk_ce_datums`); non-loss positions keep the realized token."""
|
|
107
|
+
|
|
108
|
+
target_weights: list[float] = Field(default_factory=list)
|
|
109
|
+
"""Per-position cross_entropy weights replacing the loss mask on the wire;
|
|
110
|
+
empty for ordinary datums (the loss mask is the weight). Set only on
|
|
111
|
+
topk-CE replicas: the renormalized teacher probability of this replica's
|
|
112
|
+
candidate at each loss position, 0.0 everywhere else."""
|
|
113
|
+
|
|
114
|
+
@model_validator(mode="after")
|
|
115
|
+
def _check_alignment(self) -> TrainDatum:
|
|
116
|
+
"""Reject any misalignment between the token sequence and its per-token lists."""
|
|
117
|
+
n = len(self.model_input_tokens)
|
|
118
|
+
if len(self.loss_mask) != n or len(self.sampled_logprobs) != n:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"loss_mask length {len(self.loss_mask)} and sampled_logprobs length "
|
|
121
|
+
f"{len(self.sampled_logprobs)} must both match model_input_tokens "
|
|
122
|
+
f"length {n}"
|
|
123
|
+
)
|
|
124
|
+
if any(value not in (0.0, 1.0) for value in self.loss_mask):
|
|
125
|
+
raise ValueError("loss_mask values must be exactly 0.0 or 1.0")
|
|
126
|
+
if self.advantages and len(self.advantages) != n:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f"advantages length {len(self.advantages)} must match "
|
|
129
|
+
f"model_input_tokens length {n} (or be empty before attach_advantages)"
|
|
130
|
+
)
|
|
131
|
+
if bool(self.target_tokens) != bool(self.target_weights):
|
|
132
|
+
raise ValueError(
|
|
133
|
+
"target_tokens and target_weights must be set together (a topk-CE "
|
|
134
|
+
"replica carries both) or both left empty"
|
|
135
|
+
)
|
|
136
|
+
if self.target_tokens and (len(self.target_tokens) != n or len(self.target_weights) != n):
|
|
137
|
+
raise ValueError(
|
|
138
|
+
f"target_tokens length {len(self.target_tokens)} and target_weights "
|
|
139
|
+
f"length {len(self.target_weights)} must both match "
|
|
140
|
+
f"model_input_tokens length {n}"
|
|
141
|
+
)
|
|
142
|
+
if self.target_weights and any(
|
|
143
|
+
weight != 0.0 and mask != 1.0
|
|
144
|
+
for weight, mask in zip(self.target_weights, self.loss_mask, strict=True)
|
|
145
|
+
):
|
|
146
|
+
raise ValueError(
|
|
147
|
+
"target_weights may be nonzero only at loss positions (mask 1.0); a "
|
|
148
|
+
"weighted context position would train on tokens the student never "
|
|
149
|
+
"sampled"
|
|
150
|
+
)
|
|
151
|
+
return self
|
|
152
|
+
|
|
153
|
+
@property
|
|
154
|
+
def is_topk_replica(self) -> bool:
|
|
155
|
+
"""Whether this datum is a topk-CE replica (candidate targets attached)."""
|
|
156
|
+
return bool(self.target_tokens)
|
|
157
|
+
|
|
158
|
+
@property
|
|
159
|
+
def loss_token_count(self) -> int:
|
|
160
|
+
"""How many positions carry loss (mask 1.0)."""
|
|
161
|
+
return sum(1 for value in self.loss_mask if value == 1.0)
|
|
162
|
+
|
|
163
|
+
def sampled_token_ids(self) -> list[int]:
|
|
164
|
+
"""The sampled (mask 1.0) token ids, in sequence order."""
|
|
165
|
+
return [
|
|
166
|
+
token
|
|
167
|
+
for token, mask in zip(self.model_input_tokens, self.loss_mask, strict=True)
|
|
168
|
+
if mask == 1.0
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class DatumStats(BaseModel):
|
|
173
|
+
"""Accounting for one `build_datums` call (drops are episodes, not datums)."""
|
|
174
|
+
|
|
175
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
176
|
+
|
|
177
|
+
datums: int = Field(ge=0)
|
|
178
|
+
fragments: int = Field(ge=0)
|
|
179
|
+
"""Datums beyond the first of their episode (each one is a prefix break)."""
|
|
180
|
+
|
|
181
|
+
fragmentation_rate: float = Field(ge=0.0, le=1.0)
|
|
182
|
+
"""fragments / datums; every fragment re-prefills context, so this is cost."""
|
|
183
|
+
|
|
184
|
+
overflow_drops: int = Field(ge=0)
|
|
185
|
+
"""Trials dropped for context overflow (measured or stop-reason marked)."""
|
|
186
|
+
|
|
187
|
+
overlong_drops: int = Field(ge=0)
|
|
188
|
+
"""Episodes dropped whole because a merged datum exceeded max_datum_tokens."""
|
|
189
|
+
|
|
190
|
+
loss_tokens: int = Field(ge=0)
|
|
191
|
+
context_tokens: int = Field(ge=0)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
class AdvantageStats(BaseModel):
|
|
195
|
+
"""Accounting for one `attach_advantages` call.
|
|
196
|
+
|
|
197
|
+
Token-level counters cover only the ATTACHED datums: a datum dropped for
|
|
198
|
+
a teacher mismatch is never trained on, so its tokens are not signal and
|
|
199
|
+
contribute to no counter here.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
203
|
+
|
|
204
|
+
datums: int = Field(ge=0)
|
|
205
|
+
"""Datums that came out with advantages attached."""
|
|
206
|
+
|
|
207
|
+
mismatch_drops: int = Field(ge=0)
|
|
208
|
+
"""Datums dropped because their teacher logprobs did not align."""
|
|
209
|
+
|
|
210
|
+
clipped_tokens: int = Field(ge=0)
|
|
211
|
+
"""Loss tokens whose raw advantage hit the clip bound (attached datums
|
|
212
|
+
only); always 0 when `train.advantage_clip` is None (clipping off)."""
|
|
213
|
+
|
|
214
|
+
loss_tokens: int = Field(ge=0)
|
|
215
|
+
"""Loss tokens across the attached datums (the clip-fraction denominator)."""
|
|
216
|
+
|
|
217
|
+
context_tokens: int = Field(ge=0)
|
|
218
|
+
"""Non-loss (mask 0.0) tokens across the attached datums.
|
|
219
|
+
|
|
220
|
+
Reported beside `loss_tokens` so a caller can describe the TRAINED batch's
|
|
221
|
+
token volume without falling back to the pre-drop `DatumStats`, whose
|
|
222
|
+
counts include datums this call dropped.
|
|
223
|
+
"""
|
|
224
|
+
|
|
225
|
+
advantage_mean: float | None
|
|
226
|
+
"""Mean advantage over the attached datums' loss tokens, exactly as
|
|
227
|
+
trained (after any clipping and any centering); None when no loss token
|
|
228
|
+
survived.
|
|
229
|
+
|
|
230
|
+
Under the default objective (no clipping, no centering) this is the mean
|
|
231
|
+
teacher-minus-student logprob gap, i.e. the negated reverse KL over the
|
|
232
|
+
trained tokens: the direct read of how far the student still is from the
|
|
233
|
+
teacher, and the number that should shrink as the run works. Under
|
|
234
|
+
`train.center_advantages` it is ~0.0 by construction and says nothing."""
|
|
235
|
+
|
|
236
|
+
advantage_std: float | None
|
|
237
|
+
"""Population standard deviation over the same loss tokens; None when none."""
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _is_prefix(prefix: list[int], sequence: list[int]) -> bool:
|
|
241
|
+
"""Whether `prefix` equals the start of `sequence` (cookbook semantics)."""
|
|
242
|
+
return len(prefix) <= len(sequence) and sequence[: len(prefix)] == prefix
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def _merge_trial_spans(trial_name: str, spans: Sequence[TokenSpan]) -> list[TrainDatum]:
|
|
246
|
+
"""Merge one trial's spans into datums, breaking on every non-prefix prompt.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
trial_name: The trial identity stamped onto each produced datum.
|
|
250
|
+
spans: The trial's recorded spans, in any order (sorted by call_index).
|
|
251
|
+
|
|
252
|
+
Returns:
|
|
253
|
+
The episode's datums in order: one when every prompt extended the
|
|
254
|
+
accumulated tokens as a prefix, more when the history was edited. A
|
|
255
|
+
fragment containing no sampled tokens (possible only when a span
|
|
256
|
+
sampled nothing) carries no trainable signal and is skipped.
|
|
257
|
+
"""
|
|
258
|
+
datums: list[TrainDatum] = []
|
|
259
|
+
tokens: list[int] = []
|
|
260
|
+
mask: list[float] = []
|
|
261
|
+
logprobs: list[float] = []
|
|
262
|
+
|
|
263
|
+
def flush() -> None:
|
|
264
|
+
if not tokens:
|
|
265
|
+
return
|
|
266
|
+
if not any(value == 1.0 for value in mask):
|
|
267
|
+
logger.debug(
|
|
268
|
+
"skipping a fragment of trial %s with no sampled tokens (%d context tokens)",
|
|
269
|
+
trial_name,
|
|
270
|
+
len(tokens),
|
|
271
|
+
)
|
|
272
|
+
else:
|
|
273
|
+
datums.append(
|
|
274
|
+
TrainDatum(
|
|
275
|
+
trial_name=trial_name,
|
|
276
|
+
fragment_index=len(datums),
|
|
277
|
+
model_input_tokens=list(tokens),
|
|
278
|
+
loss_mask=list(mask),
|
|
279
|
+
sampled_logprobs=list(logprobs),
|
|
280
|
+
)
|
|
281
|
+
)
|
|
282
|
+
tokens.clear()
|
|
283
|
+
mask.clear()
|
|
284
|
+
logprobs.clear()
|
|
285
|
+
|
|
286
|
+
for span in sorted(spans, key=lambda item: item.call_index):
|
|
287
|
+
prompt = span.prompt_token_ids
|
|
288
|
+
if tokens and _is_prefix(tokens, prompt):
|
|
289
|
+
delta = prompt[len(tokens) :]
|
|
290
|
+
else:
|
|
291
|
+
flush()
|
|
292
|
+
delta = prompt
|
|
293
|
+
tokens.extend(delta)
|
|
294
|
+
mask.extend([0.0] * len(delta))
|
|
295
|
+
logprobs.extend([0.0] * len(delta))
|
|
296
|
+
tokens.extend(span.sampled_token_ids)
|
|
297
|
+
mask.extend([1.0] * len(span.sampled_token_ids))
|
|
298
|
+
logprobs.extend(span.sampled_logprobs)
|
|
299
|
+
flush()
|
|
300
|
+
return datums
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def build_datums(
|
|
304
|
+
records: Sequence[TrialRecord], cfg: DistillConfig
|
|
305
|
+
) -> tuple[list[TrainDatum], DatumStats]:
|
|
306
|
+
"""Build training datums from scored trials via the prefix merge.
|
|
307
|
+
|
|
308
|
+
Per trial: spans are sorted by call_index and merged into one datum while
|
|
309
|
+
each span's prompt extends the accumulated tokens (previous prompt plus
|
|
310
|
+
previous sampled tokens) exactly as a list prefix; the appended context
|
|
311
|
+
delta gets loss mask 0.0 and sampled tokens get 1.0. A non-prefix span
|
|
312
|
+
starts a new datum (fragment). Two whole-episode drops protect alignment
|
|
313
|
+
and cost, never truncation:
|
|
314
|
+
|
|
315
|
+
- context overflow: any recorded call whose prompt plus sampled tokens
|
|
316
|
+
exceeds `cfg.rollout.context_budget_tokens` (measured from the spans),
|
|
317
|
+
or a trial whose stop reason is `CONTEXT_OVERFLOW_STOP_REASON`;
|
|
318
|
+
- episodes where any merged datum exceeds `cfg.train.max_datum_tokens`.
|
|
319
|
+
|
|
320
|
+
Trials with no spans contribute nothing here; the rollout collector
|
|
321
|
+
already counts them (`RolloutStats.empty_span_trials`).
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
records: The scored trials for one training step.
|
|
325
|
+
cfg: The run config; reads `rollout.context_budget_tokens` and
|
|
326
|
+
`train.max_datum_tokens`.
|
|
327
|
+
|
|
328
|
+
Returns:
|
|
329
|
+
The kept datums (trial order, then fragment order) and the stats.
|
|
330
|
+
"""
|
|
331
|
+
datums: list[TrainDatum] = []
|
|
332
|
+
fragments = 0
|
|
333
|
+
overflow_drops = 0
|
|
334
|
+
overlong_drops = 0
|
|
335
|
+
context_budget = cfg.rollout.context_budget_tokens
|
|
336
|
+
for record in records:
|
|
337
|
+
if record.stop_reason == CONTEXT_OVERFLOW_STOP_REASON:
|
|
338
|
+
overflow_drops += 1
|
|
339
|
+
logger.warning(
|
|
340
|
+
"dropping trial %s from training: the episode overflowed the rollout "
|
|
341
|
+
"context budget (stop reason %r)",
|
|
342
|
+
record.trial_name,
|
|
343
|
+
record.stop_reason,
|
|
344
|
+
)
|
|
345
|
+
continue
|
|
346
|
+
if not record.spans:
|
|
347
|
+
continue
|
|
348
|
+
peak_context = max(
|
|
349
|
+
len(span.prompt_token_ids) + len(span.sampled_token_ids) for span in record.spans
|
|
350
|
+
)
|
|
351
|
+
if peak_context > context_budget:
|
|
352
|
+
overflow_drops += 1
|
|
353
|
+
logger.warning(
|
|
354
|
+
"dropping trial %s from training: its largest call consumed %d tokens, "
|
|
355
|
+
"over rollout.context_budget_tokens = %d (episodes are dropped whole, "
|
|
356
|
+
"never truncated, so tokens stay aligned with their sampled logprobs)",
|
|
357
|
+
record.trial_name,
|
|
358
|
+
peak_context,
|
|
359
|
+
context_budget,
|
|
360
|
+
)
|
|
361
|
+
continue
|
|
362
|
+
episode = _merge_trial_spans(record.trial_name, record.spans)
|
|
363
|
+
longest = max((len(datum.model_input_tokens) for datum in episode), default=0)
|
|
364
|
+
if longest > cfg.train.max_datum_tokens:
|
|
365
|
+
overlong_drops += 1
|
|
366
|
+
logger.warning(
|
|
367
|
+
"dropping trial %s from training: a merged datum has %d tokens, over "
|
|
368
|
+
"train.max_datum_tokens = %d (episodes are dropped whole, never "
|
|
369
|
+
"truncated, so tokens stay aligned with their sampled logprobs)",
|
|
370
|
+
record.trial_name,
|
|
371
|
+
longest,
|
|
372
|
+
cfg.train.max_datum_tokens,
|
|
373
|
+
)
|
|
374
|
+
continue
|
|
375
|
+
datums.extend(episode)
|
|
376
|
+
fragments += max(0, len(episode) - 1)
|
|
377
|
+
loss_tokens = sum(datum.loss_token_count for datum in datums)
|
|
378
|
+
context_tokens = sum(len(datum.model_input_tokens) for datum in datums) - loss_tokens
|
|
379
|
+
stats = DatumStats(
|
|
380
|
+
datums=len(datums),
|
|
381
|
+
fragments=fragments,
|
|
382
|
+
fragmentation_rate=fragments / len(datums) if datums else 0.0,
|
|
383
|
+
overflow_drops=overflow_drops,
|
|
384
|
+
overlong_drops=overlong_drops,
|
|
385
|
+
loss_tokens=loss_tokens,
|
|
386
|
+
context_tokens=context_tokens,
|
|
387
|
+
)
|
|
388
|
+
if stats.fragments:
|
|
389
|
+
# Not always a fault. A history-editing renderer (wmo/qwen3_5_strip_history) drops prior
|
|
390
|
+
# turns' reasoning by design, which breaks the prefix property on purpose and makes every
|
|
391
|
+
# turn its own datum -- a near-1.0 rate is the EXPECTED reading there, not a regression.
|
|
392
|
+
# The message says what it costs and lets the reader judge, rather than asserting a bug:
|
|
393
|
+
# the old wording ("check the harness prefix pins") sent a reader hunting for a break that
|
|
394
|
+
# the configuration had deliberately chosen.
|
|
395
|
+
logger.warning(
|
|
396
|
+
"%d of %d datum(s) are fragments (fragmentation rate %.2f): turns are scored "
|
|
397
|
+
"separately rather than merged, so shared context is re-prefilled per turn and "
|
|
398
|
+
"teacher scoring cost rises with turn count. Expected under a history-editing "
|
|
399
|
+
"renderer; unexpected under a verbatim one, where it means the prefix property "
|
|
400
|
+
"broke and the harness pins need checking",
|
|
401
|
+
stats.fragments,
|
|
402
|
+
stats.datums,
|
|
403
|
+
stats.fragmentation_rate,
|
|
404
|
+
)
|
|
405
|
+
return datums, stats
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def attach_advantages(
|
|
409
|
+
datums: Sequence[TrainDatum],
|
|
410
|
+
teacher_logprobs: Sequence[Sequence[float | None]],
|
|
411
|
+
cfg: DistillConfig,
|
|
412
|
+
) -> tuple[list[TrainDatum], AdvantageStats]:
|
|
413
|
+
"""Fill per-token reverse-KL advantages from teacher logprobs.
|
|
414
|
+
|
|
415
|
+
Each loss token gets `teacher_lp - sampled_lp`, bounded to
|
|
416
|
+
`+-train.advantage_clip` when that bound is set (None, the default,
|
|
417
|
+
trains the raw gap and clips nothing, which is what `train.loss = "ppo"`
|
|
418
|
+
expects: the ratio clip inside the loss is the regularizer); context
|
|
419
|
+
(mask 0.0) tokens get 0.0. With `train.center_advantages` the mean over
|
|
420
|
+
ALL loss tokens in the batch is then subtracted from every loss token, so
|
|
421
|
+
the batch-mean advantage is zero; without it (the default) the raw gaps
|
|
422
|
+
ride through and `AdvantageStats.advantage_mean` reads the objective.
|
|
423
|
+
|
|
424
|
+
`teacher_logprobs[i]` scores `datums[i].model_input_tokens` in the
|
|
425
|
+
compute_logprobs convention: entry p is the logprob of token p given the
|
|
426
|
+
tokens before it, and entry 0 is None (no context). A datum whose teacher
|
|
427
|
+
entry has the wrong length, or a None at a loss position, is dropped
|
|
428
|
+
loudly and counted; a silently misaligned advantage would train on noise.
|
|
429
|
+
|
|
430
|
+
Args:
|
|
431
|
+
datums: Datums from `build_datums` (advantages not yet attached).
|
|
432
|
+
teacher_logprobs: One per-position logprob list per datum, aligned
|
|
433
|
+
one to one with `datums`.
|
|
434
|
+
cfg: The run config; reads `train.advantage_clip` (None = no
|
|
435
|
+
clipping) and `train.center_advantages`.
|
|
436
|
+
|
|
437
|
+
Returns:
|
|
438
|
+
New datums with advantages attached (drops removed, order preserved)
|
|
439
|
+
and the stats, including the trained advantage distribution (mean and
|
|
440
|
+
population std over the attached datums' loss tokens) and the kept
|
|
441
|
+
clip/loss token counts the loop derives `clip_fraction` from (no
|
|
442
|
+
token counts as clipped when clipping is off).
|
|
443
|
+
|
|
444
|
+
Raises:
|
|
445
|
+
ValueError: If `teacher_logprobs` does not have one entry per datum;
|
|
446
|
+
that is a caller bug, not per-datum evidence, so nothing is
|
|
447
|
+
dropped for it.
|
|
448
|
+
"""
|
|
449
|
+
if len(teacher_logprobs) != len(datums):
|
|
450
|
+
raise ValueError(
|
|
451
|
+
f"got {len(teacher_logprobs)} teacher logprob list(s) for {len(datums)} "
|
|
452
|
+
"datum(s); pass exactly one per-position list per datum, in datum order"
|
|
453
|
+
)
|
|
454
|
+
clip = cfg.train.advantage_clip
|
|
455
|
+
kept: list[TrainDatum] = []
|
|
456
|
+
per_datum_advantages: list[list[float]] = []
|
|
457
|
+
mismatch_drops = 0
|
|
458
|
+
clipped_tokens = 0
|
|
459
|
+
for datum, teacher in zip(datums, teacher_logprobs, strict=True):
|
|
460
|
+
n = len(datum.model_input_tokens)
|
|
461
|
+
if len(teacher) != n:
|
|
462
|
+
mismatch_drops += 1
|
|
463
|
+
logger.warning(
|
|
464
|
+
"dropping datum (trial %s, fragment %d) from training: teacher "
|
|
465
|
+
"returned %d logprob(s) for %d token(s); the teacher must score the "
|
|
466
|
+
"datum's exact token sequence with one entry per position",
|
|
467
|
+
datum.trial_name,
|
|
468
|
+
datum.fragment_index,
|
|
469
|
+
len(teacher),
|
|
470
|
+
n,
|
|
471
|
+
)
|
|
472
|
+
continue
|
|
473
|
+
advantages = [0.0] * n
|
|
474
|
+
datum_clipped = 0
|
|
475
|
+
missing_position: int | None = None
|
|
476
|
+
for position in range(n):
|
|
477
|
+
if datum.loss_mask[position] != 1.0:
|
|
478
|
+
continue
|
|
479
|
+
teacher_lp = teacher[position]
|
|
480
|
+
if teacher_lp is None:
|
|
481
|
+
missing_position = position
|
|
482
|
+
break
|
|
483
|
+
raw = teacher_lp - datum.sampled_logprobs[position]
|
|
484
|
+
if clip is None:
|
|
485
|
+
advantages[position] = raw
|
|
486
|
+
continue
|
|
487
|
+
clipped = min(max(raw, -clip), clip)
|
|
488
|
+
if clipped != raw:
|
|
489
|
+
datum_clipped += 1
|
|
490
|
+
advantages[position] = clipped
|
|
491
|
+
if missing_position is not None:
|
|
492
|
+
mismatch_drops += 1
|
|
493
|
+
logger.warning(
|
|
494
|
+
"dropping datum (trial %s, fragment %d) from training: teacher "
|
|
495
|
+
"logprob at loss position %d is None; the teacher must return a "
|
|
496
|
+
"logprob for every sampled position",
|
|
497
|
+
datum.trial_name,
|
|
498
|
+
datum.fragment_index,
|
|
499
|
+
missing_position,
|
|
500
|
+
)
|
|
501
|
+
continue
|
|
502
|
+
kept.append(datum)
|
|
503
|
+
per_datum_advantages.append(advantages)
|
|
504
|
+
clipped_tokens += datum_clipped
|
|
505
|
+
if cfg.train.center_advantages:
|
|
506
|
+
loss_count = sum(datum.loss_token_count for datum in kept)
|
|
507
|
+
if loss_count:
|
|
508
|
+
total = sum(
|
|
509
|
+
advantages[position]
|
|
510
|
+
for datum, advantages in zip(kept, per_datum_advantages, strict=True)
|
|
511
|
+
for position in range(len(advantages))
|
|
512
|
+
if datum.loss_mask[position] == 1.0
|
|
513
|
+
)
|
|
514
|
+
mean = total / loss_count
|
|
515
|
+
for datum, advantages in zip(kept, per_datum_advantages, strict=True):
|
|
516
|
+
for position in range(len(advantages)):
|
|
517
|
+
if datum.loss_mask[position] == 1.0:
|
|
518
|
+
advantages[position] -= mean
|
|
519
|
+
attached = [
|
|
520
|
+
TrainDatum(
|
|
521
|
+
trial_name=datum.trial_name,
|
|
522
|
+
fragment_index=datum.fragment_index,
|
|
523
|
+
model_input_tokens=datum.model_input_tokens,
|
|
524
|
+
loss_mask=datum.loss_mask,
|
|
525
|
+
sampled_logprobs=datum.sampled_logprobs,
|
|
526
|
+
advantages=advantages,
|
|
527
|
+
)
|
|
528
|
+
for datum, advantages in zip(kept, per_datum_advantages, strict=True)
|
|
529
|
+
]
|
|
530
|
+
# The advantage distribution actually trained on: loss tokens of the kept
|
|
531
|
+
# datums, after clipping and any centering.
|
|
532
|
+
loss_values = [
|
|
533
|
+
advantages[position]
|
|
534
|
+
for datum, advantages in zip(kept, per_datum_advantages, strict=True)
|
|
535
|
+
for position in range(len(advantages))
|
|
536
|
+
if datum.loss_mask[position] == 1.0
|
|
537
|
+
]
|
|
538
|
+
advantage_mean: float | None = None
|
|
539
|
+
advantage_std: float | None = None
|
|
540
|
+
if loss_values:
|
|
541
|
+
advantage_mean = sum(loss_values) / len(loss_values)
|
|
542
|
+
variance = sum((value - advantage_mean) ** 2 for value in loss_values) / len(loss_values)
|
|
543
|
+
advantage_std = math.sqrt(variance)
|
|
544
|
+
stats = AdvantageStats(
|
|
545
|
+
datums=len(attached),
|
|
546
|
+
mismatch_drops=mismatch_drops,
|
|
547
|
+
clipped_tokens=clipped_tokens,
|
|
548
|
+
loss_tokens=len(loss_values),
|
|
549
|
+
context_tokens=sum(len(datum.model_input_tokens) for datum in attached) - len(loss_values),
|
|
550
|
+
advantage_mean=advantage_mean,
|
|
551
|
+
advantage_std=advantage_std,
|
|
552
|
+
)
|
|
553
|
+
return attached, stats
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
class TopkCeStats(BaseModel):
|
|
557
|
+
"""Accounting for one `build_topk_ce_datums` call.
|
|
558
|
+
|
|
559
|
+
Token counters cover only the KEPT source datums: a source dropped for a
|
|
560
|
+
misaligned or unscoreable top-k row is never trained on.
|
|
561
|
+
"""
|
|
562
|
+
|
|
563
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
564
|
+
|
|
565
|
+
source_datums: int = Field(ge=0)
|
|
566
|
+
"""Source datums that survived and were replicated."""
|
|
567
|
+
|
|
568
|
+
datums: int = Field(ge=0)
|
|
569
|
+
"""Replica datums emitted (`source_datums x k`)."""
|
|
570
|
+
|
|
571
|
+
mismatch_drops: int = Field(ge=0)
|
|
572
|
+
"""Source datums dropped because their top-k row did not align (wrong
|
|
573
|
+
length, or no candidates at a loss position)."""
|
|
574
|
+
|
|
575
|
+
loss_tokens: int = Field(ge=0)
|
|
576
|
+
"""Loss positions across the kept source datums (per copy, not x k)."""
|
|
577
|
+
|
|
578
|
+
context_tokens: int = Field(ge=0)
|
|
579
|
+
"""Non-loss positions across the kept source datums (per copy, not x k).
|
|
580
|
+
|
|
581
|
+
Same role as `AdvantageStats.context_tokens`: the TRAINED batch's context
|
|
582
|
+
volume, so the loop never has to reach back to the pre-drop `DatumStats`.
|
|
583
|
+
"""
|
|
584
|
+
|
|
585
|
+
|
|
586
|
+
def _renormalized_probs(candidates: TopkCandidates) -> list[float]:
|
|
587
|
+
"""Softmax the candidates' logprobs over just the top-k support.
|
|
588
|
+
|
|
589
|
+
The teacher's top-k logprobs are from the FULL vocabulary distribution,
|
|
590
|
+
so they sum to less than 1; renormalizing over the k candidates makes the
|
|
591
|
+
per-position replica weights a proper distribution (they sum to 1 across
|
|
592
|
+
the k copies), keeping the weighted-CE objective's per-position scale
|
|
593
|
+
independent of how much mass the tail held.
|
|
594
|
+
"""
|
|
595
|
+
peak = max(logprob for _, logprob in candidates)
|
|
596
|
+
exps = [math.exp(logprob - peak) for _, logprob in candidates]
|
|
597
|
+
total = sum(exps)
|
|
598
|
+
return [value / total for value in exps]
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def build_topk_ce_datums(
|
|
602
|
+
datums: Sequence[TrainDatum],
|
|
603
|
+
topk_rows: Sequence[Sequence[TopkCandidates | None]],
|
|
604
|
+
k: int,
|
|
605
|
+
) -> tuple[list[TrainDatum], TopkCeStats]:
|
|
606
|
+
"""Build rank-aligned topk-CE replica datums from teacher top-k rows.
|
|
607
|
+
|
|
608
|
+
The weighted-CE objective is per POSITION: at each loss position p the
|
|
609
|
+
loss is `sum_j w_j(p) * CE(target=candidate_j(p))` with `w_j(p)` the
|
|
610
|
+
renormalized teacher probability of candidate j at p. Emitting one
|
|
611
|
+
single-target datum per (position, candidate) would compute exactly that
|
|
612
|
+
but explode the datum count (loss_tokens x k datums, each re-prefilling
|
|
613
|
+
the whole sequence). Instead each source datum becomes k RANK-ALIGNED
|
|
614
|
+
replicas sharing the model input: replica j uses, at EVERY loss position,
|
|
615
|
+
the j-th ranked candidate as target with its renormalized probability as
|
|
616
|
+
weight (replica 0 is the teacher's top choice everywhere, and so on).
|
|
617
|
+
Because cross_entropy is a per-position sum of `weight * -logprob(target)`
|
|
618
|
+
with no cross-position coupling, summing the k replicas reproduces the
|
|
619
|
+
identical per-position weighted-CE objective with k forward passes
|
|
620
|
+
instead of k per position; the weights at each loss position sum to 1
|
|
621
|
+
across the replicas.
|
|
622
|
+
|
|
623
|
+
Positions where the teacher returned fewer than k candidates pad the
|
|
624
|
+
missing ranks with the realized token at weight 0.0 (no loss, and the
|
|
625
|
+
remaining ranks' weights still renormalize over the AVAILABLE candidates,
|
|
626
|
+
so the position keeps unit total weight). Non-loss positions carry the
|
|
627
|
+
realized next token at weight 0.0 in every replica. Candidates are
|
|
628
|
+
sorted best-first defensively (logprob descending, token id ascending on
|
|
629
|
+
ties) so rank is well defined regardless of backend ordering.
|
|
630
|
+
|
|
631
|
+
Alignment failures drop the SOURCE datum loudly and are counted, exactly
|
|
632
|
+
like `attach_advantages`: a wrong-length row, a None at a loss position
|
|
633
|
+
(including an unscoreable loss token at position 0), or an empty
|
|
634
|
+
candidate list at a loss position.
|
|
635
|
+
|
|
636
|
+
Args:
|
|
637
|
+
datums: Datums from `build_datums` (advantages not needed).
|
|
638
|
+
topk_rows: One per-position row per datum, aligned one to one with
|
|
639
|
+
`datums`; entry p is the teacher's top-k candidates for token p
|
|
640
|
+
when p is a scoreable loss position and None everywhere else
|
|
641
|
+
(`TeacherClient.score_topk`).
|
|
642
|
+
k: The replica count (`train.topk`); rows may carry at most k
|
|
643
|
+
candidates per position.
|
|
644
|
+
|
|
645
|
+
Returns:
|
|
646
|
+
The replica datums (source order, then rank order) and the stats.
|
|
647
|
+
|
|
648
|
+
Raises:
|
|
649
|
+
ValueError: If `k < 1`, `topk_rows` does not have one entry per
|
|
650
|
+
datum, or a position carries more than k candidates; those are
|
|
651
|
+
caller bugs, not per-datum evidence, so nothing is dropped.
|
|
652
|
+
"""
|
|
653
|
+
if k < 1:
|
|
654
|
+
raise ValueError(f"k must be >= 1, got {k}")
|
|
655
|
+
if len(topk_rows) != len(datums):
|
|
656
|
+
raise ValueError(
|
|
657
|
+
f"got {len(topk_rows)} top-k row(s) for {len(datums)} datum(s); pass "
|
|
658
|
+
"exactly one per-position row per datum, in datum order"
|
|
659
|
+
)
|
|
660
|
+
replicas: list[TrainDatum] = []
|
|
661
|
+
mismatch_drops = 0
|
|
662
|
+
kept_sources = 0
|
|
663
|
+
loss_tokens = 0
|
|
664
|
+
context_tokens = 0
|
|
665
|
+
for datum, row in zip(datums, topk_rows, strict=True):
|
|
666
|
+
n = len(datum.model_input_tokens)
|
|
667
|
+
if len(row) != n:
|
|
668
|
+
mismatch_drops += 1
|
|
669
|
+
logger.warning(
|
|
670
|
+
"dropping datum (trial %s, fragment %d) from training: teacher "
|
|
671
|
+
"returned %d top-k entrie(s) for %d token(s); the teacher must "
|
|
672
|
+
"score the datum's exact token sequence with one entry per position",
|
|
673
|
+
datum.trial_name,
|
|
674
|
+
datum.fragment_index,
|
|
675
|
+
len(row),
|
|
676
|
+
n,
|
|
677
|
+
)
|
|
678
|
+
continue
|
|
679
|
+
bad_position = next(
|
|
680
|
+
(
|
|
681
|
+
position
|
|
682
|
+
for position in range(n)
|
|
683
|
+
if datum.loss_mask[position] == 1.0 and not row[position]
|
|
684
|
+
),
|
|
685
|
+
None,
|
|
686
|
+
)
|
|
687
|
+
if bad_position is not None:
|
|
688
|
+
mismatch_drops += 1
|
|
689
|
+
logger.warning(
|
|
690
|
+
"dropping datum (trial %s, fragment %d) from training: the teacher "
|
|
691
|
+
"top-k row has no candidates at loss position %d (position 0 can "
|
|
692
|
+
"never be scored; elsewhere the teacher must return candidates for "
|
|
693
|
+
"every sampled position)",
|
|
694
|
+
datum.trial_name,
|
|
695
|
+
datum.fragment_index,
|
|
696
|
+
bad_position,
|
|
697
|
+
)
|
|
698
|
+
continue
|
|
699
|
+
oversized = next((position for position in range(n) if len(row[position] or []) > k), None)
|
|
700
|
+
if oversized is not None:
|
|
701
|
+
raise ValueError(
|
|
702
|
+
f"top-k row for datum (trial {datum.trial_name}, fragment "
|
|
703
|
+
f"{datum.fragment_index}) carries {len(row[oversized] or [])} "
|
|
704
|
+
f"candidates at position {oversized}, more than k = {k}; score with "
|
|
705
|
+
"the same k the replication uses"
|
|
706
|
+
)
|
|
707
|
+
per_position: list[tuple[list[int], list[float]]] = []
|
|
708
|
+
for position in range(n):
|
|
709
|
+
candidates = row[position]
|
|
710
|
+
if datum.loss_mask[position] != 1.0 or not candidates:
|
|
711
|
+
per_position.append(([datum.model_input_tokens[position]] * k, [0.0] * k))
|
|
712
|
+
continue
|
|
713
|
+
ranked = sorted(candidates, key=lambda entry: (-entry[1], entry[0]))
|
|
714
|
+
weights = _renormalized_probs(ranked)
|
|
715
|
+
tokens = [token for token, _ in ranked]
|
|
716
|
+
pad = k - len(ranked)
|
|
717
|
+
per_position.append(
|
|
718
|
+
(
|
|
719
|
+
tokens + [datum.model_input_tokens[position]] * pad,
|
|
720
|
+
weights + [0.0] * pad,
|
|
721
|
+
)
|
|
722
|
+
)
|
|
723
|
+
kept_sources += 1
|
|
724
|
+
loss_tokens += datum.loss_token_count
|
|
725
|
+
context_tokens += n - datum.loss_token_count
|
|
726
|
+
for rank in range(k):
|
|
727
|
+
replicas.append(
|
|
728
|
+
TrainDatum(
|
|
729
|
+
trial_name=datum.trial_name,
|
|
730
|
+
fragment_index=datum.fragment_index,
|
|
731
|
+
model_input_tokens=datum.model_input_tokens,
|
|
732
|
+
loss_mask=datum.loss_mask,
|
|
733
|
+
sampled_logprobs=datum.sampled_logprobs,
|
|
734
|
+
target_tokens=[tokens[rank] for tokens, _ in per_position],
|
|
735
|
+
target_weights=[weights[rank] for _, weights in per_position],
|
|
736
|
+
)
|
|
737
|
+
)
|
|
738
|
+
stats = TopkCeStats(
|
|
739
|
+
source_datums=kept_sources,
|
|
740
|
+
datums=len(replicas),
|
|
741
|
+
mismatch_drops=mismatch_drops,
|
|
742
|
+
loss_tokens=loss_tokens,
|
|
743
|
+
context_tokens=context_tokens,
|
|
744
|
+
)
|
|
745
|
+
return replicas, stats
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def to_tinker_datums(train_datums: Sequence[TrainDatum]) -> list[tinker.Datum]:
|
|
749
|
+
"""Convert attached datums to real `tinker.Datum`s for the advantage losses.
|
|
750
|
+
|
|
751
|
+
One wire format serves both `importance_sampling` and `ppo`: the SDK
|
|
752
|
+
(tinker 0.23.3) lists both in `types.LossFnType`, its
|
|
753
|
+
`forward_backward(data, loss_fn, loss_fn_config)` takes the same
|
|
754
|
+
`Datum`s for either, and the cookbook's RL path submits exactly this
|
|
755
|
+
keyset under both (`tinker_cookbook/rl/train.py:train_step` strips
|
|
756
|
+
"mask" and passes `loss_fn` straight through, and its own docstring
|
|
757
|
+
names `"importance_sampling"` and `"ppo"` as the values). `ppo` differs
|
|
758
|
+
only server-side, in what the loss does with the advantages: it bounds
|
|
759
|
+
the update by clipping the policy ratio (Tinker's default epsilon; this
|
|
760
|
+
code sends no `loss_fn_config`) rather than trusting a bounded advantage.
|
|
761
|
+
|
|
762
|
+
Produces the cookbook's shifted next-token layout: model input is every
|
|
763
|
+
token but the last, and target_tokens, logprobs, and advantages are the
|
|
764
|
+
per-token lists with position 0 dropped, so index j scores predicting
|
|
765
|
+
token j+1 from tokens <= j. The loss mask is folded into the advantages
|
|
766
|
+
(0.0 at context positions); the live loss rejects a separate "mask" key.
|
|
767
|
+
|
|
768
|
+
Args:
|
|
769
|
+
train_datums: Datums with advantages attached (`attach_advantages`).
|
|
770
|
+
|
|
771
|
+
Returns:
|
|
772
|
+
One `tinker.Datum` per input datum, in order, with loss_fn_inputs
|
|
773
|
+
exactly target_tokens (int64), logprobs, and advantages (float32).
|
|
774
|
+
|
|
775
|
+
Raises:
|
|
776
|
+
ImportError: If the tinker SDK is not installed (distill extra).
|
|
777
|
+
ValueError: If a datum has no advantages attached or is too short for
|
|
778
|
+
the input/target shift (fewer than 2 tokens).
|
|
779
|
+
"""
|
|
780
|
+
try:
|
|
781
|
+
import tinker
|
|
782
|
+
except ImportError as exc:
|
|
783
|
+
raise ImportError(MISSING_TINKER_EXTRA) from exc
|
|
784
|
+
|
|
785
|
+
out: list[tinker.Datum] = []
|
|
786
|
+
for datum in train_datums:
|
|
787
|
+
if not datum.advantages:
|
|
788
|
+
raise ValueError(
|
|
789
|
+
f"datum (trial {datum.trial_name}, fragment {datum.fragment_index}) has "
|
|
790
|
+
"no advantages attached; run attach_advantages on the batch before "
|
|
791
|
+
"converting to tinker datums"
|
|
792
|
+
)
|
|
793
|
+
tokens = datum.model_input_tokens
|
|
794
|
+
if len(tokens) < 2:
|
|
795
|
+
raise ValueError(
|
|
796
|
+
f"datum (trial {datum.trial_name}, fragment {datum.fragment_index}) has "
|
|
797
|
+
f"{len(tokens)} token(s); at least 2 are needed for the next-token "
|
|
798
|
+
"input/target shift"
|
|
799
|
+
)
|
|
800
|
+
length = len(tokens) - 1
|
|
801
|
+
out.append(
|
|
802
|
+
tinker.Datum(
|
|
803
|
+
model_input=tinker.ModelInput.from_ints(tokens[:-1]),
|
|
804
|
+
loss_fn_inputs={
|
|
805
|
+
"target_tokens": tinker.TensorData(
|
|
806
|
+
data=tokens[1:], dtype="int64", shape=[length]
|
|
807
|
+
),
|
|
808
|
+
"logprobs": tinker.TensorData(
|
|
809
|
+
data=datum.sampled_logprobs[1:], dtype="float32", shape=[length]
|
|
810
|
+
),
|
|
811
|
+
# No "mask" key: the live importance_sampling and ppo losses accept
|
|
812
|
+
# exactly target_tokens, logprobs, and advantages (a "mask" kwarg is
|
|
813
|
+
# rejected server-side, observed 2026-07-23; the cookbook strips the
|
|
814
|
+
# same key for both losses). Masking is expressed through the
|
|
815
|
+
# advantages instead: attach_advantages leaves context positions at
|
|
816
|
+
# 0.0, and a zero-advantage position contributes zero loss.
|
|
817
|
+
"advantages": tinker.TensorData(
|
|
818
|
+
data=datum.advantages[1:], dtype="float32", shape=[length]
|
|
819
|
+
),
|
|
820
|
+
},
|
|
821
|
+
)
|
|
822
|
+
)
|
|
823
|
+
return out
|
|
824
|
+
|
|
825
|
+
|
|
826
|
+
def to_tinker_sft_datums(train_datums: Sequence[TrainDatum]) -> list[tinker.Datum]:
|
|
827
|
+
"""Convert datums to real `tinker.Datum`s for the cross_entropy loss.
|
|
828
|
+
|
|
829
|
+
Same shifted next-token layout as `to_tinker_datums`, but for the two
|
|
830
|
+
cross_entropy consumers: supervised warmup on teacher-sampled
|
|
831
|
+
trajectories (no advantages are needed or read; the loss mask rides as
|
|
832
|
+
the `weights` input so context tokens carry no loss, the backend CE loss
|
|
833
|
+
being `sum(-logprobs * weights)`), and topk-CE replicas from
|
|
834
|
+
`build_topk_ce_datums` (their attached `target_tokens` replace the
|
|
835
|
+
next-token targets at loss positions and their `target_weights`, the
|
|
836
|
+
renormalized teacher probabilities, replace the loss mask).
|
|
837
|
+
|
|
838
|
+
The loss_fn_inputs keyset is exactly {"target_tokens", "weights"}: the
|
|
839
|
+
cross_entropy backend of the pinned SDK (tinker 0.23.3) accepts only those
|
|
840
|
+
two keys (its own custom-loss path in
|
|
841
|
+
`tinker/lib/public_interfaces/training_client.py` rejects any other key,
|
|
842
|
+
and the cookbook's supervised datum builder emits exactly this pair).
|
|
843
|
+
Verified against the installed SDK the same way importance_sampling's
|
|
844
|
+
keyset was: the live service already rejected an unexpected "mask" key
|
|
845
|
+
once, so the keyset is pinned by `data_test.py` rather than trusted.
|
|
846
|
+
|
|
847
|
+
Args:
|
|
848
|
+
train_datums: Datums from `build_datums` (advantages may be empty)
|
|
849
|
+
or replicas from `build_topk_ce_datums`.
|
|
850
|
+
|
|
851
|
+
Returns:
|
|
852
|
+
One `tinker.Datum` per input datum, in order, with loss_fn_inputs
|
|
853
|
+
exactly target_tokens (int64) and weights (float32).
|
|
854
|
+
|
|
855
|
+
Raises:
|
|
856
|
+
ImportError: If the tinker SDK is not installed (distill extra).
|
|
857
|
+
ValueError: If a datum is too short for the input/target shift
|
|
858
|
+
(fewer than 2 tokens).
|
|
859
|
+
"""
|
|
860
|
+
try:
|
|
861
|
+
import tinker
|
|
862
|
+
except ImportError as exc:
|
|
863
|
+
raise ImportError(MISSING_TINKER_EXTRA) from exc
|
|
864
|
+
|
|
865
|
+
out: list[tinker.Datum] = []
|
|
866
|
+
for datum in train_datums:
|
|
867
|
+
tokens = datum.model_input_tokens
|
|
868
|
+
if len(tokens) < 2:
|
|
869
|
+
raise ValueError(
|
|
870
|
+
f"datum (trial {datum.trial_name}, fragment {datum.fragment_index}) has "
|
|
871
|
+
f"{len(tokens)} token(s); at least 2 are needed for the next-token "
|
|
872
|
+
"input/target shift"
|
|
873
|
+
)
|
|
874
|
+
length = len(tokens) - 1
|
|
875
|
+
targets = datum.target_tokens if datum.is_topk_replica else tokens
|
|
876
|
+
weights = datum.target_weights if datum.is_topk_replica else datum.loss_mask
|
|
877
|
+
out.append(
|
|
878
|
+
tinker.Datum(
|
|
879
|
+
model_input=tinker.ModelInput.from_ints(tokens[:-1]),
|
|
880
|
+
loss_fn_inputs={
|
|
881
|
+
"target_tokens": tinker.TensorData(
|
|
882
|
+
data=targets[1:], dtype="int64", shape=[length]
|
|
883
|
+
),
|
|
884
|
+
"weights": tinker.TensorData(data=weights[1:], dtype="float32", shape=[length]),
|
|
885
|
+
},
|
|
886
|
+
)
|
|
887
|
+
)
|
|
888
|
+
return out
|
|
889
|
+
|
|
890
|
+
|
|
891
|
+
def to_tinker_topk_ce_datums(
|
|
892
|
+
train_datums: Sequence[TrainDatum],
|
|
893
|
+
topk_scores: Sequence[Sequence[TopkCandidates | None]],
|
|
894
|
+
k: int,
|
|
895
|
+
) -> list[tinker.Datum]:
|
|
896
|
+
"""Convert source datums plus teacher top-k rows to cross_entropy wire datums.
|
|
897
|
+
|
|
898
|
+
The one-call conversion for the `topk_ce` loss: `build_topk_ce_datums`
|
|
899
|
+
does the rank-aligned replication and weight renormalization (see its
|
|
900
|
+
docstring for the objective-equivalence argument), then the replicas ride
|
|
901
|
+
the pinned cross_entropy wire format (`to_tinker_sft_datums`: exactly
|
|
902
|
+
{"target_tokens", "weights"}). Misaligned rows drop their source datum
|
|
903
|
+
with a warning, mirroring the loop's own two-step path.
|
|
904
|
+
|
|
905
|
+
Args:
|
|
906
|
+
train_datums: Datums from `build_datums`.
|
|
907
|
+
topk_scores: One per-position top-k row per datum
|
|
908
|
+
(`TeacherClient.score_topk`).
|
|
909
|
+
k: The replica count (`train.topk`).
|
|
910
|
+
|
|
911
|
+
Returns:
|
|
912
|
+
`k` `tinker.Datum`s per kept source datum, source order then rank
|
|
913
|
+
order, sharing each source's model input.
|
|
914
|
+
|
|
915
|
+
Raises:
|
|
916
|
+
ImportError: If the tinker SDK is not installed (distill extra).
|
|
917
|
+
ValueError: On caller bugs (`build_topk_ce_datums`) or datums too
|
|
918
|
+
short for the shift (`to_tinker_sft_datums`).
|
|
919
|
+
"""
|
|
920
|
+
replicas, _ = build_topk_ce_datums(train_datums, topk_scores, k)
|
|
921
|
+
return to_tinker_sft_datums(replicas)
|