File size: 14,898 Bytes
0b2c941
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
"""What a rollout hands a trainer.

The typed training contract is `to_training_trace`: one engine prompt, sampled completion,
behavior logprobs, and a loss mask over prompt+completion per retained model call. Prompt ids and
sampled ids are never reconstructed from text. Masks are authoritative after reconciliation.

`to_turn_records` provides the older three-array tuple for fully supervised completions. It skips
fully masked turns and rejects partial masks because a tuple cannot represent their eligibility.

`measure_retokenization_skew` stays, not as a cost estimate for a re-render you were going to do
anyway, but as the measurement that catches a producer which has quietly stopped emitting ids.

WHAT THIS MODULE CANNOT FILL IN. `TraceEntry` also declares `reward`, and nothing here emits it: a
capture proxy sees model calls, not task outcomes, and inventing a number would be worse than
omitting one. An ENVIRONMENT fills it in from its own `verify()`. `TraceEntry` is `total=False`, so
read it with `.get("reward")` — indexing it raises on every entry this module produces.
"""

from __future__ import annotations

import logging
from typing import Any

from ..training import TrainingTrace
from .graph import RolloutGraph, TurnNode
from .validate import check_turn, validate_training_turn

logger = logging.getLogger(__name__)

# Control text emitted by the proxy, never sampled or eligible for training.
BUDGET_STOP_MESSAGE = "Step budget exhausted; stopping."


def _usable(node: TurnNode) -> bool:
    """Whether this turn's sampled ids and logprobs describe the same tokens.

    The same guard `sequence_for` applies before masking a turn in, applied here too. These adapters
    walk the graph directly, so they used to emit `completion_token_ids` with every sampled id and
    `per_token_logps` as `[]` whenever ingest had rejected the logprobs — an unequal pair with no
    assertion anywhere, which a consumer zipping the two silently misattributes across every token of
    the turn.

    A turn that fails this is skipped rather than emitted short, and the caller logs how many, because
    `n_trainable_tokens` already excludes them and dropping them keeps the two counts consistent.
    """
    logprobs = node.sampled_logprobs
    return (
        bool(node.sampled_ids)
        and bool(logprobs)
        and len(logprobs) == len(node.sampled_ids)
        and check_turn(node.prompt_ids, node.sampled_ids, logprobs).ok
    )


def _require_trainable(document: dict[str, Any]) -> None:
    """Refuse to build a training contract out of an eval rollout.

    An eval document has an empty `sequences` list, so every converter here would return `[]` and the
    caller would receive a well-formed, empty contract — the exact silent-nothing failure this whole
    layer exists to prevent. Raising is the only honest answer: the data a trainer is asking for was
    never captured, and no amount of downstream care can reconstruct it.

    Raises:
        ValueError: If the document came from an endpoint that could not return token ids.
    """
    if document.get("rollout_type", "train") == "eval":
        level = document.get("capture_level") or "unknown"
        raise ValueError(
            f"this is an EVAL rollout (capture_level={level!r}): it carries the reward and the full "
            "trace, but no token ids or logprobs, so there is no training contract to build. Point "
            "the capture proxy at vLLM (--return-tokens-as-token-ids --logprobs-mode "
            "processed_logprobs) or SGLang built from main to get trainable rollouts."
        )


def _warn_skipped(nodes: list[TurnNode]) -> None:
    """Say out loud how many turns are being left out, and why."""
    skipped = [n.node_id for n in nodes if not _usable(n)]
    if skipped:
        logger.warning(
            "omitting %d of %d agent turn(s) from the training contract: their logprobs were "
            "rejected on ingest, so ids and logprobs do not describe the same tokens (%s)",
            len(skipped),
            len(nodes),
            ", ".join(skipped[:5]),
        )


def _agent_nodes(graph: RolloutGraph, document: dict[str, Any]) -> list[TurnNode]:
    """Every agent turn, in arrival order, excluding auxiliary calls and discarded retries.

    All agent sequences, not just the first. A rollout can have several: a harness that rewrites its
    system prompt mid-run starts a new root, and a fork produces several paths that share a prefix.
    Taking `agent_rows[0]` dropped the rest, so `to_turn_records` and `to_trace_entries` silently
    returned part of the rollout while reporting nothing wrong.

    Nodes are deduplicated because forked paths share their common prefix, and ordered by arrival so
    a turn's position matches the order the model produced it in.
    """
    keep: set[str] = set()
    for row in document["sequences"]:
        if row["role"] == "agent":
            keep.update(row["node_ids"])
    if not keep:
        return []
    return [n for n in graph.nodes() if n.node_id in keep]


