feat: algorithm abstraction — named algorithm classes + inline frozen-model references (grpo, opd, sft_distill, self_distill, echo)#2746
Merged
Conversation
fb0da20 to
f35e3a8
Compare
samsja
reviewed
Jun 10, 2026
samsja
reviewed
Jun 10, 2026
samsja
reviewed
Jun 10, 2026
samsja
reviewed
Jun 10, 2026
samsja
reviewed
Jun 10, 2026
samsja
reviewed
Jun 10, 2026
samsja
reviewed
Jun 10, 2026
samsja
reviewed
Jun 10, 2026
samsja
reviewed
Jun 10, 2026
…e policy pool Two fixes to the pool.score prefill path, both surfaced by GPU validation: - opsd elastic guard: the static-pool isinstance guard rejected opsd's default teacher="policy" on an elastic policy pool (TypeError at startup) even though opsd just reuses the policy inference pool (no extra deployment). Make prefill a pool capability — InferencePool.score backed by a shared PrefillScorer (one client per endpoint, cached by identity: fills once for a static pool, tolerates churn for an elastic one). opsd drops the guard and reuses the policy pool as-is; opd keeps it (its own frozen teacher pool should be static). - prefill client construction: PrefillScorer resolved its client via resolve_client(cfg).openai, but the teacher's chat-completions configs resolve to an EvalClient with no `.openai` (AttributeError, crashing opd/mixed at step 0). Build AsyncOpenAI straight from the config fields (base_url/api_key/headers) — type-agnostic, as the original compute_prefill_logprobs did. Validated: 147 unit tests; a GPU re-run of opd reaches eval 0.83 (was crashing). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
319ddf6 to
7a4a30d
Compare
The scoring hooks (score_rollout / score_group / score_batch) and custom advantage functions received a RolloutView — a frozen facade over the Rollout that re-exposed samples/reward/env_name, owned the assign_advantages mutator, and hid the lifecycle fields (is_filtered / filter_results / group_id). But the hiding was advisory: `.raw` returned the whole Rollout, and first-party hooks reached through it constantly (opsd, echo, advantage). For a power-user extension point the indirection wasn't earning its keep — every richer field needed a pass-through property, and the boundary leaked anyway. Hand hooks the Rollout directly: - assign_advantages moves onto Rollout as a method (same validation/broadcast). - The three hooks and the advantage-fn signatures take Rollout / list[Rollout]. - `.raw` accesses drop (rollout.num_turns, rollout.task, ... read the trace directly — Rollout *is* a vf.Trace subclass). - algo/__init__ re-exports Rollout in place of RolloutView so custom advantage authors keep a convenient import. - RolloutView deleted from types.py. Docs (algorithms.md) and the unit tests updated to match; the now-pointless _views() test wrapper is gone. Validated: 51 orchestrator unit tests pass; full orchestrator test collection clean (98 tests, no import breakage). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Pulls in the gibberish/repetition filter fix (#2872). filters.py auto-merges: branch-stream gibberish/repetition from main, ZeroAdvantageFilter (advantages stream) from this branch.
Brings in the 5 v1-cleanup PRs (#2872 filter fix, #2874 messages.py delete, #2875 get_metrics removal, #2876 is_vlm removal, #2877 detection_index removal, #2878 serving_tokens docs) plus the verifiers bump to 0.1.15.dev394. Conflict resolutions: - pyproject.toml: take main's verifiers floor (dev394 > dev390). - deps/verifiers: take main's 20abcd91 (dev394) — strict descendant of our e306ba52 (dev390), already includes Trace.to_record(). - utils/client.py: keep our new prefill score() method, drop the dead get_metrics() that #2875 removed. - filters.py / FilterResult: detection_index dropped (#2877); our branch-stream fix (#2872) preserved. uv lock is a no-op; orchestrator unit suite green (99 passed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
hallerite
added a commit
that referenced
this pull request
Jun 25, 2026
Six cleanups internal to the algorithm-abstraction work, each verified dead/safe on this branch (no readers/writers/callers): - obs_spans: TrainingSample field was born dead (never written non-None, never read; echo uses structural node.sampled/node.is_content). Drop the field, the no-op null-out in routing.py, and the base.py + docs/algorithms.md mentions. - Algorithm.model_role: runtime ClassVar (base/opd/opsd) is never read -- role resolution uses the config-side model_role. Drop the dead ClassVar and the docstrings that present it as a live class declaration (only action_loss_type actually is one). Config-side model_role is kept. - finalize_group: drop the redundant sample.env_name re-stamp -- trace_to_samples already stamps the identical rollout.env_name. - get_model_completion_len: inline the 1-line passthrough to vf.Trace.completion_len at its sole call site; delete the helper. - algo/__init__.py: fix the "the view" docstring left over from the RolloutView->Rollout collapse. - score_batch docstring: drop the "or modulate advantages" clause (base.py + docs) -- advantages are stamped at group time, before score_batch runs, so it has no wire path; the hook attaches per-token results (e.g. teacher logprobs). Doc/comment + dead-code only; no behavior change. Orchestrator + config unit suites green (99 + 116 passed). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Brings in the verifiers bump (20abcd91 -> 62dc38a5: Branch.input_len/output_len best-available token lengths #1875 + streamed-turn recording fix #1864) plus the two cleanups that merged to main: - #2880 eval completion_len fix (now via Branch.output_len) - #2881 dead processor= path removal from chat_template Clean auto-merge, no code conflicts; submodule working tree updated to 62dc38a5. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The `reward` algorithm (advantage = raw reward, no group baseline) was a registered first-class option selected by no shipped config. High-variance no-baseline REINFORCE is dominated by grpo/max_rl in practice; drop it to keep the algorithm menu to what's actually run. Removes RewardAlgorithm (runtime) + RewardAlgorithmConfig (config union) + the docs table rows and the `reward` mentions in the teacher-validation message and docs prose. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit a8dba67. Configure here.
… wire The per-token `rewards` stream was just the rollout's scalar reward broadcast across every token (`[reward] * len`), carried through packing/padding all the way to a GPU tensor — but no loss component ever read it; its only consumer was the default-off token export. With the broadcast gone the scalar `reward` wire field is orphaned too (stamped by the orchestrator, read by nothing on the trainer), so drop the whole path: - `MicroBatch.rewards` + `TrainingSample.reward` (transport types) - the broadcast, slice, packing, padding, and alignment check in batch.py - `TensorMicroBatch.rewards` + its tensor construction (rl/data.py) - the `rewards` column in token_export - the `sample.reward = rollout.reward` stamp in finalize_group Rollout-level reward metrics are computed in the orchestrator from the trace directly and are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Correctness: - Rollout.scalar_advantage(): nonzero-mean view of the per-token stream; fixes the diluted advantage metric in orchestrator and dedups the divergent copy in the monitor backend - opsd: select the trainable branch by mask (not branches[0]), positive-index completion slice - batch: run token-array alignment assert after distribution padding so dummy batches are covered - configs: targeted sft/teacher conflict message; drop the dead config alias machinery in validation.py Docs/contracts: - custom advantage is a full-length-N stream aligned to sample token_ids - OPD/OPSD ship no advantage stream (advantages=None) - echo filter = one keep-mask per trainable branch over its token_ids - "advantage strategy" -> "algorithm" across loss/routing/docs - drop the reward type from training docs; stale-comment sweep Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the single-turn message-rebuild scoring with OPD-style verbatim
scoring plus a leading demonstration system block: the teacher reads
`hint_block + sample.token_ids` and the hint's logprobs are sliced back off,
so `ref_logprobs` aligns full-length to the sample's exact tokens.
- robust to tool/multimodal prompts — the sample is scored verbatim, never
re-rendered
- turn-agnostic — later turns ride along in the verbatim tail; the
`num_turns != 1` guard is gone
- BPE-clean — the join lands on `<|im_end|>` -> `<|im_start|>` (special ->
special); decoding then re-encoding the splice round-trips to identical ids
Drops `_scoring_prefix`, the branch/node surgery, and the `vf.Branch` import
(opsd.py 99 -> 75 lines). `template` is now the standalone system-message
content (`{demonstration}` only); config + docs updated to match.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The external gpt-5-mini-via-PI-inference SFT config can't run without PRIME_API_KEY + PRIME_TEAM_ID, so it's untestable locally/in CI. Remove the config and its two README references; the local sft_distill / sft_distill_lora configs still cover the SFT distillation path. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…-dir footgun The configs skill still documented the removed `advantage` sub-component and the removed `reward` algorithm type: rewrite the Algorithms/Dicts/unions examples to the current surface — `[orchestrator.algo] type = ...` (7 types: grpo/max_rl/opd/opsd/sft/echo/custom), echo roles at `[orchestrator.algo.roles.user]`, per-env override at `[orchestrator.train.env.algo]`, teacher folding into `algo.model`. Also drop `reward` from the token-export field list (the rewards export was removed). Add a note + per-config `--output-dir` to the algorithms debug README: every config defaults to `outputs/`, so back-to-back / repeat runs hit FileExistsError. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
| max_completion_tokens = 768 | ||
|
|
||
| [orchestrator.client.elastic] | ||
| [orchestrator.model.client.elastic] |
…e math De-privilege model references (no shared `teacher` slot on the base config): - opd declares a required frozen `teacher`; sft samples from its `sampling.source`; opsd self-distills against the live policy and names no model at all. - Neutralize "teacher" vocabulary in the Algorithm base runtime docstrings — they describe an inference pool to another model (e.g. a teacher), not a privileged slot. Collapse the advantage plumbing into the scoring hooks: - Inline GRPO/MaxRL credit directly into `score_group`; delete `default_advantage_fn`, `max_rl_advantage_fn`, `apply_advantage_fn`, and the `AdvantageFn` type. `efficiency_shaping` stays as the one shared helper. - Delete the `custom` advantage algorithm (the config-as-code escape hatch). A new credit-assignment scheme is now a named class in the repo, not a config that points at an import path. Tests retargeted with identical expected values (efficiency_shaping tested directly; grpo/max_rl through their hooks). Docs: the "Custom Advantage" section is replaced by "Authoring an Algorithm". Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ted frozen pools
Every algorithm is still handed the live policy pool (self.policy_pool,
host-owned, never closed by the algorithm) — opsd self-distills against it,
others may judge against it or ignore it.
Algorithm.connect() is now frozen-only (FrozenModelConfig): connected_pools
holds exactly the pools an algorithm opens and must close. The dead
connect("policy") branch is removed — self.policy_pool is the direct handle
to the live model.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Aligns the config class names with the namespace they live in ([orchestrator.algo], orchestrator/algo/). The discriminated union is AlgoConfig, the base is BaseAlgoConfig, and each variant is *AlgoConfig (GRPOAlgoConfig, OPDAlgoConfig, ...). Runtime classes (Algorithm, GRPOAlgorithm, ...) are unchanged — the Config suffix distinguishes them. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nizer is always the policy Remove score_batch: opd/opsd move their reference prefill scoring into score_rollout, each bounded by a per-algorithm semaphore created in setup(). With the cross-env finalize_batch gone, finalize_rollout/finalize_group become non-virtual Algorithm methods. Drop the now-orphaned time/scoring metric. OPSD self-distillation has no separate model: remove the `model` knob; the hint renderer's tokenizer is always the live policy. Only the renderer family stays configurable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…x_concurrent The main loop consumes rollouts serially and each carries only a handful of samples, so per-rollout score_rollout never approached the 32-wide cap — the semaphore was dead. Remove it (score_rollout just gathers over the rollout's samples) and the now-vestigial max_concurrent field from OPDAlgoConfig / OPSDAlgoConfig. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Both claim to mirror the sibling reverse_text/start.toml (max_steps=15) but set 20. Match the base so the "mirrors" comment holds. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Note
On verifiers-v1. Targets
mainafter the vf-v1 ⇆ nano bridge (#2742) merged.Rolloutis avf.Tracesubclass, samples are flat-tokenTrainingSample, scoring runs offvf.Tracebranches/nodes, and the renderer is required.origin/mainis merged in (latest: #2879 internal cleanup, #2880 eval-metric fix, #2881 dead-processor=removal, verifiers bumped to62dc38a5); the branch is even withmainand CI is green.The two prior reach-arounds are now resolved on this branch:
compute_prefill_logprobsis gone; prefill logprobs go throughInferencePool.score()(PrefillScorer).MessageNode.is_contentlanded, so echo weights only message-body tokens (scaffold excluded), with a whole-span fallback when attribution is absent. Pre-v1 parity restored.Verification: verifiers pinned at submodule
62dc38a5(hasis_content,Trace.to_record(),Branch.input_len/output_len), no merge conflicts,ruffclean, imports resolve against the pinned verifiers, all CI green (unit + every integration suite). GPU smokes in a prior session confirmed all three loss components (rl/ce/ref_kl) train and learn.What
Makes prime-rl's training algorithm a first-class, hackable abstraction — removes model roles from the pipeline, unifies every training signal under one concept (the advantage: credit assignment and loss routing, fused), and makes each algorithm a named runtime class that owns its methods. There is no registry and no separate "token scorer": there is the live policy — the only model prime-rl ever hosts — and per-env algorithms whose model references are either
"policy"or an inline externally-hosted frozen endpoint.An algorithm is a bundle of two components, configured under
[orchestrator.algo](per-env:algo = {...}) and resolved per env.algo.typenames the algorithm —grpo,max_rl,opd,opsd,sft,echo— and each type's class defaults are its vetted setting; there is no separate preset layer:"policy"or an inline frozen endpoint. At runtime this builds the env'sSampler(orchestrator/sampler.py) — the pool rollouts come from, the liveness consequences (logprobs, prefix-cache salting, staleness), and the home of future sampling strategies (replay buffers, branching).rl/ce/ref_kl) and what happens to env-provided observation tokens (masked out by default;echotrains on them with weighted CE — selected by message role via the renderer's per-token attribution, each role at its ownalpha, tool-response bodies at λ=0.1 by default, optionally narrowed by a user-supplied per-rollout token filter).The training loss is a sum of three components —
rl(DPPO+KL or custom),ce(masked NLL),ref_kl(reverse KL to a reference as the PG signal) — each normalized by its own global token count:The orchestrator stamps per-token component weight streams (
rl_weights/ce_weights/ref_kl_weights) plus the per-token advantage stream (advantages— there is no scalar advantage anywhere; uniform group credit is broadcast over completion tokens at assignment); a weight scales that component's per-token loss,0.0removes the token from the component's mask and denominator, and components may overlap on the same token (gradients sum). Per-component normalization means components never dilute each other: echo's observation tokens no longer shrink the rl term's effective per-token learning rate (previously both shared one global denominator, so the rl gradient scaled with the batch's obs/action ratio), and a supervised env packed next to a GRPO env doesn't soften its gradient.The trainer is algorithm-blind (component weight streams ship per token on the wire; the trainer executes the three fixed components), and the orchestrator pipeline is too: dispatcher, train sink, and orchestrator call hooks on each env's
Sampler+Algorithmobjects and never branch on algorithm config or model roles.The API — two seams to override
Writing an algorithm is subclassing
Algorithm(orchestrator/algo/base.py) and overriding the hook(s) your signal needs. The whole override surface is small and identical for every algorithm — two declarations, a lifecycle hook, and two scoring hooks on one scope/timing ladder:The two hooks are one ladder of scope, the wider scope unlocked by a later barrier — so scope and timing coincide:
score_rolloutRolloutself.policy_pool/self.teacher_pool)score_grouplist[Rollout]score_rollout— rollout-local signals (raw reward, process rewards, echo's observation weighting) and the model-access seam: query a reference (OPD's frozenteacher, OPSD's live policy) and attach per-token logprobs as each rollout arrives. It runs before the pre-batch filters, so it pays reference compute on rollouts that may then be filtered — OPD/OPSD assign no advantage, so advantage filters never drop them anyway, and the serial main loop bounds in-flight scoring without an explicit cap.score_group— the group-relative seam: GRPO / MaxRL baselines, std-normalization. It runs before filtering on purpose, because advantage-based filters read the streams it writes.Both hooks are
async, so either may do I/O — a teacher or process-reward model at arrival, or a judge at group time whose verdict a pre-batch filter then reads; a hook that only does advantage math never awaits.The input type is always
Rollout(orchestrator/types.py) — which is the env's typedvf.Tracesubclass, with prime-rl's training fields bolted on. A hook reads the trace directly and writes credit through one method:assign_advantagestakes either a scalar (broadcast over the rollout's trainable mask-True tokens — the common, uniform-credit case) or an explicit per-token list (aligned full-length to the samples' tokens). It writes the per-tokenadvantagesstream; advantages are per-token everywhere stored or shipped, the scalar is only a write-time convenience. For non-rl components an algorithm writes the sample stream directly — echo writessample.ce_weightsoveris_contenttokens atscore_rollouttime — at the stage where that data is valid. There's no read-only facade: this is a power-user extension surface, so a hook gets the wholeRollout(an earlierRolloutViewhandle was collapsed away — everyone reached through its.rawescape hatch anyway). Stage discipline (don't readis_filteredbefore filtering, don't readadvantagesbefore assigning) is a convention, not an enforced wall.The pipeline drives the hooks through two non-virtual
Algorithmmethods named by the barrier they sit on —finalize_rollout/finalize_group— so the hooks answer "what do I override" and the finalize methods answer "when does the pipeline call me". Default hook bodies are no-ops: an algorithm that only needs a group baseline overridesscore_groupalone and inherits the rest.The algorithm classes
Each algorithm is a named runtime class — the algorithm object is the algorithm. Dispatch is keyed on
algo.type— it names the algorithm, and each config class's defaults are its vetted parameterization:Each class overrides the scoring hook(s) its signal needs, on the scope/timing ladder
score_rollout(one rollout, on arrival; async, model access) →score_group(the cohort, before filtering):algo.typegrpoGRPOAlgorithmrlscore_group: group-norm scalars (optional length penalty)max_rlMaxRLAlgorithmrlscore_group: mean-normalized group scalars (arXiv:2602.02710: unbiased for the order-group_sizetruncation of the max-likelihood objective)echoEchoAlgorithmrl+cescore_rollout: env-provided tokens selected by message role (roles.<role>.alpha, tool bodies @ 0.1 default), optional user token filter;score_group: group-norm scalars (inherited)opdOPDAlgorithmref_klscore_rollout: own-context prefill under the teacher (a required frozen model; no credit —advantages=None, filters skip)opsdOPSDAlgorithmref_klscore_rollout: demo-conditioned prefill under the live policy (self-distillation, no model to declare; no credit)sftSFTDistillAlgorithmcescore_group: group-norm (feeds reward-based filtering)Reading a class top to bottom reads the algorithm — one module per algorithm under
orchestrator/algo/(grpo.py,opd.py, …, with the base class and its non-virtualfinalize_*drivers inbase.pyand dispatch in the package__init__); writing your own is subclassingAlgorithmand overriding the seam(s) your signal needs (see The API — two seams to override above). Duplication of orchestration between similar algorithms (OPD vs OPSD) is accepted so each class stays self-contained; shared math (group normalization, prefill alignment, length penalties) lives as plain functions inalgo/advantage.py. Algorithms take exactly one host-owned resource beyond their config — the live policy pool (Algorithm(config, policy_pool), never closed by the algorithm). Anything else an algorithm needs it builds from its own config insetup():opdconnects its frozenteacher;opsdbuilds the renderer for its demonstration hint (the tokenizer is always the live policy's — self-distillation has no separate model). Text → token ids always goes through the renderer, the same path the policy's own prompts take.One deliberate expressiveness trade: loss routing is not a free config axis (there is no
algo.loss; you can't writeopd+ observation-CE in TOML) — routing variation is algorithm variation, expressed as a class. ECHO is a proper advantage type, not a flag on GRPO. Its selection surface is configurable where it matters: per-rolealpha(roles.system/user/assistant/tool— setting any role replaces the whole table) and an optionalfilterhook (import_path+kwargs, called once per rollout asfilter_fn(rollout, **kwargs) -> list[list[bool]], one keep-mask per trainable branch spanning that branch'stoken_ids; it can only narrow the role selection, never widen it) — the raw rollout exposes message text and sampling logprobs, so warning filters and low-probability filters are user code, no framework surface.Extending it: code, not config-as-code
The loss is fixed; the signal is open — and you open it in code. There are exactly three loss components (
rl/ce/ref_kl), each globally normalized, and the trainer is algorithm-blind — it runs those three and nothing else.action_loss_typeis not a config field and has no"custom"value: it is aClassVardeclaration on each algorithm class (Literal["rl", "ce", "ref_kl"]) routing the action tokens into one of the three. You cannot configure a fourth loss component into existence.A new credit-assignment scheme is a new named algorithm in the repo, not a config that points at an import path. Subclass
Algorithm, assign credit in the scoring hook whose timing fits (score_rollout/score_group) viarollout.assign_advantages(...), add a typed*AlgoConfigto the discriminated union, and register the class inALGORITHM_CLASSES. There is nocustomadvantage type and no advantageimport_path— the config surface is a closed menu of vetted algorithms with typed, validated fields; extension is forking, and the authoring path is deliberately small (onescore_groupthat builds rewards and assigns; shared math likeefficiency_shapinglives inalgo/advantage.py).The one remaining import-path escape hatch is echo's optional token
filter— and it is deliberately the weakest kind: it does not define an algorithm or touch the loss, it is a per-rollout boolean predicate (filter_fn(rollout, **kwargs) -> list[list[bool]], one keep-mask per trainable branch) that can only narrow the role-selected echo tokens, never widen them or assign credit. It parameterizes a vetted algorithm's data selection (drop tool-output warning lines, drop low-probability tokens), the same category as an env's reward function — env-specific data, not algorithm structure. The flip side is the hosted story: because every shipped algorithm is a named class plus per-token weight streams on the wire (data, not code), a hosted product exposes the vetted algorithm names and their knobs, disables that lonefilterimport-path, and still gets the full combinatorial reach over which streams get written without arbitrary code execution.The algorithms
algo.typegrpo(default)rlmax_rl(arXiv:2602.02710)rlopdref_kl(needs a frozenteacher)sftsampling.source)ceopsd(SDFT, arXiv:2601.19897)ref_klvs the live policy (self-distillation; no model to declare)echo(ECHO)rlon actions + per-role α·ceon env-provided tokensThere is no preset layer: the type IS the algorithm, its class defaults ARE the vetted setting, and every key beyond
typeis visibly the user's own assembly (an earlier iteration had named atomic presets; with type-plus-defaults equal to the preset for every algorithm, the layer had nothing left to do and was deleted). Per-env algorithms compose in one run. Because a reference can be the literal"policy",opsdruns the SDFT paper's setting with zero extra deployments — that's its default.Model references — no registry; roles are algorithm-local
prime-rl assumes it is never responsible for hosting any model other than the trainable policy. Everything else is an external OpenAI-compatible endpoint, declared inline where it's used:
ModelReference = "policy" | FrozenModelConfig, whereFrozenModelConfigis just the existingClientConfigplus the served model'sname— no new declaration scheme.base_url(or an elastic deployment) is required: a frozen reference with no endpoint fails at parse time.teacherslot and nomodel_roleindirection.opddeclares a required frozenteacher(typedFrozenModelConfig);sftsamples from itssampling.source(a frozen model);opsdself-distills against the live policy and declares no model at all. The base config knows nothing about teachers. Validation errors name the field directly ("algorithm 'opd' ... teacher: Field required").opd's teacher is typedFrozenModelConfig, so"policy"isn't even representable (the KL would be ≡ 0); frozen sampling can't feed anrl/ref_kl-type algorithm (no policy sampling logprobs for the importance ratio);sftwithout a frozen source is rejected (CE on the policy's own tokens is not a distillation target); and group-relative advantage withgroup_size=1warns loudly.Transport (orchestrator → trainer wire)
Everything an algorithm decides reaches the trainer as per-token streams on
TrainingSample(src/prime_rl/transport/types.py) — there is no scalar advantage and no algorithm tag on the wire. The trainer reads the streams, not the algorithm. Five optional per-token arrays carry the whole training signal, each the fullprompt + completionlength:Nonemeansadvantagesopd/opsd; legal only for samples with no live rl tokens, else the trainer raisesrl_weightsrl(DPPO + KL) component per token1.0on every trainable tokence_weightsce(masked NLL) component per tokencecomponentref_kl_weightsref_kl(reverse-KL-to-reference) component per tokenref_klcomponentref_logprobsThree rules make the wire legible:
0.0removes a token from its component's mask and its global denominator; components may overlap on a token (their gradients sum).None= absent, and absent is the hot path. Plain GRPO ships exactly one extra stream (advantages, same order ascompletion_logprobs) and nothing else — the wire is as small as before this PR.ref_klships logprobs, not precomputed advantages, because the trainer evaluates the KL against the live policy each microbatch (a scalar couldn't capture that).trainer/batch.py) concatenates samples into aMicroBatch; when one packed sample carries a stream a neighbor lacks, the neighbor's span is backfilled with the component's identity fill (STREAM_FILL = {rl_weights: 1.0, ce_weights: 0.0, ref_kl_weights: 0.0}), so every per-token array stays aligned toinput_ids. This is exactly what lets samples from different algorithms (e.g. a GRPO env packed next to an OPD env) share one micro-batch — membership is per token, not per bin. A post-pack assert pins every stream's length tolen(input_ids).The trainer then runs each component over its stream (rl mask
= loss_mask & rl_weights != 0; ce/ref_kl mask= weights != 0) and sums the per-component means; the three global denominators[N_rl, N_ce, N_ref_kl]come from one batched all-reduce, so every rank issues the same collective and the no-stream path is identical to the old single-loss_scalemath.One field on
TrainingSampleis deliberately not on the wire:obs_spans, interleaving's provenance record for env-provided observation tokens ([completion_start, step_idx, step_prompt_start, length]per later-turn extension span). Algorithms that train on observations (echo) consume it at group time and write thece_weightsstream directly; it is cleared before transport.How
prime_rl.configs.algorithm): one algorithm discriminated union absorbs the former token scorers (logprobs/demo_logprobs→opd/opsdwith aModelReference) and loss routing (EchoAlgoConfig(GRPOAlgoConfig)carries the role table + filter); the action-token loss component is anaction_loss_typeclass property of the algorithm, never configured; each variant declares its own reference field where the model is used (opd.teacheras a requiredFrozenModelConfig,sft.sampling.source; opsd none) — noalgo.modelshorthand and nomodel_roleindirection.algoinherit the top-level one). EveryAlgoConfigis built exactly once with everything in place: no preset tables, no name field, no merge machinery, no__pydantic_fields_set__surgery.orchestrator/algo/package +orchestrator/sampler.py):Algorithm— base class with the two scoring hooks (score_rollout/score_group, detailed under The API above) plussetup/connectfor frozen-model pools. Two non-virtual methods drive them and stamp the wire fields:finalize_rolloutrunsscore_rolloutas each rollout is tokenized (rollout-local credit and reference prefill — OPD/OPSD attachref_logprobshere);finalize_grouprunsscore_groupthen stamps each sample (the advantage stream — prompt-padded and sliced across samples — plus per-token loss routing into the declared component).setup()connects anInferencePoolto the algorithm's inline frozen reference (connect_frozen_pool— client-side only: prime-rl connects and waits, never launches) and tracksconnected_poolsfor shutdown. The live policy pool, by contrast, is handed to every algorithm asself.policy_pool(host-owned, never closed by the algorithm —opsdself-distills against it, others may judge against it or ignore it);connect()only ever opens frozen endpoints, soconnected_poolsis exactly the set the algorithm owns and must close. The named classes own their hook bodies outright;build_algorithmdispatches onalgo.type.Sampler— one per env, owns the rollout source: the generating pool (policy, or a frozen pool it connects in its ownsetup()),samples_from_live_policy, andsampling_args()(strips the logprobs knob for frozen endpoints). The dispatcher readsenv.samplerfor pool/liveness; the algorithm never sees sampling.finalize_rolloutper rollout (reference scoring runs inline here) andfinalize_groupper group; orchestrator setup is oneasyncio.gatherover both objects'setup().transport/types.py): the per-token streams laid out under Transport above.TrainingSample/MicroBatchcarry the optionalrl_weights/ce_weights/ref_kl_weightsstreams, theadvantagesstream, andref_logprobs; absent streams keep the plain-GRPO wire minimal, and the packer backfills streams across heterogeneous bins (STREAM_FILL) so samples of different algorithms pack freely. The historical collapse of the scalaradvantage+token_advantagesinto one per-tokenadvantagesstream, andteacher_logprobs→ref_logprobs, are in Breaking changes below.compute_lossruns each component over its weight stream (rl mask =loss_mask & rl_weights != 0; ce/ref_kl mask =weights != 0) and sums the per-component means; the three global denominators come from one batched all-reduce ([N_rl, N_ce, N_ref_kl]), so every rank issues the same collective. The no-stream hot path is byte-identical to the old single-loss_scalemath with zero extra device syncs. Mixed-algorithm packing fix: bins mixing ref-bearing (e.g. OPD) and ref-less (e.g. GRPO) samples now keepref_logprobsposition-aligned withinput_ids(0.0 placeholders, backfill-or-pad like the sibling per-token arrays), with a regression test and a post-pack invariant assert that every per-token array on every micro batch matcheslen(input_ids).Breaking changes (intentionally, no deprecation aliases)
orchestrator.training_modeis deleted (wasrl/opd/sft) →[orchestrator.algo] type = ....advantage.type→algo.type: thealgobundle is a discriminated union keyed ontypedirectly —[orchestrator.algo] type = "grpo"(was[orchestrator.algo.advantage]). The unusedadvantage = {}env/top-level shorthand is deleted;opddeclares a nativealgo.teacherfield.typenames the algorithm:group_norm→grpo,ref_kl→opd,demo_ref_kl→opsd,supervised→sft(config classes renamed to match). The presetnamefield is deleted — type-plus-defaults is the algorithm.observations/observation_weightare replaced byroles.<role>.alpha(tool bodies @ 0.1 default; setting any role replaces the table) plus an optionalfilterhook. Echo always requiresorchestrator.renderer(the no-attribution"all"mode is gone).sftrequires a frozen sampling source — CE on the policy's own tokens is rejected at validation (previously silently assemblable).[orchestrator.teacher]is deleted → each algorithm declares its frozen model inline where used:[orchestrator.algo.teacher]foropd,[orchestrator.algo.sampling.source]forsft(opsdneeds none), withname+base_url.[orchestrator.model], flat:name/lora/vlm/trust_remote_codedirectly on it, the deployment under[orchestrator.model.client]— no moremodel.modelstutter, in configs or resolved dumps (HostedModelConfigis nowModelConfig+ aclientfield, mirroringFrozenModelConfig). The[orchestrator.policy]/[orchestrator.student]aliases, the[orchestrator.client]shorthand, and the flat-key re-nesting shim are deleted.algorithm.token_scoreris deleted — scorers are advantage strategies now (logprobs→ref_kl,demo_logprobs→demo_ref_kl).algo.lossdoes not exist — the action-token loss component derives from the advantage strategy, and observation-token routing is theechoadvantage type (observation_weight).default→group_norm;noneis deleted (the ref_kl-family strategies carry its no-scalar semantics; nothing else used it).TrainingSample.advantage(scalar) +token_advantages→ one per-tokenadvantagesstream;AdvantageOutputsis deleted — algorithms assign credit in their scoring hooks viarollout.assign_advantages(scalar | list[float])(scalar broadcast for uniform credit), and advantage-based filters/metrics derive from the streams (zero-advantage filter = all-zero stream; logged distributions = per-rollout means).TrainingSample.teacher_logprobs→ref_logprobs; the loss-type partition (loss_type/token_loss_types/token_loss_weights/loss_type_ids,LOSS_CORE_*) is replaced by the component weight streamsrl_weights/ce_weights/ref_kl_weights(the partition was the degenerate disjoint case); thetime/teacher_logprobsmetric is removed (reference scoring now runs inline at rollout arrival, not as a distinct batch phase).configs/debug/training_modes/→configs/debug/algorithms/(incl. newself_distill.tomlrunning SDFT against the live policy); CI integration configs migrated.Configs are
extra="forbid", so stale configs fail loudly at parse time with the exact unknown key.Merged with
mainincluding #2720 (teacherless SFT). Its spelling is deliberately not carried forward:sftmeans distillation from a frozen teacher, and pointing it at"policy"is rejected at validation — CE on the policy's own rollouts is a different algorithm, and if wanted it gets its own advantage type/class rather than a degenerate spelling ofsft.Also merged
mainincluding #2641 (multiplexed trainer token export): the export machinery composes with the component weight streams — each exported record carries the per-token rl/ce/ref_kl arrays, and micro-batches carryrun_id/run_stepalongside the streams (training_modestays deleted).Deferred (next PRs)
The two-component model is built so each of these lands as its own PR without re-touching the wire:
Sampler— today the Sampler answers one question (which pool, and is it live); next it absorbs within-env example iteration and group production, plus a sink→samplerobserve()feedback edge at group finalization. In increasing order of machinery that unlocks: difficulty-pool curricula (per-example selection state fed back from group rewards), static dataset sources (supervised training from demonstrations instead of a frozen endpoint), and replay buffers / offline experience (a store between stamping and batch assembly — advantages and weight streams are already stamped-then-frozen at group finalization, which is the prerequisite).tool_namesfiltering (non-breaking optional field on the tool role;message_tool_namesalready rides in the attribution) and θ-dependent low-probability filters, which need a trainer-side per-component knob since the denominator collective happens pre-forward.Σwdenominators — a globally-folded λ cancels under it, so it pairs with a trainer-side coefficient); per-slot loss-fn configs for ce/ref_kl (the ref_kl trust region — one-sided today — becomes a deliberate choice there); barrier-blind sink (the group wait becomes a dependency declared by the algorithm;finalize_group→finalize); EMA/lagged reference endpoints; exposing frozen endpoints to envs as judge clients.87c38c704renames the config surface fromadvantage.typetoalgo.typeper review: thealgobundle flattens into a discriminated union (each*AlgoConfigvariant carriestype+ its params + the sharedsampling/teacheronBaseAlgoConfig), the unusedadvantage = {}shorthand + its fold validator are dropped, and each algorithm declares its reference inline (opd.teacher,sft.sampling.source). RuntimeAlgorithmtakes the algo config;build_algorithmdispatches onalgo.type. 12 debug/CI TOMLs,docs/algorithms.md+training.md, and the unit tests migrated. Orchestrator+config unit tests pass (incl. the full TOML-load sweep) + abuild_algorithmdispatch smoke over every algorithm type; ruff clean.cf9a21a3b/f13119938collapse the override surface from three scoring hooks to two:score_batchis removed and OPD/OPSD do their reference prefill inscore_rollout(at arrival);finalize_rollout/finalize_groupbecome non-virtualAlgorithmmethods (the cross-envfinalize_batchis gone), andAlgorithmis now constructed with just(config, policy_pool)—opsdbuilds its own hint renderer insetup()with the live policy's tokenizer (themodelknob is gone; self-distillation has no separate model). The per-rolloutmax_concurrentscoring semaphore is dropped — the serial main loop already bounds in-flight reference calls — and with it the now-orphanedtime/scoringmetric. Docs + tests updated; orchestrator/config unit tests green.🤖 Generated with Claude Code
Note
High Risk
Large breaking config and orchestrator/trainer contract change (per-token streams, deleted keys); mistakes in heterogeneous batches or frozen-model wiring could silently skew gradients until caught by tests.
Overview
Replaces the
rl/opd/sfttraining-mode switch with[orchestrator.algo] type = …(grpo,max_rl,opd,opsd,sft,echo). Frozen teachers move inline (algo.teacherfor OPD,algo.sampling.sourcefor SFT); the trainable model is a flat[orchestrator.model]with[orchestrator.model.client](nostudent/orchestrator.teacher).Orchestrator runtime gains per-env
Sampler(which pool generates rollouts) andAlgorithmsubclasses withscore_rollout/score_group/score_batch, plus wire stamping ofadvantages,rl_weights/ce_weights/ref_kl_weights, andref_logprobs. The dispatcher keys off policy vs frozen sampling for cache salting and off-policy cancellation; customorchestrator.advantageandadvantage.pyare removed in favor of algorithm classes.Trainer/docs/configs: loss is documented and wired as three globally normalized components; CI and
configs/debug/training_modes/becomeconfigs/debug/algorithms/(new echo, max_rl, self_distill, mixed GRPO+OPD). Metrics renametime/teacher_logprobs→time/scoring.Reviewed by Cursor Bugbot for commit c326868. Bugbot is set up for automated code reviews on this repo. Configure here.