Download src/core/harness/capture/contract.py from openenv/chess_env: direct link, hf CLI and curl.
- Browser
- Download file 14.9 kB
-
https://huggingface.co/spaces/openenv/chess_env/resolve/main/src/core/harness/capture/contract.py
- Command line
-
hf download hf://spaces/openenv/chess_env/src/core/harness/capture/contract.py
-
curl -L -o contract.py https://huggingface.co/spaces/openenv/chess_env/resolve/main/src/core/harness/capture/contract.py
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, | |
| } | |