Skip to content

feat: algorithm abstraction — named algorithm classes + inline frozen-model references (grpo, opd, sft_distill, self_distill, echo)#2746

Merged
hallerite merged 27 commits into
mainfrom
feat/algorithm-abstraction
Jun 27, 2026
Merged

feat: algorithm abstraction — named algorithm classes + inline frozen-model references (grpo, opd, sft_distill, self_distill, echo)#2746
hallerite merged 27 commits into
mainfrom
feat/algorithm-abstraction

Conversation

@hallerite

@hallerite hallerite commented Jun 9, 2026

Copy link
Copy Markdown
Member

Note

On verifiers-v1. Targets main after the vf-v1 ⇆ nano bridge (#2742) merged. Rollout is a vf.Trace subclass, samples are flat-token TrainingSample, scoring runs off vf.Trace branches/nodes, and the renderer is required. origin/main is merged in (latest: #2879 internal cleanup, #2880 eval-metric fix, #2881 dead-processor= removal, verifiers bumped to 62dc38a5); the branch is even with main and CI is green.

The two prior reach-arounds are now resolved on this branch:

  1. OPD/OPSD prefill scoring — the hand-rolled compute_prefill_logprobs is gone; prefill logprobs go through InferencePool.score() (PrefillScorer).
  2. echo granularity — upstream MessageNode.is_content landed, 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 (has is_content, Trace.to_record(), Branch.input_len/output_len), no merge conflicts, ruff clean, 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.type names the algorithmgrpo, max_rl, opd, opsd, sft, echo — and each type's class defaults are its vetted setting; there is no separate preset layer:

  1. Sampling — how train rollouts are produced: which model generates them, "policy" or an inline frozen endpoint. At runtime this builds the env's Sampler (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).
  2. Advantage — the per-token training signal: one mapping from a finalized rollout to per-token (loss component, weight) pairs. Credit assignment computes the magnitude, loss routing picks the component — two coordinates of the same output, so they are one config component and one runtime object. Group-relative strategies compute scalars on the orchestrator and ship numbers; reference-KL strategies query a reference model as each rollout arrives and ship its prefill logprobs for the trainer to evaluate against the live policy. The strategy determines the action-token loss component (rl / ce / ref_kl) and what happens to env-provided observation tokens (masked out by default; echo trains on them with weighted CE — selected by message role via the renderer's per-token attribution, each role at its own alpha, 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 componentsrl (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:

$$\mathcal{L} = \tfrac{\sum \mathcal{L}_{rl}}{N_{rl}} + \tfrac{\sum \mathcal{L}_{ce}}{N_{ce}} + \tfrac{\sum \mathcal{L}_{ref_kl}}{N_{ref_kl}}$$

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.0 removes 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.

[orchestrator.algo]
type = "opd"

[orchestrator.algo.teacher]   # opd's own field: the frozen model it scores against
name = "Qwen/Qwen3-32B"
base_url = ["http://localhost:8001/v1"]

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 + Algorithm objects 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:

class Algorithm:
    # declaration — read by the pipeline, never branched on
    action_loss_type: ClassVar[ActionLossType] = "rl"   # which component action tokens feed: rl | ce | ref_kl

    async def setup(self) -> None: ...                   # connect pools to frozen models via self.connect(ref)

    async def score_rollout(self, rollout: Rollout)      -> None: ...   # one rollout, on arrival (model I/O here)
    async def score_group  (self, group:  list[Rollout]) -> None: ...   # the cohort, before filtering

The two hooks are one ladder of scope, the wider scope unlocked by a later barrier — so scope and timing coincide:

hook input fires scope model access
score_rollout Rollout as each rollout is tokenized one rollout, no siblings yes (self.policy_pool / self.teacher_pool)
score_group list[Rollout] on group completion, before filtering the whole cohort
  • score_rollout — rollout-local signals (raw reward, process rewards, echo's observation weighting) and the model-access seam: query a reference (OPD's frozen teacher, 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 typed vf.Trace subclass, with prime-rl's training fields bolted on. A hook reads the trace directly and writes credit through one method:

rollout.reward       # the rollout's scalar reward (vf.Trace property)
rollout.nodes        # the message graph; rollout.num_turns / .task / .info / ... read straight off the trace
rollout.samples      # the TrainingSamples the renderer built (per branch)
rollout.env_name     # the producing env
rollout.assign_advantages(scalar | list[float])   # write the rl credit stream

assign_advantages takes 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-token advantages stream; 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 writes sample.ce_weights over is_content tokens at score_rollout time — 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 whole Rollout (an earlier RolloutView handle was collapsed away — everyone reached through its .raw escape hatch anyway). Stage discipline (don't read is_filtered before filtering, don't read advantages before assigning) is a convention, not an enforced wall.

The pipeline drives the hooks through two non-virtual Algorithm methods 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 overrides score_group alone 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.type Class Component hook(s)
grpo GRPOAlgorithm rl score_group: group-norm scalars (optional length penalty)
max_rl MaxRLAlgorithm rl score_group: mean-normalized group scalars (arXiv:2602.02710: unbiased for the order-group_size truncation of the max-likelihood objective)
echo EchoAlgorithm rl + ce score_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)
opd OPDAlgorithm ref_kl score_rollout: own-context prefill under the teacher (a required frozen model; no credit — advantages=None, filters skip)
opsd OPSDAlgorithm ref_kl score_rollout: demo-conditioned prefill under the live policy (self-distillation, no model to declare; no credit)
sft SFTDistillAlgorithm ce score_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-virtual finalize_* drivers in base.py and dispatch in the package __init__); writing your own is subclassing Algorithm and 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 in algo/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 in setup(): opd connects its frozen teacher; opsd builds 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 write opd + 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-role alpha (roles.system/user/assistant/tool — setting any role replaces the whole table) and an optional filter hook (import_path + kwargs, called once per rollout as filter_fn(rollout, **kwargs) -> list[list[bool]], one keep-mask per trainable branch spanning that branch's token_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_type is not a config field and has no "custom" value: it is a ClassVar declaration 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) via rollout.assign_advantages(...), add a typed *AlgoConfig to the discriminated union, and register the class in ALGORITHM_CLASSES. There is no custom advantage type and no advantage import_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 (one score_group that builds rewards and assigns; shared math like efficiency_shaping lives in algo/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 lone filter import-path, and still gets the full combinatorial reach over which streams get written without arbitrary code execution.

The algorithms

algo.type Sampling Loss
grpo (default) policy rl
max_rl (arXiv:2602.02710) policy rl
opd policy ref_kl (needs a frozen teacher)
sft frozen model (sampling.source) ce
opsd (SDFT, arXiv:2601.19897) policy ref_kl vs the live policy (self-distillation; no model to declare)
echo (ECHO) policy rl on actions + per-role α·ce on env-provided tokens

There is no preset layer: the type IS the algorithm, its class defaults ARE the vetted setting, and every key beyond type is 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", opsd runs 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, where FrozenModelConfig is just the existing ClientConfig plus the served model's name — no new declaration scheme. base_url (or an elastic deployment) is required: a frozen reference with no endpoint fails at parse time.
  • Each algorithm names its reference on the field where the model is actually used — there is no shared teacher slot and no model_role indirection. opd declares a required frozen teacher (typed FrozenModelConfig); sft samples from its sampling.source (a frozen model); opsd self-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").
  • Liveness is a property of the reference, not a role: policy-sourced rollouts get version-salted prefix caches, carry sampling logprobs, and age off-policy; frozen-sourced rollouts get a stable prefix cache, skip the logprobs knob, and are never off-policy-cancelled. The pipeline branches on liveness alone.
  • Config-time validation: opd's teacher is typed FrozenModelConfig, so "policy" isn't even representable (the KL would be ≡ 0); frozen sampling can't feed an rl/ref_kl-type algorithm (no policy sampling logprobs for the importance ratio); sft without a frozen source is rejected (CE on the policy's own tokens is not a distillation target); and group-relative advantage with group_size=1 warns 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 full prompt + completion length:

field what it carries None means
advantages the rl credit stream (one number per token) no rl credit assigned — opd/opsd; legal only for samples with no live rl tokens, else the trainer raises
rl_weights scales the rl (DPPO + KL) component per token rl weight 1.0 on every trainable token
ce_weights scales the ce (masked NLL) component per token no ce component
ref_kl_weights scales the ref_kl (reverse-KL-to-reference) component per token no ref_kl component
ref_logprobs the reference model's prefill logprobs, against which the trainer takes the KL no reference

Three rules make the wire legible:

  1. Per-token from end to end. Uniform group credit (GRPO's reward-minus-mean) is broadcast over the completion tokens at assignment time — the scalar never reaches the wire. A weight of 0.0 removes a token from its component's mask and its global denominator; components may overlap on a token (their gradients sum).
  2. None = absent, and absent is the hot path. Plain GRPO ships exactly one extra stream (advantages, same order as completion_logprobs) and nothing else — the wire is as small as before this PR. ref_kl ships logprobs, not precomputed advantages, because the trainer evaluates the KL against the live policy each microbatch (a scalar couldn't capture that).
  3. Streams materialize on demand at packing. The packer (trainer/batch.py) concatenates samples into a MicroBatch; 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 to input_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 to len(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_scale math.

One field on TrainingSample is 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 the ce_weights stream directly; it is cleared before transport.

How

  • Configs (prime_rl.configs.algorithm): one algorithm discriminated union absorbs the former token scorers (logprobs/demo_logprobsopd/opsd with a ModelReference) and loss routing (EchoAlgoConfig(GRPOAlgoConfig) carries the role table + filter); the action-token loss component is an action_loss_type class property of the algorithm, never configured; each variant declares its own reference field where the model is used (opd.teacher as a required FrozenModelConfig, sft.sampling.source; opsd none) — no algo.model shorthand and no model_role indirection.
  • No preset machinery at all: env algorithm inheritance is a one-loop after-validator (envs without their own algo inherit the top-level one). Every AlgoConfig is built exactly once with everything in place: no preset tables, no name field, no merge machinery, no __pydantic_fields_set__ surgery.
  • Runtime (the orchestrator/algo/ package + orchestrator/sampler.py):
    • Algorithm — base class with the two scoring hooks (score_rollout / score_group, detailed under The API above) plus setup/connect for frozen-model pools. Two non-virtual methods drive them and stamp the wire fields: finalize_rollout runs score_rollout as each rollout is tokenized (rollout-local credit and reference prefill — OPD/OPSD attach ref_logprobs here); finalize_group runs score_group then stamps each sample (the advantage stream — prompt-padded and sliced across samples — plus per-token loss routing into the declared component). setup() connects an InferencePool to the algorithm's inline frozen reference (connect_frozen_pool — client-side only: prime-rl connects and waits, never launches) and tracks connected_pools for shutdown. The live policy pool, by contrast, is handed to every algorithm as self.policy_pool (host-owned, never closed by the algorithm — opsd self-distills against it, others may judge against it or ignore it); connect() only ever opens frozen endpoints, so connected_pools is exactly the set the algorithm owns and must close. The named classes own their hook bodies outright; build_algorithm dispatches on algo.type.
    • Sampler — one per env, owns the rollout source: the generating pool (policy, or a frozen pool it connects in its own setup()), samples_from_live_policy, and sampling_args() (strips the logprobs knob for frozen endpoints). The dispatcher reads env.sampler for pool/liveness; the algorithm never sees sampling.
    • The pipeline has zero algorithm conditionals: the dispatcher derives cache-salt/aging from the sampler's liveness; the sink calls finalize_rollout per rollout (reference scoring runs inline here) and finalize_group per group; orchestrator setup is one asyncio.gather over both objects' setup().
  • Wire (transport/types.py): the per-token streams laid out under Transport above. TrainingSample/MicroBatch carry the optional rl_weights / ce_weights / ref_kl_weights streams, the advantages stream, and ref_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 scalar advantage + token_advantages into one per-token advantages stream, and teacher_logprobsref_logprobs, are in Breaking changes below.
  • Trainer: compute_loss runs 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_scale math with zero extra device syncs. Mixed-algorithm packing fix: bins mixing ref-bearing (e.g. OPD) and ref-less (e.g. GRPO) samples now keep ref_logprobs position-aligned with input_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 matches len(input_ids).

Breaking changes (intentionally, no deprecation aliases)

  • orchestrator.training_mode is deleted (was rl/opd/sft) → [orchestrator.algo] type = ....
  • advantage.typealgo.type: the algo bundle is a discriminated union keyed on type directly — [orchestrator.algo] type = "grpo" (was [orchestrator.algo.advantage]). The unused advantage = {} env/top-level shorthand is deleted; opd declares a native algo.teacher field.
  • The advantage type names the algorithm: group_normgrpo, ref_klopd, demo_ref_klopsd, supervisedsft (config classes renamed to match). The preset name field is deleted — type-plus-defaults is the algorithm.
  • Echo selection is a role table: observations / observation_weight are replaced by roles.<role>.alpha (tool bodies @ 0.1 default; setting any role replaces the table) plus an optional filter hook. Echo always requires orchestrator.renderer (the no-attribution "all" mode is gone).
  • sft requires 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] for opd, [orchestrator.algo.sampling.source] for sft (opsd needs none), with name + base_url.
  • The policy sub-config is [orchestrator.model], flat: name / lora / vlm / trust_remote_code directly on it, the deployment under [orchestrator.model.client] — no more model.model stutter, in configs or resolved dumps (HostedModelConfig is now ModelConfig + a client field, mirroring FrozenModelConfig). The [orchestrator.policy] / [orchestrator.student] aliases, the [orchestrator.client] shorthand, and the flat-key re-nesting shim are deleted.
  • algorithm.token_scorer is deleted — scorers are advantage strategies now (logprobsref_kl, demo_logprobsdemo_ref_kl).
  • algo.loss does not exist — the action-token loss component derives from the advantage strategy, and observation-token routing is the echo advantage type (observation_weight).
  • Advantage types: defaultgroup_norm; none is deleted (the ref_kl-family strategies carry its no-scalar semantics; nothing else used it).
  • Wire/trainer: TrainingSample.advantage (scalar) + token_advantages → one per-token advantages stream; AdvantageOutputs is deleted — algorithms assign credit in their scoring hooks via rollout.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_logprobsref_logprobs; the loss-type partition (loss_type / token_loss_types / token_loss_weights / loss_type_ids, LOSS_CORE_*) is replaced by the component weight streams rl_weights / ce_weights / ref_kl_weights (the partition was the degenerate disjoint case); the time/teacher_logprobs metric is removed (reference scoring now runs inline at rollout arrival, not as a distinct batch phase).
  • configs/debug/training_modes/configs/debug/algorithms/ (incl. new self_distill.toml running 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 main including #2720 (teacherless SFT). Its spelling is deliberately not carried forward: sft means 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 of sft.

Also merged main including #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 carry run_id/run_step alongside the streams (training_mode stays deleted).

Note: this PR absorbed #2764 (named algorithm classes → two-component model → Sampler split), #2778 (MaxRL), and #2782 (echo per-role selection + filter hook); those branches were merged into this one and GitHub marked their PRs merged.

Deferred (next PRs)

The two-component model is built so each of these lands as its own PR without re-touching the wire:

  • Sampling strategies behind the 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→sampler observe() 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).
  • Trainer collapse — the three fixed components reduce to two cores (policy-gradient × supervised) composed with per-token factor streams. Dissolves the rl-slot-only custom-loss wart and lets the stability guards (importance correction, trust region) compose around custom losses instead of being replaced by them.
  • Remaining ECHO surface — per-role α, arbitrary roles, and content/sampling-logprob filters shipped in this PR; still open: tool_names filtering (non-breaking optional field on the tool role; message_tool_names already rides in the attribution) and θ-dependent low-probability filters, which need a trainer-side per-component knob since the denominator collective happens pre-forward.
  • Smaller: config knobs for component sums (per-env α/β/γ folded into the streams orchestrator-side — the wire and trainer already support overlap); opt-in weight-sum normalization (Σw denominators — 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_groupfinalize); EMA/lagged reference endpoints; exposing frozen endpoints to envs as judge clients.

87c38c704 renames the config surface from advantage.type to algo.type per review: the algo bundle flattens into a discriminated union (each *AlgoConfig variant carries type + its params + the shared sampling/teacher on BaseAlgoConfig), the unused advantage = {} shorthand + its fold validator are dropped, and each algorithm declares its reference inline (opd.teacher, sft.sampling.source). Runtime Algorithm takes the algo config; build_algorithm dispatches on algo.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) + a build_algorithm dispatch smoke over every algorithm type; ruff clean.

cf9a21a3b / f13119938 collapse the override surface from three scoring hooks to two: score_batch is removed and OPD/OPSD do their reference prefill in score_rollout (at arrival); finalize_rollout / finalize_group become non-virtual Algorithm methods (the cross-env finalize_batch is gone), and Algorithm is now constructed with just (config, policy_pool)opsd builds its own hint renderer in setup() with the live policy's tokenizer (the model knob is gone; self-distillation has no separate model). The per-rollout max_concurrent scoring semaphore is dropped — the serial main loop already bounds in-flight reference calls — and with it the now-orphaned time/scoring metric. 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 / sft training-mode switch with [orchestrator.algo] type = … (grpo, max_rl, opd, opsd, sft, echo). Frozen teachers move inline (algo.teacher for OPD, algo.sampling.source for SFT); the trainable model is a flat [orchestrator.model] with [orchestrator.model.client] (no student / orchestrator.teacher).

Orchestrator runtime gains per-env Sampler (which pool generates rollouts) and Algorithm subclasses with score_rollout / score_group / score_batch, plus wire stamping of advantages, rl_weights / ce_weights / ref_kl_weights, and ref_logprobs. The dispatcher keys off policy vs frozen sampling for cache salting and off-policy cancellation; custom orchestrator.advantage and advantage.py are 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/ become configs/debug/algorithms/ (new echo, max_rl, self_distill, mixed GRPO+OPD). Metrics rename time/teacher_logprobstime/scoring.

Reviewed by Cursor Bugbot for commit c326868. Bugbot is set up for automated code reviews on this repo. Configure here.

@hallerite hallerite force-pushed the feat/algorithm-abstraction branch from fb0da20 to f35e3a8 Compare June 9, 2026 19:59
@hallerite hallerite changed the title feat: Algorithm abstraction — sampling/scoring/loss presets (grpo, opd, sft_distill, self_distill, echo) feat: algorithm abstraction — model registry (no model roles) + per-env presets (grpo, opd, sft_distill, self_distill, echo) Jun 9, 2026
@hallerite hallerite changed the title feat: algorithm abstraction — model registry (no model roles) + per-env presets (grpo, opd, sft_distill, self_distill, echo) feat: algorithm abstraction — model registry + unified advantage strategies (grpo, opd, sft_distill, self_distill, echo) Jun 9, 2026
@hallerite hallerite marked this pull request as ready for review June 9, 2026 22:50
Comment thread packages/prime-rl-configs/src/prime_rl/configs/algorithm.py Outdated
Comment thread src/prime_rl/trainer/batch.py Outdated
Comment thread packages/prime-rl-configs/src/prime_rl/configs/orchestrator.py Outdated
Comment thread packages/prime-rl-configs/src/prime_rl/configs/algorithm.py
Comment thread examples/glm5_pd_disag/rl.toml Outdated
Comment thread configs/debug/algorithms/echo.toml Outdated
Comment thread docs/algorithms.md Outdated
Comment thread docs/algorithms.md Outdated
Comment thread docs/algorithms.md
Comment thread configs/ci/integration/reverse_text_rl_opd/start.toml Outdated
Comment thread packages/prime-rl-configs/src/prime_rl/configs/algorithm.py Outdated
Comment thread packages/prime-rl-configs/src/prime_rl/configs/algorithm.py Outdated
Comment thread packages/prime-rl-configs/src/prime_rl/configs/algorithm.py Outdated
Comment thread packages/prime-rl-configs/src/prime_rl/configs/algorithm.py Outdated
Comment thread packages/prime-rl-configs/src/prime_rl/configs/orchestrator.py Outdated
Comment thread packages/prime-rl-configs/src/prime_rl/configs/orchestrator.py Outdated
Comment thread packages/prime-rl-configs/src/prime_rl/configs/orchestrator.py Outdated
Comment thread src/prime_rl/transport/types.py Outdated
Comment thread src/prime_rl/entrypoints/rl.py
@hallerite hallerite changed the title feat: algorithm abstraction — model registry + unified advantage strategies (grpo, opd, sft_distill, self_distill, echo) feat: algorithm abstraction — unified advantage strategies + inline frozen-model references (grpo, opd, sft_distill, self_distill, echo) Jun 10, 2026
Comment thread packages/prime-rl-configs/src/prime_rl/configs/orchestrator.py Outdated
@hallerite hallerite changed the title feat: algorithm abstraction — unified advantage strategies + inline frozen-model references (grpo, opd, sft_distill, self_distill, echo) feat: algorithm abstraction — named algorithm classes + inline frozen-model references (grpo, opd, sft_distill, self_distill, echo) Jun 11, 2026
Comment thread configs/debug/algorithms/self_distill.toml Outdated
Comment thread packages/prime-rl-configs/src/prime_rl/configs/orchestrator.py
Comment thread packages/prime-rl-configs/src/prime_rl/configs/algorithm.py Outdated
@hallerite hallerite requested review from mikasenghaas and samsja June 12, 2026 00:53
Comment thread configs/ci/integration/reverse_text_rl_opd/start.toml Outdated
Comment thread configs/ci/integration/reverse_text_rl_opd/start.toml Outdated
Comment thread configs/ci/integration/reverse_text_rl_opd/start.toml Outdated
Comment thread configs/ci/integration/reverse_text_rl_opd/start.toml Outdated
Comment thread configs/ci/integration/reverse_text_rl_sft/start.toml
Comment thread src/prime_rl/orchestrator/algo/algorithm.py Outdated
Comment thread src/prime_rl/orchestrator/algo/algorithm.py Outdated
Comment thread src/prime_rl/orchestrator/algo/routing.py Outdated
Comment thread src/prime_rl/orchestrator/dispatcher.py
Comment thread src/prime_rl/orchestrator/envs.py
Comment thread src/prime_rl/orchestrator/algo/opsd.py Outdated
…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>
@hallerite hallerite force-pushed the feat/algorithm-abstraction branch from 319ddf6 to 7a4a30d Compare June 24, 2026 23:35
hallerite and others added 3 commits June 25, 2026 02:19
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>
hallerite and others added 3 commits June 25, 2026 19:44
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>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Fix All in Cursor

❌ 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.

Comment thread docs/training.md Outdated
hallerite and others added 5 commits June 25, 2026 22:30
… 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>
Comment thread configs/ci/integration/reverse_text_rl_opd/start.toml Outdated
Comment thread configs/ci/integration/reverse_text_rl_sft/start.toml Outdated
Comment thread configs/ci/integration/reverse_text_rl_sft/start.toml
Comment thread configs/elastic/rl.toml
max_completion_tokens = 768

[orchestrator.client.elastic]
[orchestrator.model.client.elastic]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

have to let @eexwhyzee know then

Comment thread packages/prime-rl-configs/src/prime_rl/configs/orchestrator.py Outdated
Comment thread src/prime_rl/orchestrator/algo/base.py Outdated
Comment thread src/prime_rl/orchestrator/algo/grpo.py Outdated
Comment thread src/prime_rl/orchestrator/algo/max_rl.py Outdated
Comment thread src/prime_rl/orchestrator/algo/grpo.py Outdated
Comment thread src/prime_rl/orchestrator/algo/opd.py Outdated
hallerite and others added 7 commits June 26, 2026 15:26
…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>

@mikasenghaas mikasenghaas left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LFGTM

@hallerite hallerite merged commit 0be5ac4 into main Jun 27, 2026
28 of 29 checks passed
@hallerite hallerite deleted the feat/algorithm-abstraction branch June 27, 2026 19:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants