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/teacher.py
ADDED
|
@@ -0,0 +1,714 @@
|
|
|
1
|
+
"""Teacher-side logprob scoring for on-policy distillation.
|
|
2
|
+
|
|
3
|
+
The teacher never generates during training; it scores the student's exact
|
|
4
|
+
sampled tokens. `TinkerTeacher` sends each `TrainDatum`'s full (unshifted)
|
|
5
|
+
token sequence through `tinker.SamplingClient.compute_logprobs` and keeps the
|
|
6
|
+
loss positions. For the `topk_ce` loss, `score_topk` instead makes one
|
|
7
|
+
prefill-only `sample` call per datum (`max_tokens=1`,
|
|
8
|
+
`include_prompt_logprobs=True`, `topk_prompt_logprobs=k`; verified live on the
|
|
9
|
+
pinned SDK, k <= 1000) whose response carries BOTH the per-position top-k
|
|
10
|
+
candidates and the realized per-position logprobs, so one request feeds the
|
|
11
|
+
candidate targets and the unchanged reverse-KL metric.
|
|
12
|
+
|
|
13
|
+
Indexing convention (verified against both the real SDK and the fakes):
|
|
14
|
+
`compute_logprobs(tokens)` returns one entry per input position, where entry p
|
|
15
|
+
is the logprob of token p given tokens < p, and entry 0 is None because the
|
|
16
|
+
first token has no context. The prefill response's `prompt_logprobs` and
|
|
17
|
+
`topk_prompt_logprobs` follow the same convention. The datum's
|
|
18
|
+
`model_input_tokens` IS that full sequence and its `loss_mask` names the
|
|
19
|
+
sampled positions, so the returned rows align index for index with the datum
|
|
20
|
+
(`teacher_test.py` pins the alignment against the fake sampler's echoed
|
|
21
|
+
logprobs to guard any off-by-one).
|
|
22
|
+
|
|
23
|
+
The tinker SDK is an optional extra imported lazily (`uv sync --extra
|
|
24
|
+
distill`), mirroring `wmo.providers.tinker`; injecting a sampling client
|
|
25
|
+
(tests use `wmo.distill.fake_tinker.FakeSamplingClient`) avoids the SDK
|
|
26
|
+
entirely. The lazily built real client comes from `wmo.providers.tinker`'s
|
|
27
|
+
process-wide shared cache (one `tinker.ServiceClient` and one
|
|
28
|
+
`SamplingClient` per model string for the whole process), so teacher
|
|
29
|
+
construction never adds server-side sessions of its own.
|
|
30
|
+
|
|
31
|
+
Every SDK call is deadline-bounded (`wmo.distill.deadlines`): a wedged
|
|
32
|
+
session raises a retryable `TinkerDeadlineError` instead of hanging, and the
|
|
33
|
+
teacher drops its lazily built client on expiry, evicting the shared cache
|
|
34
|
+
entry, so the next score() call rebuilds a fresh session (an injected client
|
|
35
|
+
is never dropped).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
from __future__ import annotations
|
|
39
|
+
|
|
40
|
+
import logging
|
|
41
|
+
from collections.abc import Sequence
|
|
42
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
43
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
44
|
+
|
|
45
|
+
from pydantic import BaseModel, ConfigDict
|
|
46
|
+
|
|
47
|
+
from wmo.distill.config import TeacherConfig
|
|
48
|
+
from wmo.distill.data import TopkCandidates, TrainDatum
|
|
49
|
+
from wmo.distill.deadlines import TinkerDeadlineError, wait_with_deadline
|
|
50
|
+
from wmo.providers.base import ProviderKind, VerifyResult
|
|
51
|
+
from wmo.providers.tinker import evict_shared_sampling_client, shared_sampling_client
|
|
52
|
+
|
|
53
|
+
if TYPE_CHECKING:
|
|
54
|
+
import tinker
|
|
55
|
+
|
|
56
|
+
logger = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
_SCORE_CONCURRENCY = 8
|
|
59
|
+
"""Upper bound on concurrent compute_logprobs calls in one score() batch."""
|
|
60
|
+
|
|
61
|
+
_VERIFY_PROBE_TOKEN_IDS: tuple[int, ...] = (1, 2, 3, 4)
|
|
62
|
+
"""A tiny fixed sequence for verify(); small ids are valid in any real vocab."""
|
|
63
|
+
|
|
64
|
+
TOKENIZER_PROBE_TEXTS: tuple[str, ...] = (
|
|
65
|
+
"Hello, world! How are you today?",
|
|
66
|
+
"naïve café: déjà vu, übermäßig, œuvre, 你好世界, こんにちは, 🚀✨",
|
|
67
|
+
"def solve(xs: list[int]) -> int:\n return sum(x * 2 for x in xs)\n",
|
|
68
|
+
'{"tool": "bash", "arguments": {"cmd": "ls -la | wc -l", "timeout": 30}}',
|
|
69
|
+
"tail -n 100 /var/log/syslog | grep -E 'error|warn' >> /tmp/out.txt",
|
|
70
|
+
"Mixing scripts: Ω ≈ 3.14, привет, مرحبا, 한국어, \t tabs\nand newlines.",
|
|
71
|
+
)
|
|
72
|
+
"""Default fingerprint probe corpus: prose, unicode, code, JSON, shell."""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
class TeacherTopkScores(BaseModel):
|
|
76
|
+
"""One `score_topk` batch: top-k candidate rows plus realized logprobs.
|
|
77
|
+
|
|
78
|
+
Both lists align one to one with the scored datums and, per datum, index
|
|
79
|
+
for index with its `model_input_tokens` (the compute_logprobs
|
|
80
|
+
convention). They come from the same prefill response, so the realized
|
|
81
|
+
logprobs cost no extra teacher request.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
85
|
+
|
|
86
|
+
topk: list[list[TopkCandidates | None]]
|
|
87
|
+
"""Per datum, per position: the teacher's top-k (token_id, logprob)
|
|
88
|
+
candidates when the position is a scoreable loss position (mask 1.0,
|
|
89
|
+
p >= 1), None everywhere else."""
|
|
90
|
+
|
|
91
|
+
realized: list[list[float | None]]
|
|
92
|
+
"""Per datum, per position: the teacher's logprob of the REALIZED token
|
|
93
|
+
at scoreable loss positions, None everywhere else; identical in meaning
|
|
94
|
+
to a `score()` row, so the reverse-KL metric stays comparable across
|
|
95
|
+
loss modes."""
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
class TeacherClient(Protocol):
|
|
99
|
+
"""What the distillation loop needs from a teacher backend."""
|
|
100
|
+
|
|
101
|
+
def score(self, datums: Sequence[TrainDatum]) -> list[list[float | None]]:
|
|
102
|
+
"""Per-position teacher logprob rows, aligned one to one with `datums`.
|
|
103
|
+
|
|
104
|
+
Row entry p is the teacher's logprob of `model_input_tokens[p]` when p
|
|
105
|
+
is a scoreable loss position (mask 1.0, p >= 1) and None everywhere
|
|
106
|
+
else (context positions, and position 0, which has no context).
|
|
107
|
+
"""
|
|
108
|
+
...
|
|
109
|
+
|
|
110
|
+
def score_topk(self, datums: Sequence[TrainDatum], k: int) -> TeacherTopkScores:
|
|
111
|
+
"""Per-position top-k candidate rows plus realized logprobs.
|
|
112
|
+
|
|
113
|
+
Row entry p carries the teacher's top-k (token_id, logprob)
|
|
114
|
+
candidates for position p when p is a scoreable loss position and
|
|
115
|
+
None everywhere else, following the same convention as `score`.
|
|
116
|
+
"""
|
|
117
|
+
...
|
|
118
|
+
|
|
119
|
+
def verify(self) -> VerifyResult:
|
|
120
|
+
"""One cheap preflight probe; reports failure, never raises."""
|
|
121
|
+
...
|
|
122
|
+
|
|
123
|
+
def usage(self) -> int:
|
|
124
|
+
"""Total tokens submitted for scoring so far (the teacher_prefill meter)."""
|
|
125
|
+
...
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@runtime_checkable
|
|
129
|
+
class CachedUsageTeacher(Protocol):
|
|
130
|
+
"""A teacher that can report how much of its prefill the service cached.
|
|
131
|
+
|
|
132
|
+
Deliberately NOT part of `TeacherClient`: every existing teacher and test
|
|
133
|
+
fake satisfies that seam today, and widening it would force each of them to
|
|
134
|
+
grow a method whose only honest answer is 0. A caller narrows to this at
|
|
135
|
+
runtime and falls back to billing everything uncached.
|
|
136
|
+
"""
|
|
137
|
+
|
|
138
|
+
def cached_usage(self) -> int:
|
|
139
|
+
"""Of `usage()`, the tokens served from the service's prefix cache."""
|
|
140
|
+
...
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class EncodingTokenizer(Protocol):
|
|
144
|
+
"""The tokenizer slice the fingerprint check needs: encoding only.
|
|
145
|
+
|
|
146
|
+
HuggingFace tokenizers and the deterministic test fakes both satisfy it
|
|
147
|
+
structurally (extra defaulted parameters do not matter).
|
|
148
|
+
"""
|
|
149
|
+
|
|
150
|
+
def encode(self, text: str) -> list[int]:
|
|
151
|
+
"""Encode text to token ids."""
|
|
152
|
+
...
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
class LogprobScorer(Protocol):
|
|
156
|
+
"""The one call the teacher makes, in token-id terms.
|
|
157
|
+
|
|
158
|
+
`wmo.distill.fake_tinker.FakeSamplingClient` satisfies this directly;
|
|
159
|
+
real `tinker.SamplingClient`s are adapted via `SdkLogprobScorer`.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def compute_logprobs(self, token_ids: list[int]) -> list[float | None]:
|
|
163
|
+
"""Per-position logprobs for the sequence; entry 0 is None."""
|
|
164
|
+
...
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@runtime_checkable
|
|
168
|
+
class CacheReportingScorer(Protocol):
|
|
169
|
+
"""A scorer that can report the service's own prefix-cache hit count.
|
|
170
|
+
|
|
171
|
+
Kept a separate, runtime-checked protocol rather than folded into
|
|
172
|
+
`LogprobScorer` so the deterministic fakes and any externally injected
|
|
173
|
+
scorer keep satisfying the base seam unchanged; the caller treats a scorer
|
|
174
|
+
without this as "cache unknown", which bills every token at the uncached
|
|
175
|
+
rate exactly as before.
|
|
176
|
+
"""
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def cache_hit_tokens(self) -> int:
|
|
180
|
+
"""Prompt tokens the service reported serving from its prefix cache."""
|
|
181
|
+
...
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
@runtime_checkable
|
|
185
|
+
class TopkLogprobScorer(Protocol):
|
|
186
|
+
"""The prefill-only top-k call `score_topk` additionally needs.
|
|
187
|
+
|
|
188
|
+
`FakeSamplingClient` and `SdkLogprobScorer` (and the loop's
|
|
189
|
+
`SdkSamplingClient` wrapper) all provide it; `score_topk` narrows its
|
|
190
|
+
`LogprobScorer` to this at runtime so plain injected scorers keep
|
|
191
|
+
working for `score`.
|
|
192
|
+
"""
|
|
193
|
+
|
|
194
|
+
def topk_prompt_logprobs(
|
|
195
|
+
self, token_ids: list[int], k: int
|
|
196
|
+
) -> tuple[list[float | None], list[TopkCandidates | None]]:
|
|
197
|
+
"""(realized logprobs, top-k rows), one entry per position each."""
|
|
198
|
+
...
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
class SdkLogprobScorer:
|
|
202
|
+
"""Adapts a real `tinker.SamplingClient` to the `LogprobScorer` seam."""
|
|
203
|
+
|
|
204
|
+
def __init__(self, client: tinker.SamplingClient) -> None:
|
|
205
|
+
self._client = client
|
|
206
|
+
self._cache_hit_tokens = 0
|
|
207
|
+
|
|
208
|
+
@property
|
|
209
|
+
def sdk_client(self) -> tinker.SamplingClient:
|
|
210
|
+
"""The wrapped SDK client (shared-cache eviction compares its identity)."""
|
|
211
|
+
return self._client
|
|
212
|
+
|
|
213
|
+
@property
|
|
214
|
+
def cache_hit_tokens(self) -> int:
|
|
215
|
+
"""Prompt tokens the service reported serving from its prefix cache.
|
|
216
|
+
|
|
217
|
+
Accumulated across every scoring call this scorer has made, so the
|
|
218
|
+
caller can bill the cached portion at the cached rate instead of
|
|
219
|
+
assuming zero.
|
|
220
|
+
"""
|
|
221
|
+
return self._cache_hit_tokens
|
|
222
|
+
|
|
223
|
+
def _record_cache_hits(self, response: object) -> None:
|
|
224
|
+
"""Add one response's reported cache hits, tolerating their absence.
|
|
225
|
+
|
|
226
|
+
Read defensively on purpose. This is COST ACCOUNTING on the hot path of
|
|
227
|
+
a paid run: if a future SDK renames or drops `prompt_cache_hit_tokens`,
|
|
228
|
+
the correct outcome is a ledger that quietly reverts to billing
|
|
229
|
+
everything uncached (an overstatement, the behaviour we already lived
|
|
230
|
+
with), not an AttributeError that kills a run mid-batch after the
|
|
231
|
+
service has already been paid for the scoring. Logged at debug once per
|
|
232
|
+
call rather than warned, because a scorer that cannot report is the
|
|
233
|
+
normal case for injected fakes.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
response: The SDK sample response.
|
|
237
|
+
"""
|
|
238
|
+
hits = getattr(response, "prompt_cache_hit_tokens", None)
|
|
239
|
+
if isinstance(hits, int):
|
|
240
|
+
self._cache_hit_tokens += hits
|
|
241
|
+
return
|
|
242
|
+
logger.debug(
|
|
243
|
+
"teacher scoring response carries no usable prompt_cache_hit_tokens (%r); "
|
|
244
|
+
"billing this call's prefill as uncached",
|
|
245
|
+
hits,
|
|
246
|
+
)
|
|
247
|
+
|
|
248
|
+
def compute_logprobs(self, token_ids: list[int]) -> list[float | None]:
|
|
249
|
+
"""One deadline-bounded prefill call on the full sequence.
|
|
250
|
+
|
|
251
|
+
Issued as `sample` rather than the SDK's `compute_logprobs`, which is
|
|
252
|
+
not a different request: the SDK's own implementation is exactly this
|
|
253
|
+
call (`num_samples=1`, `max_tokens=1`, `include_prompt_logprobs=True`)
|
|
254
|
+
followed by `return sample_res.prompt_logprobs`. It throws the rest of
|
|
255
|
+
the response away, including `prompt_cache_hit_tokens` -- which is the
|
|
256
|
+
service's own measurement of how much of this prefill it did not have to
|
|
257
|
+
recompute, and the single number that decides whether our cost figures
|
|
258
|
+
are a bill or a ceiling. Tinker's prefix cache demonstrably works (a
|
|
259
|
+
repeated prompt measured 16,000 of 16,000 tokens cached) while every
|
|
260
|
+
`*_cached_prefill` meter read 0 by construction, so the run ledger has
|
|
261
|
+
been overstating spend by an unknown factor. Same request, same
|
|
262
|
+
`prompt_logprobs`, one more field kept.
|
|
263
|
+
|
|
264
|
+
The deadline keeps the `compute_logprobs` label deliberately: the
|
|
265
|
+
workload is byte-for-byte the same prefill, and relabelling it `sample`
|
|
266
|
+
would silently swap in a deadline tuned for generation instead.
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
TinkerDeadlineError: If the deadline expires (the session is
|
|
270
|
+
likely wedged; the caller should retry with a fresh one).
|
|
271
|
+
RuntimeError: If the response carries no prompt logprobs even
|
|
272
|
+
though the request asked for them (SDK drift).
|
|
273
|
+
"""
|
|
274
|
+
import tinker
|
|
275
|
+
|
|
276
|
+
future = self._client.sample(
|
|
277
|
+
prompt=tinker.ModelInput.from_ints(token_ids),
|
|
278
|
+
num_samples=1,
|
|
279
|
+
sampling_params=tinker.SamplingParams(max_tokens=1),
|
|
280
|
+
include_prompt_logprobs=True,
|
|
281
|
+
)
|
|
282
|
+
response = wait_with_deadline("compute_logprobs", future)
|
|
283
|
+
self._record_cache_hits(response)
|
|
284
|
+
realized = response.prompt_logprobs
|
|
285
|
+
if realized is None:
|
|
286
|
+
raise RuntimeError(
|
|
287
|
+
"the teacher's prefill sample response carries no prompt_logprobs even "
|
|
288
|
+
"though include_prompt_logprobs was set; check the pinned tinker SDK "
|
|
289
|
+
"version (0.23.3 populates it on request)"
|
|
290
|
+
)
|
|
291
|
+
return list(realized)
|
|
292
|
+
|
|
293
|
+
def topk_prompt_logprobs(
|
|
294
|
+
self, token_ids: list[int], k: int
|
|
295
|
+
) -> tuple[list[float | None], list[TopkCandidates | None]]:
|
|
296
|
+
"""One deadline-bounded prefill-only sample returning prompt logprobs.
|
|
297
|
+
|
|
298
|
+
The whole sequence rides as the PROMPT of a `max_tokens=1` sample
|
|
299
|
+
request with `include_prompt_logprobs=True` and
|
|
300
|
+
`topk_prompt_logprobs=k` (the pinned SDK's only top-k surface,
|
|
301
|
+
verified live: k <= 1000). The response's `prompt_logprobs` and
|
|
302
|
+
`topk_prompt_logprobs` both follow the compute_logprobs convention
|
|
303
|
+
(entry p scores token p given tokens < p; entry 0 is None), so one
|
|
304
|
+
request yields the candidates AND the realized logprobs. The single
|
|
305
|
+
sampled token is discarded.
|
|
306
|
+
|
|
307
|
+
Raises:
|
|
308
|
+
TinkerDeadlineError: If the deadline expires (the session is
|
|
309
|
+
likely wedged; the caller should retry with a fresh one).
|
|
310
|
+
RuntimeError: If the response omits either logprob field (SDK
|
|
311
|
+
drift; the pinned tinker 0.23.3 populates both on request).
|
|
312
|
+
"""
|
|
313
|
+
import tinker
|
|
314
|
+
|
|
315
|
+
future = self._client.sample(
|
|
316
|
+
prompt=tinker.ModelInput.from_ints(token_ids),
|
|
317
|
+
num_samples=1,
|
|
318
|
+
sampling_params=tinker.SamplingParams(max_tokens=1),
|
|
319
|
+
include_prompt_logprobs=True,
|
|
320
|
+
topk_prompt_logprobs=k,
|
|
321
|
+
)
|
|
322
|
+
response = wait_with_deadline("sample", future)
|
|
323
|
+
self._record_cache_hits(response)
|
|
324
|
+
realized = response.prompt_logprobs
|
|
325
|
+
rows = response.topk_prompt_logprobs
|
|
326
|
+
if realized is None or rows is None:
|
|
327
|
+
missing = "prompt_logprobs" if realized is None else "topk_prompt_logprobs"
|
|
328
|
+
raise RuntimeError(
|
|
329
|
+
f"the teacher's prefill sample response carries no {missing} even "
|
|
330
|
+
"though the request asked for it; check the pinned tinker SDK "
|
|
331
|
+
"version (0.23.3 populates both when include_prompt_logprobs and "
|
|
332
|
+
"topk_prompt_logprobs are set)"
|
|
333
|
+
)
|
|
334
|
+
return list(realized), [None if row is None else list(row) for row in rows]
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
class TinkerTeacher:
|
|
338
|
+
"""`TeacherClient` backed by a Tinker sampling client.
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
spec: The `[teacher]` section of the run config; `model` is the base
|
|
342
|
+
model name and `checkpoint` optionally pins a `tinker://` weights
|
|
343
|
+
path to serve the teacher from.
|
|
344
|
+
sampling_client: Optional injected scorer (tests use the fakes in
|
|
345
|
+
`wmo.distill.fake_tinker`; wrap a real `tinker.SamplingClient` in
|
|
346
|
+
`SdkLogprobScorer`). When None, a real client is fetched lazily
|
|
347
|
+
on first use from `wmo.providers.tinker`'s process-wide shared
|
|
348
|
+
cache, keyed by the teacher's model identity (checkpoint or base
|
|
349
|
+
model name); an injected client bypasses the cache entirely.
|
|
350
|
+
"""
|
|
351
|
+
|
|
352
|
+
def __init__(
|
|
353
|
+
self, spec: TeacherConfig, *, sampling_client: LogprobScorer | None = None
|
|
354
|
+
) -> None:
|
|
355
|
+
self._spec = spec
|
|
356
|
+
self._scorer = sampling_client
|
|
357
|
+
# Only a client the teacher built itself may be dropped and rebuilt
|
|
358
|
+
# after a deadline expiry; an injected one cannot be reconstructed.
|
|
359
|
+
self._owns_scorer = sampling_client is None
|
|
360
|
+
self._usage_tokens = 0
|
|
361
|
+
|
|
362
|
+
def _model_identity(self) -> str:
|
|
363
|
+
return self._spec.checkpoint or self._spec.model
|
|
364
|
+
|
|
365
|
+
def _get_scorer(self) -> LogprobScorer:
|
|
366
|
+
if self._scorer is None:
|
|
367
|
+
self._scorer = self._build_sdk_scorer()
|
|
368
|
+
return self._scorer
|
|
369
|
+
|
|
370
|
+
def _build_sdk_scorer(self) -> LogprobScorer:
|
|
371
|
+
# Lazy: the SDK import and the API-key check happen inside the shared
|
|
372
|
+
# cache path (`wmo.providers.tinker.shared_sampling_client`). One
|
|
373
|
+
# process-wide ServiceClient and one SamplingClient per model string
|
|
374
|
+
# are shared with the rollout providers, so building the teacher
|
|
375
|
+
# never adds server-side sessions of its own.
|
|
376
|
+
return SdkLogprobScorer(shared_sampling_client(self._model_identity()))
|
|
377
|
+
|
|
378
|
+
def _drop_wedged_scorer(self) -> None:
|
|
379
|
+
"""Forget (and evict from the shared cache) a wedged scoring client.
|
|
380
|
+
|
|
381
|
+
A wedged session keeps timing out while a freshly built one heals, so
|
|
382
|
+
the next score() call rebuilds through `_get_scorer`. The shared
|
|
383
|
+
cache entry is evicted too, not just this teacher's reference, so no
|
|
384
|
+
other user of the same model string inherits the wedged session. An
|
|
385
|
+
injected client is never dropped: the teacher cannot rebuild what it
|
|
386
|
+
did not build.
|
|
387
|
+
"""
|
|
388
|
+
if self._owns_scorer and self._scorer is not None:
|
|
389
|
+
logger.warning(
|
|
390
|
+
"dropping the tinker teacher client after a deadline expiry; "
|
|
391
|
+
"the next score() call builds a fresh session"
|
|
392
|
+
)
|
|
393
|
+
if isinstance(self._scorer, SdkLogprobScorer):
|
|
394
|
+
evict_shared_sampling_client(self._model_identity(), self._scorer.sdk_client)
|
|
395
|
+
self._scorer = None
|
|
396
|
+
|
|
397
|
+
def score(self, datums: Sequence[TrainDatum]) -> list[list[float | None]]:
|
|
398
|
+
"""Score each datum's full token sequence into a per-position row.
|
|
399
|
+
|
|
400
|
+
Datums are scored with bounded concurrency (the SDK call blocks on a
|
|
401
|
+
per-sequence future, so a small thread pool batches them) and results
|
|
402
|
+
keep datum order. Row entry p carries the teacher logprob of the
|
|
403
|
+
datum's token p exactly when p is a loss position (mask 1.0) with
|
|
404
|
+
context (p >= 1); every other entry is None. A loss token at position
|
|
405
|
+
0 cannot be conditioned on anything, so its None survives and
|
|
406
|
+
`attach_advantages` drops that datum loudly.
|
|
407
|
+
|
|
408
|
+
Args:
|
|
409
|
+
datums: The batch to score, in the loop's unshifted layout.
|
|
410
|
+
|
|
411
|
+
Returns:
|
|
412
|
+
One per-position row per datum, aligned with its
|
|
413
|
+
`model_input_tokens`.
|
|
414
|
+
|
|
415
|
+
Raises:
|
|
416
|
+
ImportError: If no client was injected and the tinker extra is
|
|
417
|
+
not installed.
|
|
418
|
+
RuntimeError: If the API key is missing, the teacher returns a
|
|
419
|
+
result of the wrong length (tokenizer or SDK drift), or a
|
|
420
|
+
scoreable loss position comes back None.
|
|
421
|
+
TinkerDeadlineError: If a compute_logprobs deadline expires; the
|
|
422
|
+
lazily built client is dropped first so retrying score()
|
|
423
|
+
rebuilds a fresh session.
|
|
424
|
+
"""
|
|
425
|
+
datum_list = list(datums)
|
|
426
|
+
if not datum_list:
|
|
427
|
+
return []
|
|
428
|
+
scorer = self._get_scorer()
|
|
429
|
+
workers = min(_SCORE_CONCURRENCY, len(datum_list))
|
|
430
|
+
try:
|
|
431
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
432
|
+
per_datum = list(
|
|
433
|
+
pool.map(
|
|
434
|
+
lambda datum: scorer.compute_logprobs(datum.model_input_tokens), datum_list
|
|
435
|
+
)
|
|
436
|
+
)
|
|
437
|
+
except TinkerDeadlineError:
|
|
438
|
+
# The teacher session is likely wedged; drop the lazily built
|
|
439
|
+
# client so the caller's retry scores through a fresh session.
|
|
440
|
+
# Every in-flight worker is itself deadline-bounded, so the
|
|
441
|
+
# pool's shutdown join above stays bounded too.
|
|
442
|
+
self._drop_wedged_scorer()
|
|
443
|
+
raise
|
|
444
|
+
finally:
|
|
445
|
+
# Counted whether or not scoring succeeded: pool.map submits every
|
|
446
|
+
# datum up front and the executor's shutdown join runs them all, so
|
|
447
|
+
# the service bills the whole batch even when one call raises. The
|
|
448
|
+
# caller's finally-charge turns this into ledgered spend.
|
|
449
|
+
self._usage_tokens += sum(len(datum.model_input_tokens) for datum in datum_list)
|
|
450
|
+
results: list[list[float | None]] = []
|
|
451
|
+
for index, (datum, logprobs) in enumerate(zip(datum_list, per_datum, strict=True)):
|
|
452
|
+
results.append(self._loss_position_row(index, datum, logprobs))
|
|
453
|
+
logger.debug(
|
|
454
|
+
"teacher scored %d datum(s), %d tokens total so far",
|
|
455
|
+
len(datum_list),
|
|
456
|
+
self._usage_tokens,
|
|
457
|
+
)
|
|
458
|
+
return results
|
|
459
|
+
|
|
460
|
+
def score_topk(self, datums: Sequence[TrainDatum], k: int) -> TeacherTopkScores:
|
|
461
|
+
"""Top-k candidates plus realized logprobs for each datum's loss positions.
|
|
462
|
+
|
|
463
|
+
One prefill-only sample request per datum (bounded concurrency, datum
|
|
464
|
+
order preserved) returns the teacher's per-position top-k candidates
|
|
465
|
+
AND the realized per-position logprobs together, so the reverse-KL
|
|
466
|
+
metric costs no second pass. Rows follow `score`'s convention: entry
|
|
467
|
+
p is populated exactly when p is a loss position (mask 1.0) with
|
|
468
|
+
context (p >= 1); a loss token at position 0 stays None in BOTH rows
|
|
469
|
+
and `build_topk_ce_datums` drops that datum loudly, mirroring
|
|
470
|
+
`attach_advantages`.
|
|
471
|
+
|
|
472
|
+
Usage accounting matches `score`: the full sequence tokens count
|
|
473
|
+
toward the teacher_prefill meter. The one discarded sampled token per
|
|
474
|
+
request is deliberately not counted (it is noise against the
|
|
475
|
+
sequence volume and has no meter of its own).
|
|
476
|
+
|
|
477
|
+
Args:
|
|
478
|
+
datums: The batch to score, in the loop's unshifted layout.
|
|
479
|
+
k: Candidates per position (`train.topk`).
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
The batch's `TeacherTopkScores`, aligned one to one with `datums`.
|
|
483
|
+
|
|
484
|
+
Raises:
|
|
485
|
+
ValueError: If `k < 1`.
|
|
486
|
+
ImportError: If no client was injected and the tinker extra is
|
|
487
|
+
not installed.
|
|
488
|
+
RuntimeError: If the API key is missing, the scoring client has
|
|
489
|
+
no top-k surface, the teacher returns rows of the wrong
|
|
490
|
+
length, or a scoreable loss position comes back without
|
|
491
|
+
candidates or without a realized logprob.
|
|
492
|
+
TinkerDeadlineError: If a request deadline expires; the lazily
|
|
493
|
+
built client is dropped first so retrying score_topk
|
|
494
|
+
rebuilds a fresh session.
|
|
495
|
+
"""
|
|
496
|
+
if k < 1:
|
|
497
|
+
raise ValueError(f"k must be >= 1, got {k}")
|
|
498
|
+
datum_list = list(datums)
|
|
499
|
+
if not datum_list:
|
|
500
|
+
return TeacherTopkScores(topk=[], realized=[])
|
|
501
|
+
scorer = self._get_scorer()
|
|
502
|
+
if not isinstance(scorer, TopkLogprobScorer):
|
|
503
|
+
raise RuntimeError(
|
|
504
|
+
f"the teacher's scoring client {type(scorer).__name__} has no "
|
|
505
|
+
"topk_prompt_logprobs surface, so it cannot serve the topk_ce "
|
|
506
|
+
"loss; inject a client with the prefill top-k call (the SDK "
|
|
507
|
+
"adapters and the fakes both have it) or use "
|
|
508
|
+
'train.loss = "importance_sampling" or "ppo"'
|
|
509
|
+
)
|
|
510
|
+
workers = min(_SCORE_CONCURRENCY, len(datum_list))
|
|
511
|
+
try:
|
|
512
|
+
with ThreadPoolExecutor(max_workers=workers) as pool:
|
|
513
|
+
per_datum = list(
|
|
514
|
+
pool.map(
|
|
515
|
+
lambda datum: scorer.topk_prompt_logprobs(datum.model_input_tokens, k),
|
|
516
|
+
datum_list,
|
|
517
|
+
)
|
|
518
|
+
)
|
|
519
|
+
except TinkerDeadlineError:
|
|
520
|
+
# Same rationale as score(): the session is likely wedged, so a
|
|
521
|
+
# lazily built client is dropped for the caller's retry.
|
|
522
|
+
self._drop_wedged_scorer()
|
|
523
|
+
raise
|
|
524
|
+
finally:
|
|
525
|
+
# Counted whether or not scoring succeeded, same contract as
|
|
526
|
+
# score(): every submitted call runs (and bills) server-side
|
|
527
|
+
# before the pool join propagates an error.
|
|
528
|
+
self._usage_tokens += sum(len(datum.model_input_tokens) for datum in datum_list)
|
|
529
|
+
topk_rows: list[list[TopkCandidates | None]] = []
|
|
530
|
+
realized_rows: list[list[float | None]] = []
|
|
531
|
+
for index, (datum, (realized, candidates)) in enumerate(
|
|
532
|
+
zip(datum_list, per_datum, strict=True)
|
|
533
|
+
):
|
|
534
|
+
realized_rows.append(self._loss_position_row(index, datum, realized))
|
|
535
|
+
topk_rows.append(self._loss_position_topk_row(index, datum, candidates))
|
|
536
|
+
logger.debug(
|
|
537
|
+
"teacher top-%d scored %d datum(s), %d tokens total so far",
|
|
538
|
+
k,
|
|
539
|
+
len(datum_list),
|
|
540
|
+
self._usage_tokens,
|
|
541
|
+
)
|
|
542
|
+
return TeacherTopkScores(topk=topk_rows, realized=realized_rows)
|
|
543
|
+
|
|
544
|
+
def _loss_position_topk_row(
|
|
545
|
+
self,
|
|
546
|
+
datum_index: int,
|
|
547
|
+
datum: TrainDatum,
|
|
548
|
+
candidates: list[TopkCandidates | None],
|
|
549
|
+
) -> list[TopkCandidates | None]:
|
|
550
|
+
"""Keep the prefill top-k entries at the datum's loss positions.
|
|
551
|
+
|
|
552
|
+
Mirrors `_loss_position_row` index for index: `candidates[p]` holds
|
|
553
|
+
the top-k for token p given tokens < p, so the result aligns with
|
|
554
|
+
`model_input_tokens`. Position 0's None survives (no context), and
|
|
555
|
+
`build_topk_ce_datums` drops that datum loudly.
|
|
556
|
+
"""
|
|
557
|
+
tokens = datum.model_input_tokens
|
|
558
|
+
if len(candidates) != len(tokens):
|
|
559
|
+
raise RuntimeError(
|
|
560
|
+
f"teacher returned {len(candidates)} top-k entrie(s) for the "
|
|
561
|
+
f"{len(tokens)}-token sequence of datum {datum_index}; the prefill "
|
|
562
|
+
"top-k must return one entry per input position. Check that the "
|
|
563
|
+
"teacher model matches the student's tokenizer (run "
|
|
564
|
+
"tokenizer_fingerprint_check) and that the pinned tinker SDK "
|
|
565
|
+
"version is unchanged"
|
|
566
|
+
)
|
|
567
|
+
row: list[TopkCandidates | None] = [None] * len(tokens)
|
|
568
|
+
for position, weight in enumerate(datum.loss_mask):
|
|
569
|
+
if weight != 1.0 or position == 0:
|
|
570
|
+
continue
|
|
571
|
+
value = candidates[position]
|
|
572
|
+
if not value:
|
|
573
|
+
raise RuntimeError(
|
|
574
|
+
f"teacher returned no top-k candidates for loss position "
|
|
575
|
+
f"{position} of datum {datum_index}; only position 0 may be "
|
|
576
|
+
f"empty. Re-run the step; if it persists, the teacher "
|
|
577
|
+
f"{self._model_identity()!r} could not score the sequence, so "
|
|
578
|
+
"check the model/checkpoint and the tokenizer fingerprint"
|
|
579
|
+
)
|
|
580
|
+
row[position] = list(value)
|
|
581
|
+
return row
|
|
582
|
+
|
|
583
|
+
def _loss_position_row(
|
|
584
|
+
self,
|
|
585
|
+
datum_index: int,
|
|
586
|
+
datum: TrainDatum,
|
|
587
|
+
logprobs: list[float | None],
|
|
588
|
+
) -> list[float | None]:
|
|
589
|
+
"""Keep the compute_logprobs entries at the datum's loss positions.
|
|
590
|
+
|
|
591
|
+
`logprobs[p]` is the logprob of token p given tokens < p, so the
|
|
592
|
+
result aligns index for index with `model_input_tokens`.
|
|
593
|
+
"""
|
|
594
|
+
tokens = datum.model_input_tokens
|
|
595
|
+
if len(logprobs) != len(tokens):
|
|
596
|
+
raise RuntimeError(
|
|
597
|
+
f"teacher returned {len(logprobs)} logprobs for the {len(tokens)}-token "
|
|
598
|
+
f"sequence of datum {datum_index}; compute_logprobs must return one "
|
|
599
|
+
"entry per input position. Check that the teacher model matches the "
|
|
600
|
+
"student's tokenizer (run tokenizer_fingerprint_check) and that the "
|
|
601
|
+
"pinned tinker SDK version is unchanged"
|
|
602
|
+
)
|
|
603
|
+
row: list[float | None] = [None] * len(tokens)
|
|
604
|
+
for position, weight in enumerate(datum.loss_mask):
|
|
605
|
+
if weight != 1.0 or position == 0:
|
|
606
|
+
continue
|
|
607
|
+
value = logprobs[position]
|
|
608
|
+
if value is None:
|
|
609
|
+
raise RuntimeError(
|
|
610
|
+
f"teacher returned no logprob for loss position {position} of "
|
|
611
|
+
f"datum {datum_index}; only position 0 may be None. Re-run the "
|
|
612
|
+
f"step; if it persists, the teacher {self._model_identity()!r} "
|
|
613
|
+
"could not score the sequence, so check the model/checkpoint "
|
|
614
|
+
"and the tokenizer fingerprint"
|
|
615
|
+
)
|
|
616
|
+
row[position] = value
|
|
617
|
+
return row
|
|
618
|
+
|
|
619
|
+
def verify(self) -> VerifyResult:
|
|
620
|
+
"""One tiny compute_logprobs probe, reporting failure as ok=False.
|
|
621
|
+
|
|
622
|
+
Mirrors `wmo.providers.base.verify_via_ping`: never raises, so
|
|
623
|
+
preflight can report every misconfigured client at once.
|
|
624
|
+
"""
|
|
625
|
+
model = self._model_identity()
|
|
626
|
+
try:
|
|
627
|
+
result = self._get_scorer().compute_logprobs(list(_VERIFY_PROBE_TOKEN_IDS))
|
|
628
|
+
if len(result) != len(_VERIFY_PROBE_TOKEN_IDS):
|
|
629
|
+
raise RuntimeError(
|
|
630
|
+
f"probe returned {len(result)} logprobs for "
|
|
631
|
+
f"{len(_VERIFY_PROBE_TOKEN_IDS)} tokens"
|
|
632
|
+
)
|
|
633
|
+
except Exception as exc: # noqa: BLE001 - verify reports failure, never raises
|
|
634
|
+
if isinstance(exc, TinkerDeadlineError):
|
|
635
|
+
self._drop_wedged_scorer()
|
|
636
|
+
return VerifyResult(ok=False, kind=ProviderKind.TINKER, model=model, detail=str(exc))
|
|
637
|
+
return VerifyResult(ok=True, kind=ProviderKind.TINKER, model=model)
|
|
638
|
+
|
|
639
|
+
def usage(self) -> int:
|
|
640
|
+
"""Total tokens submitted through score() so far (verify probes excluded)."""
|
|
641
|
+
return self._usage_tokens
|
|
642
|
+
|
|
643
|
+
def cached_usage(self) -> int:
|
|
644
|
+
"""Of `usage()`, the tokens the SERVICE reported serving from its cache.
|
|
645
|
+
|
|
646
|
+
Zero when the active scorer cannot report it (an injected fake, or a
|
|
647
|
+
future SDK that drops the field), which bills everything at the uncached
|
|
648
|
+
rate — the same conservative behaviour the ledger had before this was
|
|
649
|
+
readable. The count is cumulative and monotonic like `usage()`, so a
|
|
650
|
+
caller takes deltas around a step.
|
|
651
|
+
"""
|
|
652
|
+
scorer = self._scorer
|
|
653
|
+
if isinstance(scorer, CacheReportingScorer):
|
|
654
|
+
return scorer.cache_hit_tokens
|
|
655
|
+
return 0
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def tokenizer_fingerprint_check(
|
|
659
|
+
student_model: str,
|
|
660
|
+
teacher_model: str,
|
|
661
|
+
student_tokenizer: EncodingTokenizer,
|
|
662
|
+
teacher_tokenizer: EncodingTokenizer,
|
|
663
|
+
probe_texts: Sequence[str] = TOKENIZER_PROBE_TEXTS,
|
|
664
|
+
) -> None:
|
|
665
|
+
"""Require the student and teacher tokenizers to be interchangeable.
|
|
666
|
+
|
|
667
|
+
Tinker distillation scores the student's exact token ids with the teacher,
|
|
668
|
+
which is only meaningful when both models tokenize identically. Every
|
|
669
|
+
probe text must encode to the same token ids under both tokenizers.
|
|
670
|
+
|
|
671
|
+
Args:
|
|
672
|
+
student_model: Student base model name, for the error message.
|
|
673
|
+
teacher_model: Teacher model name, for the error message.
|
|
674
|
+
student_tokenizer: The student's tokenizer.
|
|
675
|
+
teacher_tokenizer: The teacher's tokenizer.
|
|
676
|
+
probe_texts: The probe corpus; defaults to `TOKENIZER_PROBE_TEXTS`
|
|
677
|
+
(prose, mixed unicode, code, JSON, shell).
|
|
678
|
+
|
|
679
|
+
Raises:
|
|
680
|
+
ValueError: If `probe_texts` is empty, or any probe encodes
|
|
681
|
+
differently; the message names both models and the fix.
|
|
682
|
+
"""
|
|
683
|
+
if not probe_texts:
|
|
684
|
+
raise ValueError(
|
|
685
|
+
"probe_texts is empty, so the tokenizer fingerprint check would prove "
|
|
686
|
+
"nothing; pass at least one probe text (or use the default corpus)"
|
|
687
|
+
)
|
|
688
|
+
for text in probe_texts:
|
|
689
|
+
student_ids = student_tokenizer.encode(text)
|
|
690
|
+
teacher_ids = teacher_tokenizer.encode(text)
|
|
691
|
+
if student_ids == teacher_ids:
|
|
692
|
+
continue
|
|
693
|
+
mismatch = next(
|
|
694
|
+
(
|
|
695
|
+
index
|
|
696
|
+
for index, (a, b) in enumerate(zip(student_ids, teacher_ids, strict=False))
|
|
697
|
+
if a != b
|
|
698
|
+
),
|
|
699
|
+
min(len(student_ids), len(teacher_ids)),
|
|
700
|
+
)
|
|
701
|
+
raise ValueError(
|
|
702
|
+
f"tokenizer fingerprint mismatch between student {student_model!r} and "
|
|
703
|
+
f"teacher {teacher_model!r}: probe {text!r} encodes to {len(student_ids)} "
|
|
704
|
+
f"student token(s) vs {len(teacher_ids)} teacher token(s), first "
|
|
705
|
+
f"difference at position {mismatch}. Tinker distillation requires "
|
|
706
|
+
"identical tokenizers, so pick a same-family teacher that shares the "
|
|
707
|
+
"student's tokenizer, or choose a different student/teacher pair"
|
|
708
|
+
)
|
|
709
|
+
logger.debug(
|
|
710
|
+
"tokenizer fingerprint ok: %s and %s agree on %d probe(s)",
|
|
711
|
+
student_model,
|
|
712
|
+
teacher_model,
|
|
713
|
+
len(probe_texts),
|
|
714
|
+
)
|