Skip to content

Commit f8edacc

Browse files
halleriteclaude
andcommitted
feat(orchestrator): per-env sample strategy + env-mix seam
Introduce the per-env sampling seam. Each train env owns a `SampleStrategy` (what example to serve, plus an `observe()` feedback hook); env selection is delegated to a swappable `EnvMixStrategy`. Defaults reproduce today's behavior (weighted round-robin over per-env reshuffling-cursor datasets). - `orchestrator/sampling.py` (new): SampleStrategy + ShuffledCursorSampler; EnvMixStrategy + WeightedRoundRobin. - TrainEnv owns its dataset via `build_sampler()` and holds `.sampler`. - TrainSource slims to env-mix + per-env samplers. - TrainSink.process_group calls `env.sampler.observe(survivors)` after advantages (no-op default) — the feedback wire for curriculum / replay samplers. Behavior-equivalent; RNG partitioned per-env + mix. Stacked on feat/per-env-advantage. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e0f8a35 commit f8edacc

5 files changed

Lines changed: 209 additions & 37 deletions

File tree

src/prime_rl/orchestrator/envs.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from prime_rl.configs.orchestrator import EnvConfig, EvalEnvConfig, TrainEnvConfig
1515
from prime_rl.orchestrator.advantage import AdvantageFn, setup_advantage_fn
16+
from prime_rl.orchestrator.sampling import SampleStrategy, ShuffledCursorSampler
1617
from prime_rl.utils.logger import get_logger
1718

1819
REQUIRED_STATE_COLUMNS = ["trajectory"]
@@ -170,10 +171,24 @@ def __init__(self, config: TrainEnvConfig):
170171
self.advantage_fn: AdvantageFn | None = (
171172
setup_advantage_fn(config.advantage) if config.advantage is not None else None
172173
)
174+
# Set by ``build_sampler`` (called by TrainSource at setup). Owns this
175+
# env's dataset + selection state; reached by the sink for ``observe``.
176+
self.sampler: SampleStrategy | None = None
173177

174178
def get_dataset(self, seed: int | None = None):
175179
return self.env.get_dataset(seed=seed)
176180

181+
def build_sampler(self, *, seed: int | None) -> None:
182+
"""Load this env's dataset and build its default ``SampleStrategy``.
183+
Each row is stamped with ``env_name`` (``example_id`` comes from the
184+
dataset)."""
185+
rows: list[dict] = []
186+
for row in self.get_dataset(seed=seed):
187+
ex = dict(row)
188+
ex["env_name"] = self.name
189+
rows.append(ex)
190+
self.sampler = ShuffledCursorSampler(rows, seed=seed)
191+
177192

178193
class EvalEnv(Env):
179194
config: EvalEnvConfig
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""Sampling strategies for training rollouts.
2+
3+
Two seams sit between the train envs and the dispatcher:
4+
5+
- ``EnvMixStrategy`` (global) — decides *which* env to draw from next.
6+
- ``SampleStrategy`` (per-env) — decides *what* example that env serves next,
7+
and (via ``observe``) can learn from finished, scored groups. Each env owns
8+
its own ``SampleStrategy`` instance, so it can hold dataset + per-env state
9+
(cursor today; curriculum / replay buffers later).
10+
11+
The defaults (``WeightedRoundRobin`` + ``ShuffledCursorSampler``) reproduce the
12+
previous ``TrainSource`` behavior: a weighted round-robin over per-env datasets
13+
that are each shuffled once and walked with a reshuffling cursor.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import random
19+
from abc import ABC, abstractmethod
20+
from typing import TYPE_CHECKING
21+
22+
if TYPE_CHECKING:
23+
from prime_rl.orchestrator.types import TrainRollout
24+
25+
26+
class SampleStrategy(ABC):
27+
"""Per-env example selection. One stateful instance per env, alive for the
28+
whole run. ``next`` returns the next example dict (carrying ``env_name`` +
29+
``example_id``); ``observe`` is the feedback hook for stateful strategies."""
30+
31+
@abstractmethod
32+
def next(self) -> dict:
33+
"""Return the next example for this env."""
34+
...
35+
36+
def observe(self, group: list[TrainRollout]) -> None:
37+
"""Called with one finished, scored group of this env's rollouts (after
38+
advantages are assigned). Default is a no-op; stateful strategies
39+
(curriculum, replay) override this to learn from outcomes."""
40+
return
41+
42+
43+
class ShuffledCursorSampler(SampleStrategy):
44+
"""Default sampler: shuffle the env's rows once, walk a cursor, reshuffle on
45+
exhaustion (infinite pull)."""
46+
47+
def __init__(self, rows: list[dict], *, seed: int | None) -> None:
48+
if not rows:
49+
raise ValueError("ShuffledCursorSampler needs at least one example")
50+
self._rng = random.Random(seed)
51+
self._rows = list(rows)
52+
self._rng.shuffle(self._rows)
53+
self._cursor = 0
54+
55+
@property
56+
def dataset_size(self) -> int:
57+
return len(self._rows)
58+
59+
def next(self) -> dict:
60+
if self._cursor >= len(self._rows):
61+
self._rng.shuffle(self._rows)
62+
self._cursor = 0
63+
row = self._rows[self._cursor]
64+
self._cursor += 1
65+
return row
66+
67+
68+
class EnvMixStrategy(ABC):
69+
"""Global: which env to draw from next. ``pick`` returns an env name."""
70+
71+
@abstractmethod
72+
def pick(self) -> str:
73+
"""Return the env name to sample from next."""
74+
...
75+
76+
77+
class WeightedRoundRobin(EnvMixStrategy):
78+
"""Default env mix: weighted random choice over env names. Weights are the
79+
configured per-env ratios (when all set) or per-env dataset sizes."""
80+
81+
def __init__(self, env_names: list[str], weights: list[float], *, seed: int | None) -> None:
82+
if not env_names:
83+
raise ValueError("WeightedRoundRobin needs at least one env")
84+
self._rng = random.Random(seed)
85+
self._env_names = list(env_names)
86+
self._weights = list(weights)
87+
88+
def pick(self) -> str:
89+
return self._rng.choices(self._env_names, weights=self._weights, k=1)[0]

