-
Notifications
You must be signed in to change notification settings - Fork 580
Option B: typed MessageNode.kind for replay resume points #1896
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a8df793
ab09c16
4f7e820
4a65a3d
21c1545
8a1ab79
ecddd73
e3e6262
7b79984
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,42 @@ | ||
| """Producer-side helpers: locate the compaction resume points a harness should tag. | ||
|
|
||
| A compacting rollout splits into branches (one per context rewrite). Each compaction exposes | ||
| two replay resume points: | ||
|
|
||
| - ``compaction_after`` — the post-compaction branch start (the rewritten ``[system, user(notes)]``). | ||
| Resuming here, the model continues solving *from* the compaction message. | ||
| - ``compaction_before`` — the leaf of the branch that compaction summarized (the prior turn's | ||
| response). Resuming here, the model is back in the pre-compaction context and its continuation | ||
| *writes* the compaction itself (then keeps solving). | ||
|
|
||
| This module only *finds* the nodes; where the tag is stored is the A/B decision (Option A: | ||
| ``trace.info``; Option B: ``MessageNode.kind``) and lives at the harness write site. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from verifiers.v1 import graph | ||
| from verifiers.v1.trace import Trace | ||
|
|
||
|
|
||
| def compaction_after_nodes(trace: Trace) -> list[int]: | ||
| """Post-compaction branch starts: the first node of each forked branch. A node with >1 | ||
| child is a fork point; ``children[0]`` is the original line, ``children[1:]`` are the | ||
| rewritten (post-compaction) branches. The compacting harness rewrites every turn, so each | ||
| is a compaction boundary.""" | ||
| children: dict[int | None, list[int]] = {} | ||
| for nid, node in enumerate(trace.nodes): | ||
| children.setdefault(node.parent, []).append(nid) | ||
| starts: list[int] = [] | ||
| for kids in children.values(): | ||
| if len(kids) > 1: | ||
| starts.extend(kids[1:]) | ||
| return starts | ||
|
|
||
|
|
||
| def compaction_before_nodes(trace: Trace) -> list[int]: | ||
| """Pre-compaction points: every branch leaf except the final-answer branch. Each such leaf | ||
| is the turn whose output was summarized into the next branch's compaction message, so | ||
| resuming there puts the model right before it writes a compaction.""" | ||
| leaves = sorted(graph.leaves(trace)) | ||
| return leaves[:-1] if len(leaves) > 1 else [] |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,21 @@ | ||||||
| [project] | ||||||
| name = "replay" | ||||||
| version = "0.1.0" | ||||||
| description = "replay — replay-buffer tasksets (recheck / judge / compaction_before / compaction_after)." | ||||||
| requires-python = ">=3.10" | ||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟠 High
Suggested change
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||||||
| dependencies = ["verifiers"] | ||||||
|
|
||||||
| [build-system] | ||||||
| requires = ["hatchling"] | ||||||
| build-backend = "hatchling.build" | ||||||
|
|
||||||
| [tool.hatch.build.targets.wheel] | ||||||
| # One distribution ships five top-level modules: the shared library + the four selectable | ||||||
| # tasksets (each a taskset id, e.g. `--taskset.id replay_recheck`). | ||||||
| packages = [ | ||||||
| "replay_common", | ||||||
| "replay_recheck", | ||||||
| "replay_judge", | ||||||
| "replay_compaction_after", | ||||||
| "replay_compaction_before", | ||||||
| ] | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,23 @@ | ||
| """replay_common — shared base for the replay-buffer tasksets. | ||
|
|
||
| The four selectable tasksets live in sibling top-level modules, each fixing one ``KIND``: | ||
| ``replay_recheck``, ``replay_judge``, ``replay_compaction_after``, ``replay_compaction_before``. | ||
| This module is the shared library (buffer sourcing, seeding, scoring); it is not itself a | ||
| selectable taskset. | ||
| """ | ||
|
|
||
| from replay_common.base import ( | ||
| BaseReplayHarness, | ||
| BaseReplayTaskset, | ||
| ReplayConfig, | ||
| ReplayHarnessConfig, | ||
| ReplayTask, | ||
| ) | ||
|
|
||
| __all__ = [ | ||
| "BaseReplayTaskset", | ||
| "BaseReplayHarness", | ||
| "ReplayConfig", | ||
| "ReplayHarnessConfig", | ||
| "ReplayTask", | ||
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,227 @@ | ||
| """Shared base for the replay-buffer tasksets. | ||
|
|
||
| Each concrete taskset/harness fixes one ``KIND`` (``recheck`` / ``judge`` / ``compaction_after`` | ||
| / ``compaction_before``) and lives in its own selectable module (``replay_recheck``, ...). This | ||
| base holds everything they share: buffer sourcing (offline materialize + online sampling), | ||
| seed building, snapshot restore, and scoring. | ||
|
|
||
| Sourcing (``config.mode``): | ||
| - ``offline`` — ``load_tasks`` materializes one task per resume point of this taskset's ``KIND``; | ||
| the bundled harness seeds the chat loop from the task's prompt. | ||
| - ``online`` — ``load_tasks`` returns ``pool_size`` virtual slots and the harness samples a | ||
| ``KIND`` resume point from the *live* buffer per rollout. | ||
|
|
||
| Scoring: | ||
| - ``recheck`` / ``compaction_*`` reuse the ORIGINAL env's verifier (``config.inner``). | ||
| - ``judge`` grades the model's verdict against the original rollout's reward (no inner verifier). | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import glob | ||
| import json | ||
| import random | ||
| from pathlib import Path | ||
| from typing import ClassVar, Literal | ||
|
|
||
| from pydantic import SerializeAsAny | ||
|
|
||
| from verifiers.v1.clients import RolloutContext | ||
| from verifiers.v1.dialects.chat import message_to_wire | ||
| from verifiers.v1.harness import Harness, HarnessConfig | ||
| from verifiers.v1.harnesses.default.harness import PROGRAM_SOURCE | ||
| from verifiers.v1.loaders import load_taskset | ||
| from verifiers.v1.runtimes import ProgramResult, Runtime | ||
| from verifiers.v1.task import Task, WireTask | ||
| from verifiers.v1.taskset import Taskset, TasksetConfig | ||
| from verifiers.v1.trace import Trace, WireTrace | ||
| from verifiers.v1.types import Messages | ||
|
|
||
| from replay_common.selector import ( | ||
| DEFAULT_FOLLOWUP, | ||
| build_seed, | ||
| iter_traces, | ||
| resume_points, | ||
| snapshot_ref_of, | ||
| ) | ||
|
|
||
|
|
||
| class ReplayTask(Task): | ||
| """A replayed seed plus the provenance needed to score it (offline mode; online mode carries | ||
| the same provenance in ``trace.info["replay"]``).""" | ||
|
|
||
| source_trace_id: str = "" | ||
| resume_node: int = -1 | ||
| snapshot_ref: str | None = None | ||
| original_task: dict = {} | ||
| original_reward: float = 0.0 | ||
|
|
||
|
|
||
| class ReplayConfig(TasksetConfig): | ||
| mode: Literal["offline", "online"] = "offline" | ||
| buffer_glob: str = "" | ||
| """Glob of stored-rollout JSONL files, e.g. ``.../rollouts/step_*/train_rollouts.jsonl``.""" | ||
| followup: str = DEFAULT_FOLLOWUP | ||
| """The user turn appended for the ``recheck`` taskset.""" | ||
| judge_threshold: float = 0.5 | ||
| """``judge`` taskset: the original rollout counts as correct when ``original_reward >`` this.""" | ||
| pool_size: int = 1024 | ||
| """Online mode: number of virtual task slots (num_tasks); the harness samples per rollout.""" | ||
| inner: SerializeAsAny[TasksetConfig] = TasksetConfig() | ||
| """The ORIGINAL env's taskset (the verifier to reuse). Empty id => no inner scoring.""" | ||
|
|
||
|
|
||
| class ReplayHarnessConfig(HarnessConfig): | ||
| buffer_glob: str = "" | ||
| """Online mode: the live buffer to sample from.""" | ||
| followup: str = DEFAULT_FOLLOWUP | ||
|
|
||
|
|
||
| def _parse_verdict(trace: Trace) -> bool | None: | ||
| """The model's yes/no judgment from its last response (``judge``). None if unclear.""" | ||
| msgs = trace.assistant_messages | ||
| text = (msgs[-1].content or "").strip().lower() if msgs else "" | ||
| if text.startswith("yes") or text.startswith("correct"): | ||
| return True | ||
| if text.startswith("no") or text.startswith("incorrect"): | ||
| return False | ||
| has_yes, has_no = "yes" in text, "no" in text | ||
| return has_yes if has_yes != has_no else None | ||
|
|
||
|
|
||
| class BaseReplayTaskset(Taskset[ReplayTask, ReplayConfig]): | ||
| """Concrete subclasses set ``KIND``; everything else is shared.""" | ||
|
|
||
| KIND: ClassVar[str] = "" | ||
|
|
||
| def __init__(self, config: ReplayConfig) -> None: | ||
| super().__init__(config) | ||
| # Reuse the original env's verifier when configured (empty id => no inner scoring). | ||
| self._inner: Taskset | None = load_taskset(config.inner) if config.inner.id else None | ||
|
|
||
| def load_tasks(self) -> list[ReplayTask]: | ||
| if self.config.mode == "online": | ||
| return [ReplayTask(idx=i, prompt=None) for i in range(self.config.pool_size)] | ||
| tasks: list[ReplayTask] = [] | ||
| for src in iter_traces(self.config.buffer_glob): | ||
| for pt in resume_points(src, kinds={self.KIND}): | ||
| tasks.append( | ||
| ReplayTask( | ||
| idx=len(tasks), | ||
| prompt=build_seed(src, pt, self.config.followup), | ||
| source_trace_id=src.id, | ||
| resume_node=pt["node"], | ||
| snapshot_ref=snapshot_ref_of(src, pt["node"]), | ||
| original_task=src.task.model_dump(), | ||
| original_reward=src.reward, | ||
| ) | ||
| ) | ||
| return tasks | ||
|
|
||
| async def setup(self, task: ReplayTask, runtime: Runtime) -> None: | ||
| # Offline exec/sandbox replay: restore to the resume point before the harness runs. | ||
| # (Online restores inside the harness; judge needs no sandbox.) Skeleton: refs are None. | ||
| if task.snapshot_ref is not None and self.KIND != "judge": | ||
| await runtime.restore(task.snapshot_ref) | ||
|
|
||
| async def score(self, trace: Trace, runtime: Runtime) -> None: | ||
| replay = trace.info.get("replay") # online: harness-stashed provenance | ||
| original_dump = replay["original_task"] if replay else getattr(trace.task, "original_task", {}) | ||
| original_reward = replay["original_reward"] if replay else getattr(trace.task, "original_reward", 0.0) | ||
|
|
||
| if self.KIND == "judge": | ||
| verdict = _parse_verdict(trace) | ||
| correct = original_reward > self.config.judge_threshold | ||
| trace.record_reward("judge_match", 1.0 if verdict == correct else 0.0) | ||
| return | ||
|
|
||
| # recheck / compaction_*: reuse the ORIGINAL verifier with the original task swapped in. | ||
| if self._inner is None or not original_dump: | ||
| return | ||
| replay_task = trace.task | ||
| trace.task = WireTask.model_validate(original_dump) | ||
| try: | ||
| await self._inner.score(trace, runtime) | ||
| finally: | ||
| trace.task = replay_task | ||
|
|
||
|
|
||
| class BaseReplayHarness(Harness[ReplayHarnessConfig]): | ||
| """Seeds the default chat-loop program (``PROGRAM_SOURCE``) from a replay prefix. Offline: the | ||
| task already carries the materialized seed. Online: sample a ``KIND`` resume point from the | ||
| live buffer. Concrete subclasses set ``KIND``.""" | ||
|
|
||
| KIND: ClassVar[str] = "" | ||
| SUPPORTS_MESSAGE_PROMPT = True | ||
| SUPPORTS_MCP = True | ||
|
|
||
| async def setup(self, runtime: Runtime) -> None: | ||
| await runtime.prepare_uv_script(PROGRAM_SOURCE, self.config.env) | ||
|
|
||
| async def launch( | ||
| self, | ||
| ctx: RolloutContext, | ||
| trace: Trace, | ||
| runtime: Runtime, | ||
| endpoint: str, | ||
| secret: str, | ||
| mcp_urls: dict[str, str], | ||
| ) -> ProgramResult: | ||
| seed = await self._resolve_seed(trace, runtime) | ||
| if seed is None: # warmup: live buffer empty / no matching points yet | ||
| return ProgramResult(exit_code=0, stdout="", stderr="") | ||
| env = {**self.config.env} | ||
| env["INITIAL_MESSAGES"] = json.dumps([message_to_wire(m) for m in seed]) | ||
| args = [f"--base-url={endpoint}", f"--api-key={secret}", f"--model={ctx.model}"] | ||
| if mcp_urls: | ||
| args.append( | ||
| "--mcp-config=" | ||
| + json.dumps({"mcpServers": {n: {"url": u} for n, u in mcp_urls.items()}}) | ||
| ) | ||
| program = await runtime.prepare_uv_script(PROGRAM_SOURCE, self.config.env) | ||
| return await runtime.run_program([*program, *args], env) | ||
|
|
||
| async def _resolve_seed(self, trace: Trace, runtime: Runtime) -> Messages | None: | ||
| if trace.task.prompt is not None: # offline: task carries the materialized seed | ||
| return list(trace.task.prompt) | ||
| # online: sample a KIND resume point from the live buffer. Seed the RNG with the task | ||
| # index (shared across a GRPO group), NOT the rollout id, so every rollout of a group | ||
| # replays the SAME source+point — N diverse continuations of one point, which is what the | ||
| # group-relative baseline needs. Freshness still comes from the buffer growing: glob picks | ||
| # up new files, which shifts each index's draw over time. | ||
| sample = self._sample(random.Random(trace.task.idx)) | ||
| if sample is None: | ||
| trace.stop("replay_buffer_empty") | ||
| return None | ||
| src, point = sample | ||
| ref = snapshot_ref_of(src, point["node"]) | ||
| if ref is not None and self.KIND != "judge": # judge needs no sandbox; skeleton refs None | ||
| await runtime.restore(ref) | ||
| trace.info["replay"] = { | ||
| "source_id": src.id, | ||
| "resume_node": point["node"], | ||
| "kind": point["kind"], | ||
| "original_task": src.task.model_dump(), | ||
| "original_reward": src.reward, | ||
| } | ||
| return build_seed(src, point, self.config.followup) | ||
|
|
||
| def _sample(self, rng: random.Random) -> tuple[Trace, dict] | None: | ||
| """Scan the live buffer in random order; return the first (trace, ``KIND`` point) found.""" | ||
| files = sorted(glob.glob(self.config.buffer_glob)) | ||
|
macroscopeapp[bot] marked this conversation as resolved.
|
||
| rng.shuffle(files) | ||
| for path in files: | ||
| try: | ||
| lines = Path(path).read_text().splitlines() | ||
| except OSError: | ||
| continue | ||
| rng.shuffle(lines) | ||
| for line in lines: | ||
| line = line.strip() | ||
| if not line: | ||
| continue | ||
| src = WireTrace.model_validate(json.loads(line)) | ||
| points = resume_points(src, kinds={self.KIND}) | ||
| if points: | ||
| return src, rng.choice(points) | ||
| return None | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🟡 Medium
compact/harness.py:56launchunconditionally overwritestrace.nodes[*].kindafterruntime.run_program(...)returns, before the caller checksresult.exit_code. When the compact harness crashes or exits non-zero after producing a partial trace, the compaction resume tags are still persisted on an errored rollout.ReplayTaskset.load_tasks()reads these tags from every stored trace without filteringtrace.error, so failed or incomplete compact rollouts generate replay tasks that resume from bogus prefixes. Consider skipping the tagging whenresult.exit_codeindicates failure, or guardingload_tasks()to skip traces withtrace.error.🚀 Reply "fix it for me" or copy this AI Prompt for your agent: