|
1 | | -"""TrainSource: weighted round-robin across train envs, infinite pull. |
| 1 | +"""TrainSource: weighted env mix over per-env samplers, infinite pull. |
2 | 2 |
|
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 | +""" |
5 | 9 |
|
6 | 10 | from __future__ import annotations |
7 | 11 |
|
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 |
11 | 14 |
|
12 | 15 |
|
13 | 16 | 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).""" |
18 | 21 |
|
19 | 22 | def __init__(self, train_envs: TrainEnvs, *, seed: int | None) -> None: |
20 | | - self.rng = random.Random(seed) |
21 | 23 | self.envs = list(train_envs) |
22 | 24 | if not self.envs: |
23 | 25 | raise ValueError("TrainSource needs at least one train env") |
| 26 | + self._envs_by_name = {env.name: env for env in self.envs} |
24 | 27 |
|
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. |
29 | 32 | 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) |
39 | 35 | self.env_costs[env.name] = env.config.group_size if env.requires_group_scoring else 1 |
40 | 36 |
|
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] |
43 | 39 | 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] |
45 | 41 | 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 |
47 | 53 |
|
48 | 54 | 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() |
50 | 56 | if self.env_costs[env_name] > available_permits: |
51 | 57 | 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() |
0 commit comments