src/prime_rl/orchestrator/train_sink.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,13 @@ def process_group(self, group_id: uuid.UUID) -> None:
198198

199199
assign_advantages(survivors, self.train_envs.get(env_name).advantage_fn)
200200

201+
# Feedback hook: let this env's sampler learn from the finished, scored
202+
# group (advantages now assigned). No-op for the default cursor sampler;
203+
# curriculum / replay samplers override ``observe``.
204+
sampler = self.train_envs.get(env_name).sampler
205+
if sampler is not None:
206+
sampler.observe(survivors)
207+
201208
# Propagate to the pre-tokenized samples so the orchestrator can
202209
# collect samples at ship time without re-walking rollouts. The env
203210
# has a single sampling temperature; fan it out across each sample's
Lines changed: 38 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -1,59 +1,60 @@
1-
"""TrainSource: weighted round-robin across train envs, infinite pull.
1+
"""TrainSource: weighted env mix over per-env samplers, infinite pull.
22
3-
Weights default to configured ``ratio`` (when every env sets one) or to
4-
per-env dataset size. ``next_example`` reshuffles on cursor exhaustion."""
3+
Env selection is delegated to an ``EnvMixStrategy`` (default: weighted
4+
round-robin by configured ``ratio`` when all envs set one, else by per-env
5+
dataset size); example selection within an env is delegated to that env's
6+
``SampleStrategy`` (default: a reshuffling cursor). Both are swappable seams.
7+
Returned dicts carry ``env_name`` + ``example_id``.
8+
"""
59

610
from __future__ import annotations
711

8-
import random
9-
10-
from prime_rl.orchestrator.envs import TrainEnvs
12+
from prime_rl.orchestrator.envs import TrainEnv, TrainEnvs
13+
from prime_rl.orchestrator.sampling import WeightedRoundRobin
1114

1215

1316
class TrainSource:
14-
"""``next_example(available_permits)`` picks a weighted-RR env and
15-
returns its next example (or ``None`` when the env's per-call permit
16-
cost doesn't fit — the dispatch loop retries when permits free up).
17-
Returned dicts carry ``env_name`` + ``example_id``."""
17+
"""``next_example(available_permits)`` picks an env via the mix strategy and
18+
pulls that env's next example from its sampler (or ``None`` when the env's
19+
per-call permit cost doesn't fit — the dispatch loop retries when permits
20+
free up)."""
1821

1922
def __init__(self, train_envs: TrainEnvs, *, seed: int | None) -> None:
20-
self.rng = random.Random(seed)
2123
self.envs = list(train_envs)
2224
if not self.envs:
2325
raise ValueError("TrainSource needs at least one train env")
26+
self._envs_by_name = {env.name: env for env in self.envs}
2427

25-
self.examples: dict[str, list[dict]] = {}
26-
self.cursors: dict[str, int] = {}
27-
# Group-scoring envs reserve ``group_size`` permits up front;
28-
# per-rollout envs need 1
28+
# Build each env's sampler (which owns its dataset) and per-env permit
29+
# cost. Group-scoring envs reserve ``group_size`` permits up front;
30+
# per-rollout envs need 1. Per-env seeds keep distinct envs from
31+
# shuffling in lockstep.
2932
self.env_costs: dict[str, int] = {}
30-
for env in self.envs:
31-
rows: list[dict] = []
32-
for row in env.get_dataset(seed=seed):
33-
ex = dict(row)
34-
ex["env_name"] = env.name
35-
rows.append(ex)
36-
self.rng.shuffle(rows)
37-
self.examples[env.name] = rows
38-
self.cursors[env.name] = 0
33+
for i, env in enumerate(self.envs):
34+
env.build_sampler(seed=(seed + i) if seed is not None else None)
3935
self.env_costs[env.name] = env.config.group_size if env.requires_group_scoring else 1
4036

