feat(v1): replay taskset — re-enter saved rollouts as continue/recheck tasks#1933
feat(v1): replay taskset — re-enter saved rollouts as continue/recheck tasks#1933faresobeid wants to merge 11 commits into
Conversation
…k tasks Loads rollout records (Trace.to_record() JSONL) and turns each into a task that re-enters the original task mid-way, delegating tools, user, lifecycle, and scoring to the source taskset loaded by id — so new completions are judged by the original env's verifier with its real toolkit. - mode=continue, anchor=compaction: one task per context restart, detected structurally from the branch graph (no compaction-prompt matching, so records from different harnesses mix freely); the canonical [system, user] restart lowers to a string prompt and runs under any harness (incl. rlm) - mode=continue, anchor=tool-call: one deterministically-drawn resume point per rollout, right after a complete tool-result run (Messages prompt; needs a message-seeding harness) - mode=recheck: the final branch with truncation artifacts stripped plus a verification turn; sampled-only trace views mean rewards score the revision - sandbox snapshot seam: records may carry trace.info["snapshots"] refs per anchor node; seeds restrict to snapshotted anchors and setup restores via restore_snapshot() (fails loudly until runtimes support capture) - new recycling schemes subclass ReplayTaskset and override seeds() Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…round-trips, seed edge cases Adversarial review of the replay taskset surfaced structural issues; fixes: - Environment's harness-capability gates now read taskset instance properties (defines_tools / defines_user) instead of class-override identity, and the rollout's state type comes from taskset.state_type() — a wrapping taskset (replay) delegates all three to its source, so replay runs under rlm and other capability-limited harnesses and stateful sources keep their typed per-rollout State - ReplayConfig validation freezes record globs to the concrete matched files, so every env-server pool worker (elastic pools spawn workers mid-run) builds the identical task list; the recheck/anchor conflict check compares against the default rather than model_fields_set, so a dumped resolved config re-validates cleanly in pool workers - seed builders enforce the snapshots contract everywhere (a record carrying refs offers only snapshotted anchors) with the ref keyed to the node the resume re-enters at; recheck truncates at the first partially-answered tool run (a dangling call makes the replayed prompt malformed); compaction detection skips the task's own launch context, so an auxiliary same-system branch sorting first can't fabricate a restart - default/null harnesses ship a Messages prompt as a file in the runtime instead of the INITIAL_MESSAGES env var — replayed histories are exactly the long contexts that overflow MAX_ARG_STRLEN - config-resolution, glob-freeze, and delegation tests pin the above Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lumbing, cheaper record loading Applies a reuse/simplification/efficiency/altitude review of the replay feature: - hook discovery becomes a Taskset seam: stops() and defines_group_rewards() (methods, not properties — getmembers evaluates properties during the discovery walk, so a discovery-running property recurses) plus needs_container; rollout/server/episode/env/validate call the seams and replay overrides them, replacing the bound-method setattr grafting in load_tasks - Messages-prompt file transport hoisted to Harness.write_message_prompt (shared INITIAL_MESSAGES_FILE), used by default+null; stale env-var comment in the null harness dropped; programs read via Path.read_text - expand_records() is the one shared glob expansion (config freeze + load_tasks); foreign records are filtered on task fields alone before the full-trace validation (nodes with token ids are far too big to validate for a line we discard); estimate_tokens estimates from message text instead of re-serializing every message - restore_snapshot docstring records the endgame (promote the ref to a base Task field alongside image/workdir when snapshot support lands) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… source replay.follow=true skips the validation-time glob freeze and makes load_tasks incremental and append-only (numeric-aware file order, only new files parsed, tasks only ever appended) so a run can replay its own rollouts as it produces them. The env server refreshes via a new Taskset.reload_tasks() seam — on info and on an out-of-range task index — and append-only reloads keep indices aligned across pool workers refreshing at different times. Records produced by the replay env itself (continue:/recheck: task names) are skipped so seeds don't compound. Empty-at-startup is legal in follow mode (records appear with the first steps). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| # `follow` reloads stay aligned across pool workers refreshing at different times. | ||
| if "_tasks" not in self.__dict__: # first load on this instance | ||
| self._tasks, self._files_done, self._snapshots = [], set(), {} | ||
| new_paths = [path for path in paths if path not in self._files_done] |
There was a problem hiding this comment.
🟡 Medium replay/taskset.py:215
In follow mode, load_tasks permanently ignores records appended to a JSONL file it has already read. It tracks only file paths in _files_done and excludes any previously seen path from new_paths, so when rollouts/step_N/train_rollouts.jsonl grows after the first read, the new lines are never parsed — online self-replay silently drops tasks from the current step. Consider tracking a per-file byte offset (or line count) and re-reading from that position for files already seen, instead of skipping them outright.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/tasksets/replay/taskset.py around line 215:
In `follow` mode, `load_tasks` permanently ignores records appended to a JSONL file it has already read. It tracks only file paths in `_files_done` and excludes any previously seen path from `new_paths`, so when `rollouts/step_N/train_rollouts.jsonl` grows after the first read, the new lines are never parsed — online self-replay silently drops tasks from the current step. Consider tracking a per-file byte offset (or line count) and re-reading from that position for files already seen, instead of skipping them outright.
Following (incremental, append-only re-expansion of the records globs) is now the only behavior: a static finished-run source is trivially aligned (all files present and sorted at first load), and a growing own-run source is the online case. Empty matches warn instead of raising (records may not exist until the first step ships). Caveat documented: pool-worker index alignment assumes files appear in glob order — avoid multi-writer record trees. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| # writes every field explicitly, and must re-validate cleanly. | ||
| if self.mode == "recheck" and self.anchor != "compaction": | ||
| raise ValueError("`anchor` only applies to mode='continue'") | ||
| # Freeze globs to the matched files: every env-server pool worker re-validates this |
There was a problem hiding this comment.
🟠 High replay/taskset.py:146
ReplayConfig._validate documents that it freezes the records globs to the matched files so every pool worker sees the same file set, but it never actually replaces self.records with the matched list — the method just returns self unchanged. As a result, load_tasks re-expands the globs on every call. A worker started later, after new record files have appeared, sees extra files and assigns different tasks to the same numeric idx values than an earlier worker. Requests routed by task index then execute or score the wrong replay task across pool workers. The validator should replace self.records with the resolved file list before returning, or freeze the expansion in a separate field that load_tasks reads instead of the raw globs.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/tasksets/replay/taskset.py around line 146:
`ReplayConfig._validate` documents that it freezes the `records` globs to the matched files so every pool worker sees the same file set, but it never actually replaces `self.records` with the matched list — the method just returns `self` unchanged. As a result, `load_tasks` re-expands the globs on every call. A worker started later, after new record files have appeared, sees extra files and assigns different tasks to the same numeric `idx` values than an earlier worker. Requests routed by task index then execute or score the wrong replay task across pool workers. The validator should replace `self.records` with the resolved file list before returning, or freeze the expansion in a separate field that `load_tasks` reads instead of the raw globs.
… comments - ReplayTaskset incremental state (_tasks/_files_done/_snapshots) becomes lazy cached_properties, dropping the "_tasks in self.__dict__" first-load guard and the mutable class-attribute defaults. - Drop the dead glob-freeze comment left in ReplayConfig._validate and the stale "config validation freezes with it" claim in expand_records; scrub leftover "follow mode/knob" wording from docstrings. - server.py: fix import order; update the stale "index range is fixed for the server's lifetime" comment. - Tighten _natural's return annotation. No behavior changes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| self._refresh_tasks() | ||
| return self.tasks[task_idx] | ||
|
|
||
| def _refresh_tasks(self) -> None: |
There was a problem hiding this comment.
🟡 Medium serve/server.py:126
_refresh_tasks() replaces self.tasks with a new list, but self.serving() was already entered at server startup with the original list object. When the taskset grows (e.g. starts empty and gains tasks later), Environment.shared_tools keeps the stale list reference, so newly discovered tasks never get their shared tool servers. Replay tasksets that rely on shared MCP tools will run those later tasks without the intended shared resources, changing rollout behavior. Consider re-entering serving() (or rebuilding shared tools) when the task list actually changes in _refresh_tasks(), or otherwise ensuring shared serving resources track the refreshed task list.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/serve/server.py around line 126:
`_refresh_tasks()` replaces `self.tasks` with a new list, but `self.serving()` was already entered at server startup with the original list object. When the taskset grows (e.g. starts empty and gains tasks later), `Environment.shared_tools` keeps the stale list reference, so newly discovered tasks never get their shared tool servers. Replay tasksets that rely on `shared` MCP tools will run those later tasks without the intended shared resources, changing rollout behavior. Consider re-entering `serving()` (or rebuilding shared tools) when the task list actually changes in `_refresh_tasks()`, or otherwise ensuring shared serving resources track the refreshed task list.
| skipped = {"replayed": 0, "foreign": 0, "empty": 0, "no_seed": 0, "overlong": 0} | ||
| for record in iter_records(Path(path) for path in new_paths): | ||
| name = (record.get("task") or {}).get("name") or "" | ||
| if isinstance(name, str) and name.startswith(("continue:", "recheck:")): |
There was a problem hiding this comment.
🟡 Medium replay/taskset.py:212
load_tasks silently discards any source record whose task name begins with "continue:" or "recheck:", treating it as this taskset's own recycled output. Because Task.name is arbitrary user data from the source environment, a legitimate source task named e.g. "recheck: algebra" or "continue: step 2" is dropped and can never be replayed, even though it is valid source data. The name-prefix heuristic is an unreliable way to detect self-output. Consider tagging replayed output with a dedicated marker field rather than overloading the user-facing name.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/tasksets/replay/taskset.py around line 212:
`load_tasks` silently discards any source record whose task name begins with `"continue:"` or `"recheck:"`, treating it as this taskset's own recycled output. Because `Task.name` is arbitrary user data from the source environment, a legitimate source task named e.g. `"recheck: algebra"` or `"continue: step 2"` is dropped and can never be replayed, even though it is valid source data. The name-prefix heuristic is an unreliable way to detect self-output. Consider tagging replayed output with a dedicated marker field rather than overloading the user-facing `name`.
| f"{reason}={count}" for reason, count in skipped.items() if count | ||
| ), | ||
| ) | ||
| self._files_done.update(new_paths) |
There was a problem hiding this comment.
🟡 Medium replay/taskset.py:259
load_tasks() only updates _files_done at the very end of the batch. If any later file/line in new_paths raises (e.g. malformed JSON in iter_records(), or trace_cls.model_validate(record) rejects a bad trace), every earlier valid file in that batch is left unmarked. The next reload_tasks() reparses those already-consumed files and appends duplicate tasks, so task indices stop being stable across pool workers. Marking each file done as it's parsed prevents the duplicates.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/tasksets/replay/taskset.py around line 259:
`load_tasks()` only updates `_files_done` at the very end of the batch. If any later file/line in `new_paths` raises (e.g. malformed JSON in `iter_records()`, or `trace_cls.model_validate(record)` rejects a bad trace), every earlier valid file in that batch is left unmarked. The next `reload_tasks()` reparses those already-consumed files and appends duplicate tasks, so task indices stop being stable across pool workers. Marking each file done as it's parsed prevents the duplicates.
| "replay `records` matched no files (yet): %s", self.config.records | ||
| ) | ||
| task_cls = task_type(self.config.source.id) | ||
| trace_cls = Trace[task_cls] |
There was a problem hiding this comment.
🟡 Medium replay/taskset.py:202
load_tasks() validates each record as Trace[task_cls] (the base Trace type), which forbids unknown fields. Source tasksets can subclass Trace to persist extra trace fields, and their rollout records contain those extra keys — so trace_cls.model_validate(record) raises ValidationError and the record is skipped. Replay therefore drops all records from any taskset that uses a custom Trace subclass. Use the source taskset's own Trace subclass type (obtainable via self.source.trace_type() or equivalent) for validation instead of the generic base type.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/tasksets/replay/taskset.py around line 202:
`load_tasks()` validates each record as `Trace[task_cls]` (the base `Trace` type), which forbids unknown fields. Source tasksets can subclass `Trace` to persist extra trace fields, and their rollout records contain those extra keys — so `trace_cls.model_validate(record)` raises `ValidationError` and the record is skipped. Replay therefore drops all records from any taskset that uses a custom `Trace` subclass. Use the source taskset's own `Trace` subclass type (obtainable via `self.source.trace_type()` or equivalent) for validation instead of the generic base type.
… rollouts e.g. max_source_reward=0.5 rechecks only incorrect attempts on a 0/1 env, concentrating groups where revision can flip the outcome (the gsm8k debug run showed 81% of unfiltered recheck seeds were already-correct attempts, mostly zero-advantage groups). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| continue | ||
| if seed.snapshot is not None: | ||
| self._snapshots[len(tasks)] = seed.snapshot | ||
| tasks.append( |
There was a problem hiding this comment.
🟠 High replay/taskset.py:270
load_tasks() overwrites every replayed task's prompt with seed.prompt, even when the source task's original prompt was None. The server uses task.prompt is None as the signal to let the user() simulator open the conversation on the initial respond("") call, so replay tasks lose that opening turn. In environments like alphabet_sort_v1, this leaves the simulator queue one turn behind and injects the initial prompt again after the first assistant reply, corrupting the replayed conversation and scoring the wrong trajectory.
This happens at trace.task.model_copy(update={... "prompt": seed.prompt ...}) — it unconditionally replaces the original prompt. Consider preserving the None semantics when the source task had no prompt, or signaling the replay-seeded context differently so the server's opening-turn detection is not disrupted.
🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/tasksets/replay/taskset.py around line 270:
`load_tasks()` overwrites every replayed task's `prompt` with `seed.prompt`, even when the source task's original `prompt` was `None`. The server uses `task.prompt is None` as the signal to let the `user()` simulator open the conversation on the initial `respond("")` call, so replay tasks lose that opening turn. In environments like `alphabet_sort_v1`, this leaves the simulator queue one turn behind and injects the initial prompt again after the first assistant reply, corrupting the replayed conversation and scoring the wrong trajectory.
This happens at `trace.task.model_copy(update={... "prompt": seed.prompt ...})` — it unconditionally replaces the original prompt. Consider preserving the `None` semantics when the source task had no prompt, or signaling the replay-seeded context differently so the server's opening-turn detection is not disrupted.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tool_call_seed's single draw generalizes to tool_call_seeds: the same candidate anchors (complete tool-result runs, snapshot-gated, deduped across branches), but max_anchors of them drawn deterministically and kept in trajectory order — None seeds every valid anchor. Names stay continue:<trace8>:tool-call<pos>, disambiguated with an ordinal when two branches anchor at the same in-branch index. Default 1 preserves the current single uniform draw. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Record files may ship with a derived sibling index (prime-rl writes index_<env>.jsonl beside each train_rollouts_<env>.jsonl): one row per record line with selection fields (task_name, reward, branches, ...) and the line's byte span. read_index / select_index_rows / iter_indexed_records filter records index-side — already-replayed names, out-of-range source rewards, and (for continue/compaction) un-compacted rollouts — then parse only the surviving spans. A record file without an index full-parses as before; the parsed-record checks (foreign task validation, empty nodes) still run on every survivor. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…pers - Collapse read_index / select_index_rows / iter_indexed_records into iter_selected_records: index-side filtering (skip counts updated in place) with span-selective reads, full parse when no index exists. - Keep index_path / index_row as the small cross-repo contract with the record writer (prime-rl); index_row documents required selection keys vs informational provenance. - REPLAYED_PREFIXES module constant shared by the index-side and parse-side already-replayed filters. - max_anchors bounds via Annotated[int, Field(ge=1)] | None instead of a hand-written validator line. - tool_call_seeds: per-branch prefix sums make token estimates O(1) per anchor, prefix sliced once, Counter ordinal for name disambiguation. - Tests: hoist _echo_trace/_load_replay, fold _replay_taskset away, drop the reward-filter test subsumed by the index-vs-full-parse test's no-index leg, build _index_row via index_row. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
| prefix = nodes[: end + 1] | ||
| messages = [node.message for node in prefix] | ||
| if messages[0].role == "system": | ||
| messages = messages[1:] |
There was a problem hiding this comment.
🟡 Medium replay/records.py:298
tool_call_seeds always strips the leading system message from the seeded history. When the source task's system prompt lives in-band in Task.prompt (as Environment builds by prepending it into the message list instead of populating Task.system_prompt), the seed loses it entirely — the harness re-emits Task.system_prompt, which is empty, so the replayed rollout resumes with different instructions than the original. Consider preserving the system message in the seed so the replayed context matches what the original rollout actually had.
for nodes, end, tokens in candidates:
prefix = nodes[: end + 1]
messages = [node.message for node in prefix]
- if messages[0].role == "system":
- messages = messages[1:]
base = f"continue:{trace.id[:8]}:tool-call{end}"🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/tasksets/replay/records.py around lines 298-301:
`tool_call_seeds` always strips the leading system message from the seeded history. When the source task's system prompt lives in-band in `Task.prompt` (as `Environment` builds by prepending it into the message list instead of populating `Task.system_prompt`), the seed loses it entirely — the harness re-emits `Task.system_prompt`, which is empty, so the replayed rollout resumes with different instructions than the original. Consider preserving the system message in the seed so the replayed context matches what the original rollout actually had.
| for end, node in enumerate(nodes): | ||
| if node.message.role != "tool" or id(node) in seen: | ||
| continue |
There was a problem hiding this comment.
🟡 Medium replay/records.py:274
tool_call_seeds can produce replay seeds from fabricated context that was never part of the original rollout. When a branch carries copied tool messages (e.g. from a retokenization split or compaction), those nodes have sampled=False, but the function only checks node.message.role == "tool" and never checks node.sampled or issuer.sampled. This creates continue:...:tool-call seeds from context the source model never produced, polluting the replay taskset with invalid resume points. Consider filtering candidates so the issuing assistant node and tool result nodes must all have sampled=True.
- for end, node in enumerate(nodes):
- if node.message.role != "tool" or id(node) in seen:
+ for end, node in enumerate(nodes):
+ if node.message.role != "tool" or not node.sampled or id(node) in seen:🚀 Reply "fix it for me" or copy this AI Prompt for your agent:
In file @verifiers/v1/tasksets/replay/records.py around lines 274-276:
`tool_call_seeds` can produce replay seeds from fabricated context that was never part of the original rollout. When a branch carries copied `tool` messages (e.g. from a retokenization split or compaction), those nodes have `sampled=False`, but the function only checks `node.message.role == "tool"` and never checks `node.sampled` or `issuer.sampled`. This creates `continue:...:tool-call` seeds from context the source model never produced, polluting the replay taskset with invalid resume points. Consider filtering candidates so the issuing assistant node and tool result nodes must all have `sampled=True`.
What
A built-in v1 taskset,
replay, that re-enters saved rollout records (Trace.to_record()JSONL lines) as fresh tasks — plus the small framework seams a wrapping taskset needs. This is the verifiers half of prime-rl's Continue/Recheck training (prime-rl PR to follow); everything is driven by env config, no orchestrator changes.Each replayed task is the original typed task with its
promptswapped for the seed (model_copy), and every hook —tools,user,setup/finalize,score/score_group, stops, state — delegates to thesourcetaskset loaded by id. New completions are therefore judged by the original env's own verifier, with its real toolkit.Modes
continue,anchor = "compaction"(default): one task per recorded context restart. Detection is structural, not textual — a restart is a branch that forks off shared history on unsampled, system/user-only launch context before its first sampled node — so records from different harnesses (each with its own compaction wording/shape) mix freely. The canonical[system, user(summary)]restart lowers to a plain string prompt, so it runs under any harness, including agent CLIs likerlm(a fresh launch reproduces the post-compaction context exactly). A guard drops candidates equal to the task's own launch context (branch order follows leaf order, so an auxiliary same-system branch can sort first), and retokenization splits are excluded because their forked context carries assistant/tool copies.continue,anchor = "tool-call": one deterministically-drawn resume point per rollout (Random(f"{seed}:{trace.id}")), right after a complete tool-result run (every tool_call id of the issuing assistant answered).Messagesprompt → needs a message-seeding harness (default/null).recheck: the attempt's final branch, truncation artifacts stripped (everything from the first partially-answered tool run; trailing unanswered user turns), plus a configurable verification turn.last_reply-style rewards score the revision — seeded context is neversampled.Other recycling schemes subclass
ReplayTasksetand overrideseeds(trace); record loading, delegation, and snapshot plumbing stay inherited.Sandbox snapshots (seam only)
No runtime can capture/restore snapshots yet, but the contract is in place so producers can start recording:
trace.info["snapshots"]maps node index → opaque ref, keyed at the node a resume point re-enters at. A record carrying any refs offers only snapshotted anchors (an unsnapshotted mid-trajectory resume would be unfaithful once state is captured), andsetuprestores viarestore_snapshot(), which fails loudly until support lands (endgame documented there: promote the ref to a baseTaskfield alongsideimage/workdir).Framework seams (shared surface — the part to review closely)
Taskset.defines_tools/defines_user/needs_container/stops()/defines_group_rewards()/state_type()— instance-level seams replacing class-identity checks (type(ts).tools is not Taskset.tools) and directdiscover_decoratedcalls inEnvironment.__init__,Rollout.run,EnvServer,Episode, andcli/validate. Defaults preserve today's behavior exactly; a wrapping taskset overrides them to report its source. Notedefines_group_rewardsis deliberately a method: hook discovery walks instances withinspect.getmembers, which evaluates properties — a discovery-running property recurses (pinned by a test).Harness.write_message_prompt()+ sharedINITIAL_MESSAGES_FILE: aMessagesprompt now ships to thedefault/nullprograms as a file in the runtime instead of theINITIAL_MESSAGESenv var — replayed histories are exactly the long contexts that overflowMAX_ARG_STRLEN(~128 KiB per env string).Operational details
ReplayConfigvalidation freezesrecordsglobs to the concrete matched files, so every pool worker (elastic pools spawn workers mid-run) builds the identical, positionally-indexed task list.taskpayload against the source's task type (cheap, before full-trace validation) and counted as skipped. The filter is structural — sources whose task type has no distinguishing required fields should use single-env files (documented).max_seed_tokensdrops over-long seeds at load (recorded token count when present, chars/4 estimate otherwise).Verification
tests/v1/test_replay_taskset.py(18 tests): seed construction across compaction shapes (rlm shape, kept-task-message shape, multi-compaction), retokenization/subagent/aux-branch exclusions, complete-vs-partial tool runs, recheck cleanup, snapshot gating, config narrowing + dump→re-validate round trip (the pool-worker path), glob freezing, capability/state delegation against the echo fixtures.tests/v1/+tests/test_imports.py+tests/test_environment.py+tests/test_env_server.py→ 87 passed (excluding the pre-existingwiki_search_compact.tomlenv-dependent failure, which fails identically on the base commit).load_tasksin all three modes → correct prompts/typed task fields;Environmentconstruction under therlmharness passes the capability gates.🤖 Generated with Claude Code
Update: online self-replay (records are always followed)
replaycan now follow a growing records source — the current run's ownrollouts/step_*/train_rollouts.jsonl— instead of requiring a finished prior run. There is no freeze/knob —load_tasksis always incremental + append-only (numeric-aware step ordering, only new files parsed, indices never reassigned); the env server refreshes through a newTaskset.reload_tasks()seam oninfoand on out-of-range task indices, so pool workers refreshing at different times stay index-aligned. The replay env's own output (continue:/recheck:task names) is skipped so seeds don't compound. Covered by a follow-mode test (append across reloads, step_9/step_10 ordering, self-replay guard).Update: max_anchors + source-reward filters + per-env records
max_anchors: int | None = 1on continue/tool-call draws that many resume points per source rollout (None = every complete tool-run boundary becomes a task).min/max_source_rewardseed only from selected-outcome rollouts (e.g. recheck only incorrect attempts). prime-rl now writes per-env train record files (train_rollouts_.jsonl), so mixed-file filtering is a fallback rather than the mechanism. Validated by three live gsm8k debug runs (recheck, tool-call continue, incorrect-only recheck: repair rate 31%→37% over 100 steps).Update: rollout record index
Record files can now ship with a derived sibling index (prime-rl writes
index_<env>.jsonlbeside eachtrain_rollouts_<env>.jsonl): one row per record line with selection fields (task_name,reward,branches, ...) and the line's byte span. New pure helpers inrecords.py—read_index/select_index_rows/iter_indexed_records— letload_tasksfilter records index-side (already-replayed names, out-of-range source rewards, and un-compacted rollouts for continue/compaction) and then seek/read/parse only the surviving spans, opening the record file once. A file without an index full-parses exactly as before, and the parsed-record checks (foreign task validation, empty nodes) still run on every survivor. Covered by 3 new tests: index-selected loading matches full parse, filtered records are provably never parsed (invalid JSON at their spans), and branch-count prefiltering for compaction; plus explicit missing-index fallback asserts.Update: cleanup pass
Consolidated the index API:
read_index/select_index_rows/iter_indexed_recordscollapsed into oneiter_selected_recordsgenerator (index-side filtering + span-selective reads, full parse when no index exists), withindex_path/index_rowkept as the small writer-side contract (prime-rl builds its index rows through them). SharedREPLAYED_PREFIXESconstant for both replayed-name filters,max_anchorsbounds viaAnnotated[int, Field(ge=1)] | None, andtool_call_seedsnow uses per-branch token-count prefix sums (O(1) estimates per anchor) plus aCounterordinal for name disambiguation. Behavior unchanged; tests reorganized around the shared helpers (one redundant reward-filter test folded into the index-vs-full-parse test).