chess_env / src /core /harness /capture /contract.py
sergiopaniego's picture
sergiopaniego HF Staff
Upload folder using huggingface_hub
7aca0bc verified
Raw History Blame Contribute Delete
14.9 kB
"""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,
}