def to_trace_entries(
    graph: RolloutGraph, document: dict[str, Any]
) -> list[dict[str, Any]]:
    """Rollout graph -> `list[TraceEntry]`, carrying the ENGINE's own prompt tokenization.

    Every entry includes `prompt_token_ids` and `loss_mask`, so a consumer never re-tokenizes. This
    used to emit neither, on the reasoning that a consumer could re-derive the prompt from
    `request`. It cannot: measured on Qwen3.5-4B over 28 live turns, a local re-render matched the
    engine on ZERO of them, and training on the difference collapsed a run at its first weight
    update. The ids were always here in `node.prompt_ids`; they were simply dropped at this
    boundary.

    `loss_mask` spans `prompt_token_ids + completion_token_ids`: 0 across the prompt (context) and 1
    at eligible sampled positions. Zero bits from reconciliation remain zero, including partial
    completion masks; eligibility is not inferable downstream.

    Auxiliary roots and discarded retries are already excluded here, so the caller does not need an
    `agent_turn_fn`. That hook exists because a flat trace cannot tell an aux call from an agent
    turn; a graph can, structurally.

    Raises:
        ValueError: If `document` is an eval rollout. See `_require_trainable`.
    """
    _require_trainable(document)
    entries = []
    nodes = _agent_nodes(graph, document)
    masks = _completion_masks(graph, document)
    _warn_skipped(nodes)
    for node in nodes:
        if not _usable(node):
            continue
        mask = [0] * len(node.prompt_ids) + masks[node.node_id]
        validate_training_turn(
            node.prompt_ids, node.sampled_ids, node.sampled_logprobs, mask
        )
        entries.append(
            {
                "request": {
                    "messages": node.request_messages,
                    "tools": node.request_tools,
                },
                "response": {
                    "choices": [
                        {
                            "message": node.response_message,
                            "finish_reason": node.finish_reason,
                        }
                    ]
                },
                "prompt_token_ids": node.prompt_ids,
                "completion_token_ids": node.sampled_ids,
                "per_token_logps": node.sampled_logprobs or [],
                # Deterministic here because `_usable` already skipped every turn whose logprobs
                # were rejected on ingest -- so anything that reaches this line is fully trainable,
                # and the mask is simply context across the prompt, train across the sample. The
                # field is emitted anyway rather than left for the consumer to synthesise: a
                # consumer that assumes "all sampled tokens are trainable" is right only because of
                # a filter it cannot see from here.
                "loss_mask": mask,
                "metadata": {
                    "node_id": node.node_id,
                    "index": node.index,
                    "model": node.model,
                    "n_tools": node.n_tools,
                    "harness_session_id": node.harness_session_id,
                    "sampling_params": dict(node.sampling_params),
                    "requested_sampling_params": dict(node.requested_sampling_params),
                },
            }
        )
    return entries


def _completion_masks(
    graph: RolloutGraph, document: dict[str, Any]
) -> dict[str, list[int]]:
    """Read authoritative masks after reconciliation, deduplicating shared nodes."""
    masks: dict[str, list[int]] = {}
    for row in document["sequences"]:
        if row["role"] != "agent":
            continue
        mask = row.get("loss_mask")
        if mask is None:
            # Older callers supply only graph membership; use the graph's validated mask.
            mask = graph.sequence_for(row["node_ids"][-1]).loss_mask
        for node_id in row["node_ids"]:
            node = graph.get(node_id)
            start = len(node.prompt_ids)
            span = list(mask[start : start + len(node.sampled_ids)])
            if len(span) != len(node.sampled_ids):
                raise ValueError(f"incomplete completion mask for node {node_id}")
            if node_id in masks and masks[node_id] != span:
                raise ValueError(
                    f"inconsistent completion masks for shared node {node_id}"
                )
            masks[node_id] = span
    return masks


def to_training_trace(graph: RolloutGraph, document: dict[str, Any]) -> TrainingTrace:
    """Return validated agent calls once each, including shared graph prefixes."""
    if any("[FATAL" in finding for finding in document.get("validation", [])):
        raise ValueError("cannot train a capture with fatal validation findings")
    _require_trainable(document)
    if any(not _usable(node) for node in _agent_nodes(graph, document)):
        raise ValueError("agent capture has missing or invalid engine tokens/logprobs")
    return TrainingTrace.from_entries(to_trace_entries(graph, document))


