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/tripwire.py
ADDED
|
@@ -0,0 +1,411 @@
|
|
|
1
|
+
"""Degeneration tripwires: entropy and generation length, relative to baseline.
|
|
2
|
+
|
|
3
|
+
Reverse-KL distillation can degenerate in ways a KL curve cannot show, because
|
|
4
|
+
KL falls while the policy collapses. Two failures are on record in sibling
|
|
5
|
+
lanes:
|
|
6
|
+
|
|
7
|
+
- Length/mode collapse: generation length fell 50x (2,866 to about 50 tokens),
|
|
8
|
+
task accuracy fell 0.813 to 0.596, and entropy breached the lane's
|
|
9
|
+
pre-registered floor at steps 17 to 23; the run was killed at step 30/200.
|
|
10
|
+
- The mirror pathology, reported by a second lane: "pure KL gives EOS no
|
|
11
|
+
gradient, so the student never learns to stop", i.e. episodes that run to
|
|
12
|
+
the turn or token cap instead of submitting.
|
|
13
|
+
|
|
14
|
+
This module measures the two statistics that make both visible and decides
|
|
15
|
+
warn/kill against a baseline THIS run measured, from data the training step
|
|
16
|
+
already holds (the student's own recorded spans): no extra sampling, no extra
|
|
17
|
+
spend.
|
|
18
|
+
|
|
19
|
+
Why relative and never absolute. The sibling lane's rule was "entropy < 0.2
|
|
20
|
+
nats means collapse". Our healthy, untrained baseline is 0.181 nats/token
|
|
21
|
+
(`PROBE_BASELINE_ENTROPY_NATS`, pooled over 356,122 sampled tokens of a live
|
|
22
|
+
48-episode TerminalBench-2 probe on Super-120B base weights at temperature
|
|
23
|
+
0.7), so that rule would fire at step 0, before a single gradient step.
|
|
24
|
+
Terminal-command tokens are much more predictable than math reasoning, and a
|
|
25
|
+
sampled-token entropy estimate taken at temperature 0.7 is biased downward
|
|
26
|
+
besides. An always-firing tripwire gets muted, so an absolute threshold here
|
|
27
|
+
would be worse than no threshold at all: every bound in `TripwireConfig` is a
|
|
28
|
+
fraction of the measured baseline, and it must stay that way.
|
|
29
|
+
|
|
30
|
+
Why batch-pooled and never per-episode. On that same probe the healthiest
|
|
31
|
+
single episode already sits at entropy 0.082 nats/token and episode lengths
|
|
32
|
+
span 349 to 30,869 sampled tokens. Any per-episode rule (or a mean of
|
|
33
|
+
per-episode ratios) would fire on healthy runs constantly, so both statistics
|
|
34
|
+
pool over the whole batch: total sampled logprob over total sampled tokens, and
|
|
35
|
+
total sampled tokens over episodes.
|
|
36
|
+
|
|
37
|
+
What the defaults cost in false alarms. Resampling the probe's own 47 healthy
|
|
38
|
+
episodes into batches (200,000 draws per shape) gives the pooled ratio a
|
|
39
|
+
completely stationary policy would report:
|
|
40
|
+
|
|
41
|
+
episodes/batch length ratio p0.1 / min entropy ratio p0.1 / min
|
|
42
|
+
32 (8 tasks x 4) 0.62 / 0.47 0.86 / 0.81
|
|
43
|
+
16 0.50 / 0.36 0.80 / 0.70
|
|
44
|
+
8 0.37 / 0.21 0.71 / 0.57
|
|
45
|
+
|
|
46
|
+
At the headline batch shape the length WARN (0.5) has probability 1e-5 per step
|
|
47
|
+
and the length KILL (0.25) never occurred in 200,000 draws; no draw at any shape
|
|
48
|
+
put the entropy ratio under 0.5, let alone the 0.3 kill bound. Requiring
|
|
49
|
+
`kill_consecutive_steps` in a row squares an already negligible number. The
|
|
50
|
+
tripwire tightens as the batch shrinks, though, so a step whose batch was
|
|
51
|
+
decimated by infrastructure failures is the one case worth reading beside its
|
|
52
|
+
`trials` and `empty_span_trials` counts before believing a length warning.
|
|
53
|
+
|
|
54
|
+
Both directions, since 2026-07-26. The bounds above are floors, and for a while
|
|
55
|
+
they were the only bounds, on the argument that a turn cap already bounds the
|
|
56
|
+
runaway direction. A measured run refuted that: under a 20-turn training cap the
|
|
57
|
+
pooled length still reached 5.33x baseline and entropy 1.68x, because the cap
|
|
58
|
+
bounds turns while each turn grew (172 turns pinned the 12,288-token output cap
|
|
59
|
+
in one step). Nothing fired, and the damage landed somewhere neither floor
|
|
60
|
+
watches — episodes over the context budget are dropped whole, so 22 of 64
|
|
61
|
+
episodes and a third of the trainable datums disappeared while every metric the
|
|
62
|
+
tripwire reads looked "fine, just larger". Each metric now answers to a ceiling
|
|
63
|
+
(`*_mult`) as well, and the ceilings are set from that run's own trajectory:
|
|
64
|
+
see `TripwireConfig` for which observation fixed each number.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
from __future__ import annotations
|
|
68
|
+
|
|
69
|
+
import logging
|
|
70
|
+
from collections.abc import Sequence
|
|
71
|
+
from typing import Literal
|
|
72
|
+
|
|
73
|
+
from pydantic import BaseModel, ConfigDict, Field
|
|
74
|
+
|
|
75
|
+
from wmo.distill.config import TripwireConfig
|
|
76
|
+
from wmo.distill.tokens import TrialRecord
|
|
77
|
+
|
|
78
|
+
logger = logging.getLogger(__name__)
|
|
79
|
+
|
|
80
|
+
TripwireMetric = Literal["entropy_per_token", "mean_generation_tokens"]
|
|
81
|
+
"""The two statistics under tripwire, named exactly as the metrics-row keys."""
|
|
82
|
+
|
|
83
|
+
TripwireLevel = Literal["warn", "kill"]
|
|
84
|
+
|
|
85
|
+
TripwireDirection = Literal["floor", "ceiling"]
|
|
86
|
+
"""Which side of the baseline a breach is on.
|
|
87
|
+
|
|
88
|
+
`floor` is collapse (the ratio fell to a fraction); `ceiling` is runaway (it
|
|
89
|
+
rose to a multiple). Both are the same pathology seen from opposite ends, and a
|
|
90
|
+
run has been lost to each: a sibling lane's length fell 50x, and this lane's
|
|
91
|
+
length rose 5.33x until context overflow was discarding a third of every batch.
|
|
92
|
+
"""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
class PolicyHealth(BaseModel):
|
|
96
|
+
"""One rollout batch's batch-pooled entropy proxy and generation length."""
|
|
97
|
+
|
|
98
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
99
|
+
|
|
100
|
+
episodes: int = Field(ge=0)
|
|
101
|
+
"""Episodes that recorded at least one sampled token.
|
|
102
|
+
|
|
103
|
+
Episodes with no spans are excluded from BOTH denominators on purpose: a
|
|
104
|
+
trial that died at sandbox creation or lost its transport generated nothing,
|
|
105
|
+
so counting it as a zero-length episode would drag the length statistic down
|
|
106
|
+
for an infrastructure outage and could kill a healthy policy. The
|
|
107
|
+
all-empty-batch abort (`MAX_CONSECUTIVE_EMPTY_STEPS`) owns that failure
|
|
108
|
+
mode, and `RolloutStats.empty_span_trials` counts it in the same row."""
|
|
109
|
+
|
|
110
|
+
sampled_tokens: int = Field(ge=0)
|
|
111
|
+
"""Sampled (loss-position) tokens pooled over those episodes."""
|
|
112
|
+
|
|
113
|
+
entropy_per_token: float | None
|
|
114
|
+
"""`-mean(sampled_logprobs)` over every sampled token in the batch.
|
|
115
|
+
|
|
116
|
+
The tokens were drawn from the policy that scored them, so `-log p(x)` with
|
|
117
|
+
`x ~ p` is an unbiased single-sample estimator of that policy's entropy
|
|
118
|
+
`H(p) = E_{x~p}[-log p(x)]`; pooling over the batch averages many such
|
|
119
|
+
samples. Two caveats to read it with:
|
|
120
|
+
|
|
121
|
+
- Temperature. Rollouts sample at `sampling.temperature` (0.7 for the
|
|
122
|
+
headline run), which concentrates the distribution the tokens come from,
|
|
123
|
+
so this sits BELOW the T=1 entropy: treat it as a lower bound and never
|
|
124
|
+
compare it against a number measured at another temperature.
|
|
125
|
+
- It is a policy-shape statistic, not a per-token uncertainty: a batch
|
|
126
|
+
dominated by long, highly predictable tool output reads low without
|
|
127
|
+
anything being wrong, which is exactly why the tripwire compares it only
|
|
128
|
+
against this run's own baseline.
|
|
129
|
+
|
|
130
|
+
None when the batch recorded no sampled token at all."""
|
|
131
|
+
|
|
132
|
+
mean_generation_tokens: float | None
|
|
133
|
+
"""Sampled tokens per episode, pooled (`sampled_tokens / episodes`).
|
|
134
|
+
|
|
135
|
+
None when no episode recorded a sampled token."""
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def policy_health(records: Sequence[TrialRecord]) -> PolicyHealth:
|
|
139
|
+
"""Pool one rollout batch's recorded spans into the two tripwire statistics.
|
|
140
|
+
|
|
141
|
+
Measured from the raw `TrialRecord` spans rather than from the built
|
|
142
|
+
`TrainDatum`s, though the datum builder's loss positions are exactly these
|
|
143
|
+
sampled tokens (its loss mask is 1.0 on sampled tokens and 0.0 on context).
|
|
144
|
+
The reason is drops: `build_datums` discards whole episodes that overflow
|
|
145
|
+
the context budget or exceed `train.max_datum_tokens`, which are precisely
|
|
146
|
+
the LONGEST episodes, so measuring generation length after the drops would
|
|
147
|
+
deflate it in exactly the tail that matters and could kill a healthy run.
|
|
148
|
+
|
|
149
|
+
Args:
|
|
150
|
+
records: The batch's assembled trial records.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
The batch-pooled health; both statistics are None when no episode
|
|
154
|
+
recorded a sampled token (an all-empty batch, whose own abort path
|
|
155
|
+
handles it).
|
|
156
|
+
"""
|
|
157
|
+
episodes = 0
|
|
158
|
+
sampled_tokens = 0
|
|
159
|
+
logprob_total = 0.0
|
|
160
|
+
for record in records:
|
|
161
|
+
episode_tokens = sum(len(span.sampled_token_ids) for span in record.spans)
|
|
162
|
+
if episode_tokens == 0:
|
|
163
|
+
continue
|
|
164
|
+
episodes += 1
|
|
165
|
+
sampled_tokens += episode_tokens
|
|
166
|
+
logprob_total += sum(sum(span.sampled_logprobs) for span in record.spans)
|
|
167
|
+
if episodes == 0 or sampled_tokens == 0:
|
|
168
|
+
return PolicyHealth(
|
|
169
|
+
episodes=episodes,
|
|
170
|
+
sampled_tokens=sampled_tokens,
|
|
171
|
+
entropy_per_token=None,
|
|
172
|
+
mean_generation_tokens=None,
|
|
173
|
+
)
|
|
174
|
+
return PolicyHealth(
|
|
175
|
+
episodes=episodes,
|
|
176
|
+
sampled_tokens=sampled_tokens,
|
|
177
|
+
entropy_per_token=-logprob_total / sampled_tokens,
|
|
178
|
+
mean_generation_tokens=sampled_tokens / episodes,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
class TripwireBaseline(BaseModel):
|
|
183
|
+
"""The reference the tripwires compare every later step against.
|
|
184
|
+
|
|
185
|
+
Captured from the run's FIRST training step and persisted in the run's
|
|
186
|
+
checkpoint manifest, because a resumed run that re-baselines against an
|
|
187
|
+
already-degenerated policy has a blind tripwire: the collapse becomes the
|
|
188
|
+
new normal and nothing fires again.
|
|
189
|
+
"""
|
|
190
|
+
|
|
191
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
192
|
+
|
|
193
|
+
step: int = Field(ge=0)
|
|
194
|
+
"""The 0-based training step measured, so a resumed run can tell whether
|
|
195
|
+
the current step IS the baseline step (which never fires against itself)."""
|
|
196
|
+
|
|
197
|
+
entropy_per_token: float = Field(gt=0.0)
|
|
198
|
+
mean_generation_tokens: float = Field(gt=0.0)
|
|
199
|
+
episodes: int = Field(ge=1)
|
|
200
|
+
"""Episodes behind the measurement, for reading the row's confidence."""
|
|
201
|
+
|
|
202
|
+
sampled_tokens: int = Field(ge=1)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def capture_baseline(step: int, health: PolicyHealth) -> TripwireBaseline | None:
|
|
206
|
+
"""Turn a first step's measured health into the run's baseline.
|
|
207
|
+
|
|
208
|
+
Args:
|
|
209
|
+
step: The 0-based training step being measured.
|
|
210
|
+
health: That step's batch-pooled health.
|
|
211
|
+
|
|
212
|
+
Returns:
|
|
213
|
+
The baseline, or None when this step cannot serve as one: no episode
|
|
214
|
+
sampled anything, or a statistic came out non-positive (an entropy of
|
|
215
|
+
exactly 0.0 would make every later ratio undefined). The caller leaves
|
|
216
|
+
the tripwire unarmed and tries again at the next step.
|
|
217
|
+
"""
|
|
218
|
+
entropy = health.entropy_per_token
|
|
219
|
+
length = health.mean_generation_tokens
|
|
220
|
+
if entropy is None or length is None or entropy <= 0.0 or length <= 0.0:
|
|
221
|
+
return None
|
|
222
|
+
return TripwireBaseline(
|
|
223
|
+
step=step,
|
|
224
|
+
entropy_per_token=entropy,
|
|
225
|
+
mean_generation_tokens=length,
|
|
226
|
+
episodes=health.episodes,
|
|
227
|
+
sampled_tokens=health.sampled_tokens,
|
|
228
|
+
)
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class TripwireBreach(BaseModel):
|
|
232
|
+
"""One metric under one threshold at one step."""
|
|
233
|
+
|
|
234
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
235
|
+
|
|
236
|
+
metric: TripwireMetric
|
|
237
|
+
level: TripwireLevel
|
|
238
|
+
direction: TripwireDirection
|
|
239
|
+
baseline: float
|
|
240
|
+
value: float
|
|
241
|
+
ratio: float
|
|
242
|
+
"""`value / baseline`; the tripwire is a bound on this, never on `value`."""
|
|
243
|
+
|
|
244
|
+
threshold_frac: float
|
|
245
|
+
"""The configured bound the ratio reached: a fraction for a `floor` breach,
|
|
246
|
+
a multiple for a `ceiling` one."""
|
|
247
|
+
|
|
248
|
+
config_field: str
|
|
249
|
+
"""The `[tripwire]` key that set `threshold_frac`, so a breach message says
|
|
250
|
+
exactly which knob to move."""
|
|
251
|
+
|
|
252
|
+
def describe(self) -> str:
|
|
253
|
+
"""One line naming the metric, its baseline, the value, and the ratio."""
|
|
254
|
+
crossed = "at or under" if self.direction == "floor" else "at or above"
|
|
255
|
+
return (
|
|
256
|
+
f"{self.metric} {self.value:.4g} is {self.ratio:.2f}x this run's baseline "
|
|
257
|
+
f"{self.baseline:.4g} (measured at its first training step), {crossed} the "
|
|
258
|
+
f"{self.level} threshold {self.threshold_frac:.2f} ({self.config_field})"
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def metric_ratio(value: float | None, baseline: float | None) -> float | None:
|
|
263
|
+
"""`value / baseline`, or None when either side is missing or unusable."""
|
|
264
|
+
if value is None or baseline is None or baseline <= 0.0:
|
|
265
|
+
return None
|
|
266
|
+
return value / baseline
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
class _MetricReading(BaseModel):
|
|
270
|
+
"""One metric's current value beside the baseline and bounds it answers to."""
|
|
271
|
+
|
|
272
|
+
model_config = ConfigDict(frozen=True, extra="forbid")
|
|
273
|
+
|
|
274
|
+
metric: TripwireMetric
|
|
275
|
+
value: float | None
|
|
276
|
+
baseline: float
|
|
277
|
+
warn_frac: float
|
|
278
|
+
kill_frac: float
|
|
279
|
+
warn_field: str
|
|
280
|
+
kill_field: str
|
|
281
|
+
warn_mult: float
|
|
282
|
+
kill_mult: float
|
|
283
|
+
warn_mult_field: str
|
|
284
|
+
kill_mult_field: str
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _readings(
|
|
288
|
+
cfg: TripwireConfig, baseline: TripwireBaseline, health: PolicyHealth
|
|
289
|
+
) -> list[_MetricReading]:
|
|
290
|
+
"""Both metrics wired to their configured bounds, in report order."""
|
|
291
|
+
return [
|
|
292
|
+
_MetricReading(
|
|
293
|
+
metric="entropy_per_token",
|
|
294
|
+
value=health.entropy_per_token,
|
|
295
|
+
baseline=baseline.entropy_per_token,
|
|
296
|
+
warn_frac=cfg.entropy_warn_frac,
|
|
297
|
+
kill_frac=cfg.entropy_kill_frac,
|
|
298
|
+
warn_field="tripwire.entropy_warn_frac",
|
|
299
|
+
kill_field="tripwire.entropy_kill_frac",
|
|
300
|
+
warn_mult=cfg.entropy_warn_mult,
|
|
301
|
+
kill_mult=cfg.entropy_kill_mult,
|
|
302
|
+
warn_mult_field="tripwire.entropy_warn_mult",
|
|
303
|
+
kill_mult_field="tripwire.entropy_kill_mult",
|
|
304
|
+
),
|
|
305
|
+
_MetricReading(
|
|
306
|
+
metric="mean_generation_tokens",
|
|
307
|
+
value=health.mean_generation_tokens,
|
|
308
|
+
baseline=baseline.mean_generation_tokens,
|
|
309
|
+
warn_frac=cfg.length_warn_frac,
|
|
310
|
+
kill_frac=cfg.length_kill_frac,
|
|
311
|
+
warn_field="tripwire.length_warn_frac",
|
|
312
|
+
kill_field="tripwire.length_kill_frac",
|
|
313
|
+
warn_mult=cfg.length_warn_mult,
|
|
314
|
+
kill_mult=cfg.length_kill_mult,
|
|
315
|
+
warn_mult_field="tripwire.length_warn_mult",
|
|
316
|
+
kill_mult_field="tripwire.length_kill_mult",
|
|
317
|
+
),
|
|
318
|
+
]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def evaluate_breaches(
|
|
322
|
+
cfg: TripwireConfig, baseline: TripwireBaseline, health: PolicyHealth
|
|
323
|
+
) -> list[TripwireBreach]:
|
|
324
|
+
"""Compare one step's health against the baseline bounds, both sides.
|
|
325
|
+
|
|
326
|
+
A metric with no measurement (None) is skipped rather than treated as zero:
|
|
327
|
+
a batch that sampled nothing is an infrastructure failure, and the
|
|
328
|
+
all-empty-batch abort owns it. The boundary belongs to the breach side (a
|
|
329
|
+
ratio exactly at the fraction or the multiple fires), so a threshold set to a
|
|
330
|
+
value that was actually observed is never silently inert.
|
|
331
|
+
|
|
332
|
+
Kill is checked before warn on BOTH sides before either warn is considered,
|
|
333
|
+
so a metric that has blown through a kill bound is never reported as a mere
|
|
334
|
+
warning. A ratio cannot breach a floor and a ceiling at once (the validator
|
|
335
|
+
keeps every fraction <= 1 < every multiple), so the two directions cannot
|
|
336
|
+
collide.
|
|
337
|
+
|
|
338
|
+
Args:
|
|
339
|
+
cfg: The run's `[tripwire]` section (its `enabled` flag is the caller's
|
|
340
|
+
business; this function always reports what it sees).
|
|
341
|
+
baseline: The run's captured baseline.
|
|
342
|
+
health: The step's batch-pooled health.
|
|
343
|
+
|
|
344
|
+
Returns:
|
|
345
|
+
At most one breach per metric, the more severe level when both bounds
|
|
346
|
+
are crossed; an empty list when nothing breached.
|
|
347
|
+
"""
|
|
348
|
+
breaches: list[TripwireBreach] = []
|
|
349
|
+
for reading in _readings(cfg, baseline, health):
|
|
350
|
+
ratio = metric_ratio(reading.value, reading.baseline)
|
|
351
|
+
if reading.value is None or ratio is None:
|
|
352
|
+
continue
|
|
353
|
+
level: TripwireLevel
|
|
354
|
+
direction: TripwireDirection
|
|
355
|
+
if ratio <= reading.kill_frac:
|
|
356
|
+
level, direction = "kill", "floor"
|
|
357
|
+
threshold, field = reading.kill_frac, reading.kill_field
|
|
358
|
+
elif ratio >= reading.kill_mult:
|
|
359
|
+
level, direction = "kill", "ceiling"
|
|
360
|
+
threshold, field = reading.kill_mult, reading.kill_mult_field
|
|
361
|
+
elif ratio <= reading.warn_frac:
|
|
362
|
+
level, direction = "warn", "floor"
|
|
363
|
+
threshold, field = reading.warn_frac, reading.warn_field
|
|
364
|
+
elif ratio >= reading.warn_mult:
|
|
365
|
+
level, direction = "warn", "ceiling"
|
|
366
|
+
threshold, field = reading.warn_mult, reading.warn_mult_field
|
|
367
|
+
else:
|
|
368
|
+
continue
|
|
369
|
+
breaches.append(
|
|
370
|
+
TripwireBreach(
|
|
371
|
+
metric=reading.metric,
|
|
372
|
+
level=level,
|
|
373
|
+
direction=direction,
|
|
374
|
+
baseline=reading.baseline,
|
|
375
|
+
value=reading.value,
|
|
376
|
+
ratio=ratio,
|
|
377
|
+
threshold_frac=threshold,
|
|
378
|
+
config_field=field,
|
|
379
|
+
)
|
|
380
|
+
)
|
|
381
|
+
return breaches
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def health_summary(health: PolicyHealth, baseline: TripwireBaseline | None) -> str:
|
|
385
|
+
"""The progress-line fragment for one step's health.
|
|
386
|
+
|
|
387
|
+
Args:
|
|
388
|
+
health: The step's batch-pooled health.
|
|
389
|
+
baseline: The run's baseline, when one is armed.
|
|
390
|
+
|
|
391
|
+
Returns:
|
|
392
|
+
A compact "entropy X (Rx baseline), gen Y tok/episode (Rx)" fragment,
|
|
393
|
+
with `n/a` in place of a statistic the batch could not measure and the
|
|
394
|
+
ratios omitted until a baseline exists.
|
|
395
|
+
"""
|
|
396
|
+
parts: list[str] = []
|
|
397
|
+
entropy_ratio = metric_ratio(
|
|
398
|
+
health.entropy_per_token, baseline.entropy_per_token if baseline else None
|
|
399
|
+
)
|
|
400
|
+
length_ratio = metric_ratio(
|
|
401
|
+
health.mean_generation_tokens, baseline.mean_generation_tokens if baseline else None
|
|
402
|
+
)
|
|
403
|
+
entropy = health.entropy_per_token
|
|
404
|
+
length = health.mean_generation_tokens
|
|
405
|
+
parts.append("entropy/token n/a" if entropy is None else f"entropy/token {entropy:.4f}")
|
|
406
|
+
if entropy_ratio is not None:
|
|
407
|
+
parts[-1] += f" ({entropy_ratio:.2f}x baseline)"
|
|
408
|
+
parts.append("gen tokens/episode n/a" if length is None else f"gen tokens/episode {length:.0f}")
|
|
409
|
+
if length_ratio is not None:
|
|
410
|
+
parts[-1] += f" ({length_ratio:.2f}x)"
|
|
411
|
+
return ", ".join(parts)
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
"""Exact per-token byte offsets for a token sequence, with no re-encoding.
|
|
2
|
+
|
|
3
|
+
Cross-tokenizer chunk alignment needs to know which BYTES of the decoded text
|
|
4
|
+
each token covers, on both the student side (the exact sampled ids, which
|
|
5
|
+
TITO forbids re-encoding) and the teacher side. Re-encoding is not an option
|
|
6
|
+
for the student: sampling routinely emits a NON-canonical BPE segmentation of
|
|
7
|
+
its own text (measured on the headline run's real sinks, `'S' + 'olver'` where
|
|
8
|
+
canonical BPE gives `'Solver'`), so `encode(decode(ids)) != ids` for 14% of
|
|
9
|
+
spans covering 42% of sampled tokens. Gating on that round trip would discard
|
|
10
|
+
those tokens as "unstable" when nothing about them is unstable.
|
|
11
|
+
|
|
12
|
+
Byte-level BPE vocabularies (the GPT-2 lineage, which both Qwen3.6 and
|
|
13
|
+
GLM-5.2 use) make the exact answer available directly: every ordinary token's
|
|
14
|
+
surface form is a reversible byte-level encoding of the bytes it covers, so
|
|
15
|
+
per-token byte lengths come from the vocabulary alone. `span_byte_ends`
|
|
16
|
+
reconstructs the span's bytes that way and VERIFIES the reconstruction against
|
|
17
|
+
the tokenizer's own decode before returning, so a vocabulary that does not
|
|
18
|
+
work this way (a SentencePiece tokenizer that normalizes text, say) is
|
|
19
|
+
reported as unusable instead of silently producing wrong offsets.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import logging
|
|
25
|
+
from typing import Protocol
|
|
26
|
+
|
|
27
|
+
logger = logging.getLogger(__name__)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ByteOffsetTokenizer(Protocol):
|
|
31
|
+
"""The tokenizer slice byte-offset reconstruction needs.
|
|
32
|
+
|
|
33
|
+
HuggingFace fast tokenizers satisfy this structurally, as do the small
|
|
34
|
+
deterministic fakes in the tests (extra defaulted parameters do not
|
|
35
|
+
matter).
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def convert_ids_to_tokens(self, ids: list[int]) -> list[str | None]:
|
|
39
|
+
"""The surface form of each token id; None for ids outside the vocab."""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
def decode(self, token_ids: list[int]) -> str:
|
|
43
|
+
"""Decode token ids back to text."""
|
|
44
|
+
...
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _byte_decoder() -> dict[str, int]:
|
|
48
|
+
"""The GPT-2 byte-level BPE surface-character to raw-byte map.
|
|
49
|
+
|
|
50
|
+
Byte-level BPE renders each of the 256 possible bytes as exactly one
|
|
51
|
+
printable character so a BPE vocabulary can be built over text. The map is
|
|
52
|
+
a fixed property of the scheme, not of any model, so it is derived here
|
|
53
|
+
rather than read from a tokenizer.
|
|
54
|
+
"""
|
|
55
|
+
printable = list(range(33, 127)) + list(range(161, 173)) + list(range(174, 256))
|
|
56
|
+
surface = list(printable)
|
|
57
|
+
spare = 0
|
|
58
|
+
for value in range(256):
|
|
59
|
+
if value not in printable:
|
|
60
|
+
printable.append(value)
|
|
61
|
+
surface.append(256 + spare)
|
|
62
|
+
spare += 1
|
|
63
|
+
return {chr(code): value for value, code in zip(printable, surface, strict=True)}
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
BYTE_DECODER = _byte_decoder()
|
|
67
|
+
"""Surface character to raw byte, for byte-level BPE token surface forms."""
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def token_bytes(surface: str) -> bytes | None:
|
|
71
|
+
"""The raw bytes one byte-level BPE token surface form stands for.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
surface: A token's surface form from `convert_ids_to_tokens`.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
The bytes the token covers, or None when the surface form contains a
|
|
78
|
+
character outside the byte-level alphabet. That is the normal case for
|
|
79
|
+
special and added tokens (`<|im_end|>`), whose surface form is their
|
|
80
|
+
literal text; callers fall back to encoding that text directly.
|
|
81
|
+
"""
|
|
82
|
+
try:
|
|
83
|
+
return bytes(BYTE_DECODER[char] for char in surface)
|
|
84
|
+
except KeyError:
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def span_byte_ends(
|
|
89
|
+
tokenizer: ByteOffsetTokenizer, token_ids: list[int]
|
|
90
|
+
) -> tuple[list[int], bytes] | None:
|
|
91
|
+
"""Cumulative byte-end offset per token, plus the span's decoded bytes.
|
|
92
|
+
|
|
93
|
+
Entry i is the byte offset just past token i, so token i covers
|
|
94
|
+
`bytes[ends[i - 1] : ends[i]]` (with `ends[-1] == 0` implied for i = 0).
|
|
95
|
+
The result is exact for the tokenizer's ACTUAL ids: nothing is re-encoded,
|
|
96
|
+
so a non-canonical sampled segmentation is handled like any other.
|
|
97
|
+
|
|
98
|
+
Args:
|
|
99
|
+
tokenizer: The tokenizer that produced `token_ids`.
|
|
100
|
+
token_ids: The span's token ids, in order.
|
|
101
|
+
|
|
102
|
+
Returns:
|
|
103
|
+
`(byte_ends, span_bytes)` when reconstruction agrees with the
|
|
104
|
+
tokenizer's own decode, else None. None means this tokenizer does not
|
|
105
|
+
expose reversible per-token bytes (it normalizes text, or a token id
|
|
106
|
+
is outside the vocabulary), so the caller must treat the span as
|
|
107
|
+
unscoreable rather than guess at offsets.
|
|
108
|
+
"""
|
|
109
|
+
if not token_ids:
|
|
110
|
+
return [], b""
|
|
111
|
+
surfaces = tokenizer.convert_ids_to_tokens(list(token_ids))
|
|
112
|
+
if len(surfaces) != len(token_ids):
|
|
113
|
+
logger.warning(
|
|
114
|
+
"tokenizer returned %d surface form(s) for %d token id(s); cannot "
|
|
115
|
+
"reconstruct byte offsets for this span",
|
|
116
|
+
len(surfaces),
|
|
117
|
+
len(token_ids),
|
|
118
|
+
)
|
|
119
|
+
return None
|
|
120
|
+
pieces: list[bytes] = []
|
|
121
|
+
for index, surface in enumerate(surfaces):
|
|
122
|
+
if surface is None:
|
|
123
|
+
logger.warning(
|
|
124
|
+
"token id %d at position %d has no surface form (outside the "
|
|
125
|
+
"vocabulary); cannot reconstruct byte offsets for this span",
|
|
126
|
+
token_ids[index],
|
|
127
|
+
index,
|
|
128
|
+
)
|
|
129
|
+
return None
|
|
130
|
+
raw = token_bytes(surface)
|
|
131
|
+
pieces.append(surface.encode("utf-8") if raw is None else raw)
|
|
132
|
+
span_bytes = b"".join(pieces)
|
|
133
|
+
# The reconstruction is only trustworthy if it reproduces what the
|
|
134
|
+
# tokenizer itself decodes. A normalizing tokenizer (SentencePiece
|
|
135
|
+
# whitespace rewriting, NFKC) fails here, which is exactly the signal the
|
|
136
|
+
# caller needs: alignment by byte offset is invalid for that vocabulary.
|
|
137
|
+
decoded = tokenizer.decode(list(token_ids))
|
|
138
|
+
if span_bytes != decoded.encode("utf-8"):
|
|
139
|
+
logger.warning(
|
|
140
|
+
"per-token byte reconstruction (%d bytes) does not match the "
|
|
141
|
+
"tokenizer's decode (%d bytes); this vocabulary does not expose "
|
|
142
|
+
"reversible per-token bytes, so the span cannot be byte-aligned",
|
|
143
|
+
len(span_bytes),
|
|
144
|
+
len(decoded.encode("utf-8")),
|
|
145
|
+
)
|
|
146
|
+
return None
|
|
147
|
+
ends: list[int] = []
|
|
148
|
+
total = 0
|
|
149
|
+
for piece in pieces:
|
|
150
|
+
total += len(piece)
|
|
151
|
+
ends.append(total)
|
|
152
|
+
return ends, span_bytes
|