41-
self.env_names = [e.name for e in self.envs]
42-
configured_ratios = [e.config.ratio for e in self.envs]
37+
env_names = [env.name for env in self.envs]
38+
configured_ratios = [env.config.ratio for env in self.envs]
4339
if all(r is not None for r in configured_ratios):
44-
self.weights: list[float] = [float(r) for r in configured_ratios] # type: ignore[arg-type]
40+
weights = [float(r) for r in configured_ratios] # type: ignore[arg-type]
4541
else:
46-
self.weights = [float(len(self.examples[name])) for name in self.env_names]
42+
weights = [float(self._dataset_size(env)) for env in self.envs]
43+
self.env_mix = WeightedRoundRobin(env_names, weights, seed=seed)
44+
45+
@staticmethod
46+
def _dataset_size(env: TrainEnv) -> int:
47+
size = getattr(env.sampler, "dataset_size", None)
48+
if size is None:
49+
raise ValueError(
50+
f"Env {env.name!r} sampler exposes no dataset_size; set explicit per-env ratios to weight the env mix."
51+
)
52+
return size
4753

4854
def next_example(self, available_permits: int) -> dict | None:
49-
env_name = self.rng.choices(self.env_names, weights=self.weights, k=1)[0]
55+
env_name = self.env_mix.pick()
5056
if self.env_costs[env_name] > available_permits:
5157
return None
52-
rows = self.examples[env_name]
53-
cursor = self.cursors[env_name]
54-
if cursor >= len(rows):
55-
self.rng.shuffle(rows)
56-
cursor = 0
57-
example = rows[cursor]
58-
self.cursors[env_name] = cursor + 1
59-
return example
58+
sampler = self._envs_by_name[env_name].sampler
59+
assert sampler is not None # built in __init__
60+
return sampler.next()
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
from collections import Counter
2+
3+
import pytest
4+
5+
from prime_rl.orchestrator.sampling import (
6+
ShuffledCursorSampler,
7+
WeightedRoundRobin,
8+
)
9+
10+
11+
def _rows(n: int) -> list[dict]:
12+
return [{"example_id": i, "env_name": "e"} for i in range(n)]
13+
14+
15+
def test_shuffled_cursor_cycles_without_repeats_then_reshuffles():
16+
sampler = ShuffledCursorSampler(_rows(5), seed=42)
17+
cycle1 = [sampler.next()["example_id"] for _ in range(5)]
18+
cycle2 = [sampler.next()["example_id"] for _ in range(5)]
19+
# Each cycle visits every example exactly once (cursor), then reshuffles.
20+
assert sorted(cycle1) == list(range(5))
21+
assert sorted(cycle2) == list(range(5))
22+
23+
24+
def test_shuffled_cursor_dataset_size():
25+
assert ShuffledCursorSampler(_rows(7), seed=0).dataset_size == 7
26+
27+
28+
def test_shuffled_cursor_is_deterministic_per_seed():
29+
a = ShuffledCursorSampler(_rows(8), seed=123)
30+
b = ShuffledCursorSampler(_rows(8), seed=123)
31+
assert [a.next()["example_id"] for _ in range(8)] == [b.next()["example_id"] for _ in range(8)]
32+
33+
34+
def test_shuffled_cursor_empty_raises():
35+
with pytest.raises(ValueError):
36+
ShuffledCursorSampler([], seed=0)
37+
38+
39+
def test_observe_default_is_noop():
40+
sampler = ShuffledCursorSampler(_rows(3), seed=0)
41+
# Default observe accepts a (possibly empty) group and does nothing observable.
42+
assert sampler.observe([]) is None
43+
44+
45+
def test_weighted_round_robin_honors_weights():
46+
mix = WeightedRoundRobin(["A", "B"], [1.0, 3.0], seed=0)
47+
counts = Counter(mix.pick() for _ in range(4000))
48+
# B weighted 3x A — expect roughly a 3:1 split.
49+
assert 2.5 < counts["B"] / counts["A"] < 3.5
50+
51+
52+
def test_weighted_round_robin_is_deterministic_per_seed():
53+
a = WeightedRoundRobin(["A", "B", "C"], [1.0, 1.0, 1.0], seed=7)
54+
b = WeightedRoundRobin(["A", "B", "C"], [1.0, 1.0, 1.0], seed=7)
55+
assert [a.pick() for _ in range(20)] == [b.pick() for _ in range(20)]
56+
57+
58+
def test_weighted_round_robin_empty_raises():
59+
with pytest.raises(ValueError):
60+
WeightedRoundRobin([], [], seed=0)

0 commit comments

Comments
 (0)