def to_turn_records(
    graph: RolloutGraph, document: dict[str, Any]
) -> list[tuple[list[int], list[int], list[float]]]:
    """Rollout graph -> `(prompt_ids, output_ids, output_log_probs)` per turn, losslessly.

    For fully supervised completions only. Fully masked calls are skipped; partial masks raise
    rather than silently restoring supervision. Use `to_trace_entries` for the mask-aware contract.

    Raises:
        ValueError: If `document` is an eval rollout. See `_require_trainable`.
    """
    _require_trainable(document)
    nodes = _agent_nodes(graph, document)
    _warn_skipped(nodes)
    masks = _completion_masks(graph, document)
    if any(any(mask) and not all(mask) for mask in masks.values()):
        raise ValueError(
            "to_turn_records cannot represent partial masks; use to_trace_entries"
        )
    return [
        (node.prompt_ids, node.sampled_ids, node.sampled_logprobs or [])
        for node in nodes
        if _usable(node) and any(masks[node.node_id])
    ]


def measure_retokenization_skew(
    graph: RolloutGraph,
    document: dict[str, Any],
    tokenizer,
    *,
    chat_template: str | None = None,
    chat_template_kwargs: dict[str, Any] | None = None,
) -> dict[str, Any]:
    """Compare TRL's re-tokenized prompt against the engine's, per turn. The number nobody has had.

    Reproduces `_turns_from_trace` exactly, including the `_decode_tool_call_arguments` step (the
    trace stores tool-call `arguments` as a JSON string; XML-style templates such as Qwen3.5's
    iterate it and raise on a string).

    Returns per-turn exact-match, common-prefix length and length delta. `exact_match_frac == 1.0`
    means re-tokenization is safe for this model+harness pair and the hook buys nothing. Anything
    less means TRL is training on a prompt the model never saw, and `_chain_to_sequences` will fork
    the conversation at the first divergence.
    """
    import json as _json

    def decode_arguments(messages):
        out = []
        for message in messages:
            calls = message.get("tool_calls")
            if not calls:
                out.append(message)
                continue
            new = []
            for call in calls:
                function = call.get("function")
                arguments = (function or call).get("arguments")
                if not isinstance(arguments, str):
                    new.append(call)
                    continue
                try:
                    arguments = _json.loads(arguments)
                except _json.JSONDecodeError:
                    arguments = {}
                new.append(
                    {**call, "function": {**function, "arguments": arguments}}
                    if function
                    else {**call, "arguments": arguments}
                )
            out.append({**message, "tool_calls": new})
        return out

    def prefix_len(a, b):
        n = min(len(a), len(b))
        i = 0
        while i < n and a[i] == b[i]:
            i += 1
        return i

    turns = []
    for i, node in enumerate(_agent_nodes(graph, document)):
        try:
            rebuilt = tokenizer.apply_chat_template(
                decode_arguments(node.request_messages),
                tools=node.request_tools,
                add_generation_prompt=True,
                tokenize=True,
                return_dict=False,
                chat_template=chat_template,
                **(chat_template_kwargs or {}),
            )
        except Exception as exc:  # noqa: BLE001 - a template that raises IS the finding
            turns.append(
                {
                    "turn": i,
                    "error": f"{type(exc).__name__}: {str(exc)[:160]}",
                    "engine_len": len(node.prompt_ids),
                }
            )
            continue
        turns.append(
            {
                "turn": i,
                "engine_len": len(node.prompt_ids),
                "rebuilt_len": len(rebuilt),
                "delta": len(rebuilt) - len(node.prompt_ids),
                "prefix_match": prefix_len(node.prompt_ids, rebuilt),
                "exact": list(rebuilt) == list(node.prompt_ids),
            }
        )

    scored = [t for t in turns if "exact" in t]
    return {
        "n_turns": len(turns),
        "n_errors": len(turns) - len(scored),
        "exact_match_frac": (sum(t["exact"] for t in scored) / len(scored))
        if scored
        else 0.0,
        "max_abs_delta": max((abs(t["delta"]) for t in scored), default=0),
        "min_prefix_match_frac": min(
            (t["prefix_match"] / max(t["engine_len"], 1) for t in scored), default=0.0
        ),
        "turns": turns,
    }