-
Notifications
You must be signed in to change notification settings - Fork 579
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 2 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,13 @@ | ||||||
| [project] | ||||||
| name = "replay" | ||||||
| version = "0.1.0" | ||||||
| description = "replay — a replay-buffer taskset that resumes old rollouts from compaction points." | ||||||
| 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] | ||||||
| packages = ["replay"] | ||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,8 @@ | ||
| """replay — resume old rollouts from tagged compaction points (a replay buffer). | ||
|
|
||
| Option B: compaction tags are read from the typed ``MessageNode.kind`` field (see | ||
| ``replay/selector.py``).""" | ||
|
|
||
| from replay.taskset import ReplayTask, ReplayTaskset, ReplayTasksetConfig | ||
|
|
||
| __all__ = ["ReplayTaskset", "ReplayTasksetConfig", "ReplayTask"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| """Consumer-side resume-point selection for the replay buffer (Option B: ``MessageNode`` fields). | ||
|
|
||
| Reads the compaction tags the producing harness stamped on ``MessageNode.kind`` and turns them | ||
| into resume points the ReplayTaskset can sample. ``seed_messages`` builds the replay prefix (the | ||
| root->node path); ``snapshot_ref_of`` returns the durable sandbox handle to restore (None until | ||
| per-turn snapshot capture is wired). | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| from verifiers.v1.trace import Trace | ||
| from verifiers.v1.types import Messages | ||
|
|
||
|
|
||
| def get_tag(trace: Trace, node_id: int) -> str | None: | ||
| """Read a node's replay tag (Option B: from the typed ``MessageNode.kind`` field).""" | ||
| return trace.nodes[node_id].kind | ||
|
|
||
|
|
||
| def snapshot_ref_of(trace: Trace, node_id: int) -> str | None: | ||
| """Durable sandbox snapshot ref for a node (Option B: from ``MessageNode.snapshot_ref``). | ||
| None when snapshotting was off/unsupported (the skeleton never captures one yet).""" | ||
| return trace.nodes[node_id].snapshot_ref | ||
|
|
||
|
|
||
| def seed_messages(trace: Trace, node_id: int) -> Messages: | ||
| """The replay prefix: messages along root->node_id, in order.""" | ||
| path: Messages = [] | ||
| nid: int | None = node_id | ||
| while nid is not None: | ||
| path.append(trace.nodes[nid].message) | ||
| nid = trace.nodes[nid].parent | ||
| path.reverse() | ||
| return path | ||
|
|
||
|
|
||
| def resume_points(trace: Trace, *, kinds: set[str]) -> list[dict]: | ||
| """Compaction resume points whose tag is in ``kinds``. Each: ``node`` id and ``kind`` | ||
| ("compaction_before" | "compaction_after").""" | ||
| points: list[dict] = [] | ||
| for nid in range(len(trace.nodes)): | ||
| tag = get_tag(trace, nid) | ||
| if tag in kinds: | ||
| points.append({"node": nid, "kind": tag}) | ||
| return points |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| """A minimal replay-buffer taskset (skeleton). | ||
|
|
||
| Materializes replay tasks from a buffer of stored rollouts (offline mode): each compaction | ||
| resume point in each stored trace becomes one task whose ``prompt`` is the replay prefix | ||
| (root->resume node). Run it with the default harness (``--harness.id default``), which accepts | ||
| a Messages prompt and continues the rollout from the prefix: | ||
|
|
||
| - ``compaction_after`` -> the prefix ends at the compaction message; the model continues solving. | ||
| - ``compaction_before`` -> the prefix is the pre-compaction context; the model first writes the | ||
| compaction (the compact system prompt instructs it), then continues. | ||
|
|
||
| Scoring reuses the ORIGINAL task's verifier (skeleton stub below). Exec/sandbox envs restore the | ||
| resume point's snapshot in ``setup`` before the harness runs (skeleton: ref is None -> no-op). | ||
|
|
||
| NOTE (online/growing buffer): ``load_tasks`` runs once at env-server start, so this offline | ||
| materialization sees only the buffer present at startup. The online variant samples a stored | ||
| trace + resume point per rollout in a custom replay harness (rollout-time), keeping num_tasks | ||
| virtual; left as a follow-up. | ||
| """ | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import glob | ||
| import json | ||
|
|
||
| from verifiers.v1.decorators import reward | ||
| from verifiers.v1.runtimes import Runtime | ||
| from verifiers.v1.task import Task | ||
| from verifiers.v1.taskset import Taskset, TasksetConfig | ||
| from verifiers.v1.trace import Trace, WireTrace | ||
|
|
||
| from replay.selector import resume_points, seed_messages, snapshot_ref_of | ||
|
|
||
|
|
||
| class ReplayTask(Task): | ||
| """A replayed prefix plus the provenance needed to score it with the original verifier.""" | ||
|
|
||
| source_trace_id: str = "" | ||
| resume_node: int = -1 | ||
| resume_kind: str = "" | ||
| snapshot_ref: str | None = None | ||
| original_task: dict = {} | ||
| original_reward: float = 0.0 | ||
|
|
||
|
|
||
| class ReplayTasksetConfig(TasksetConfig): | ||
|
macroscopeapp[bot] marked this conversation as resolved.
Outdated
|
||
| buffer_glob: str = "" | ||
| """Glob of stored-rollout JSONL files, e.g. ``.../rollouts/step_*/train_rollouts.jsonl``.""" | ||
| kinds: list[str] = ["compaction_after", "compaction_before"] | ||
| """Which resume-point kinds to materialize tasks from.""" | ||
|
|
||
|
|
||
| class ReplayTaskset(Taskset[ReplayTask, ReplayTasksetConfig]): | ||
| def load_tasks(self) -> list[ReplayTask]: | ||
| tasks: list[ReplayTask] = [] | ||
| kinds = set(self.config.kinds) | ||
| for path in sorted(glob.glob(self.config.buffer_glob)): | ||
| with open(path) as f: | ||
| for line in f: | ||
| line = line.strip() | ||
| if not line: | ||
| continue | ||
| src: Trace = WireTrace.model_validate(json.loads(line)) | ||
| for pt in resume_points(src, kinds=kinds): | ||
| tasks.append( | ||
| ReplayTask( | ||
| idx=len(tasks), | ||
| prompt=seed_messages(src, pt["node"]), | ||
| source_trace_id=src.id, | ||
| resume_node=pt["node"], | ||
| resume_kind=pt["kind"], | ||
| 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: | ||
| # Exec/sandbox replay: restore the sandbox to the resume point before the harness runs. | ||
| # Skeleton: snapshot capture isn't wired yet, so ref is None -> nothing to restore. | ||
| if task.snapshot_ref is not None: | ||
| await runtime.restore(task.snapshot_ref) | ||
|
|
||
| @reward | ||
| def replay_reward(self, trace: Trace, task: ReplayTask) -> float: | ||
| # TODO: reuse the ORIGINAL env's verifier — reconstruct its taskset + the original Task | ||
| # from ``task.original_task`` and delegate (``inner.score(trace, runtime)``); for the | ||
| # judge-style mode, compare the model's verdict against ``task.original_reward``. | ||
| # Skeleton placeholder so the env is runnable end-to-end. | ||
| return 0.0 | ||
|
|
||
|
|
||
| __all__ = ["ReplayTaskset", "ReplayTasksetConfig", "ReplayTask"] | ||
|
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. 🟡 Medium verifiers/verifiers/v1/graph.py Line 128 in a8df793
🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -21,7 +21,7 @@ | |
| import hashlib | ||
| import json | ||
| from dataclasses import dataclass | ||
| from typing import TYPE_CHECKING, Any | ||
| from typing import TYPE_CHECKING, Any, Literal | ||
|
|
||
| import numpy as np | ||
| from pydantic import ConfigDict, Field, field_serializer, field_validator | ||
|
|
@@ -60,6 +60,14 @@ def _decode_ndarray(d: dict) -> np.ndarray: | |
| return np.frombuffer(d["data"], dtype=np.dtype(d["dtype"])).reshape(d["shape"]) | ||
|
|
||
|
|
||
| NodeTag = Literal["compaction_before", "compaction_after", "subagent"] | None | ||
| """Replay resume-point tag for a node — branch provenance the typed graph can't otherwise | ||
| express. ``compaction_before``/``compaction_after`` mark the two replay points around a | ||
| compaction (regenerate it vs. continue from it); ``subagent`` marks a subagent fork. Written by | ||
| the harness during generation, read by the replay-buffer resume-point selector. None for an | ||
| ordinary node.""" | ||
|
|
||
|
|
||
| class MessageNode(StrictBaseModel): | ||
| """One message in the graph: a message plus the tokens it adds to the cumulative | ||
| sequence. Concatenating a root→leaf path's nodes reconstructs that branch's full token | ||
|
|
@@ -73,6 +81,16 @@ class MessageNode(StrictBaseModel): | |
| """True iff a model call produced this message (the response passed to `commit`); False for | ||
| every prompt-supplied message — including assistant/tool messages fabricated as context | ||
| the model never generated, which role alone can't tell apart from real turns.""" | ||
| kind: NodeTag = None | ||
|
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. 🟡 Medium Because 🚀 Reply "fix it for me" or copy this AI Prompt for your agent: |
||
| """Replay resume-point tag, written by the harness during generation: branch provenance | ||
| (compaction_before/compaction_after/subagent); None for an ordinary node. Read by the | ||
| replay-buffer resume-point selector. A plain str/None, so it rides the wire and the JSON | ||
| dump automatically (no `_NODE_DUMP_EXCLUDE` entry needed).""" | ||
| snapshot_ref: str | None = None | ||
| """Durable handle to the sandbox state captured after this node's turn (registry tag / | ||
| modal snapshot id / object-store key); restore it to resume a replay from here. Written by | ||
| the per-turn snapshot hook during generation; None when snapshotting was off/unsupported. | ||
| A plain str/None, so it rides the wire and the JSON dump automatically.""" | ||
| token_ids: list[int] = Field(default_factory=list) | ||
| """This message's delta contribution to the cumulative token sequence: its leading | ||
| template scaffold + its own tokens — for an assistant, the generation-prompt scaffold | ||
|
|
||
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: