composer-replication-framework / research /08-sdpo-grpo-integration.md
Codeseys's picture
research: Composer 2.5 data-gen + targeted-textual-feedback deep-research wave
6049d00
|
Raw
History Blame Contribute Delete
30.1 kB
# SDPO ⊕ Dr. GRPO: wiring the on-policy KL-at-error-turns channel into a live RL loop
> **Design date:** 2026-05-28.
> **Scope:** A concrete, implementable design for adding the SDPO auxiliary
> loss channel (on-policy KL at error turns, teacher = same weights conditioned
> on a hint) as a **second loss head** on a live **Dr. GRPO** update step. Targets
> the two integration substrates already in this repo: the **PRIME-RL parity
> recipe** (`recipes/prime_rl/composer_loss.py`) and the **TRL `GRPOTrainer`
> subclass** (`trainer/composer_trainer.py`). Recommends the TRL subclass as the
> host and gives a ~70-LoC `ComposerGRPOTrainer` sketch.
> **Method:** Lead with local-file analysis of `loss.py`, `composer_loss.py`,
> `composer_trainer.py`, `data_collator.py`, plus `research/07` (HintGenerator)
> and `research/10` (the Dr. GRPO target). One bounded TRL API lookup
> (`mcp_exa_get_code_context_exa` on `huggingface/trl@main`) to confirm the
> `GRPOTrainer` loss-override surface; the DeepWiki follow-up timed out, so the
> version-robust guard in §4 documents both the `_compute_loss(self, model,
> inputs)` internal hook (what this repo already overrides) and the public
> `compute_loss(self, model, inputs, return_outputs=False,
> num_items_in_batch=None)` HF-Trainer wrapper.
---
## TL;DR
SDPO is **not** the GRPO-KL-to-reference term and must not be folded into it. It
is a **separate distillation head**: a generalized-JSD between the student's
on-policy logits and the **same model's** logits when its context has a hint
spliced in at the error turn, masked to the post-hint recovery tokens. The
integration is therefore "compute the Dr. GRPO loss as usual, then **add
`beta_sdpo · JSD_error_turns`** before `.backward()`."
- **Host = the TRL `GRPOTrainer` subclass.** It already exists
(`ComposerReplicationTrainer`), already overrides the loss with exactly this
`grpo + alpha*sdpo + beta*replay` shape, and — decisively — it has **full
logits** in `_compute_loss`. The PRIME-RL recipe **cannot** host SDPO today:
its `LossInputs` exposes per-token **log-probs only, not full vocabulary
logits**, and `composer_loss.py` correctly raises `NotImplementedError` when
`alpha_sdpo>0`. SDPO needs the full distribution; PRIME-RL is blocked until
upstream exposes logits.
- **Attach point:** inside the Dr. GRPO update step, after the policy-gradient +
k1-KL loss is computed on the minibatch, run one **student forward (grad)** +
one **teacher forward (`no_grad`, hint-spliced context)**, take
`generalized_jsd_loss` masked to `sdpo_loss_mask`, scale by `beta_sdpo`, and
add. Single-epoch Dr. GRPO makes this clean: the teacher forward happens on
**the same minibatch being updated**, so the KL is genuinely on-policy.
- **Dr. GRPO specifics are preserved untouched:** SDPO touches neither the
advantage estimator (no std-norm, no length-standardization) nor the GRPO
**k1** (`−log r`) KL-to-ref. It is purely additive.
- **CPU-testable:** a 1–2 rollout Dr. GRPO step on Qwen2.5-0.5B with the SDPO
channel on, mirroring the existing `examples/sdpo_real_trace_train_smoke`.
---
## 1. The two in-repo substrates, and why TRL is the host
### 1.1 Substrate A — TRL `GRPOTrainer` subclass (`trainer/composer_trainer.py`)
Already in the repo and already the right shape. `ComposerReplicationTrainer`
subclasses `trl.GRPOTrainer` and overrides:
```python
def _compute_loss(self, model, inputs) -> torch.Tensor:
grpo_loss = super()._compute_loss(model, inputs) # channel 1
sdpo_kl = self._compute_sdpo_loss(model, inputs) # channel 2
replay_dpo = self._compute_trace_replay_loss(model, inputs)
return grpo_loss + self.alpha_sdpo*sdpo_kl + self.beta_replay*replay_dpo
```
`_compute_sdpo_loss` (lines 133–178) already does the **student forward (grad) +
teacher forward (`no_grad`) over `ctx_teacher_input_ids`**, the
`student_logits.shape == teacher_logits.shape` gate, and
`generalized_jsd_loss(..., labels=inputs["sdpo_loss_mask"], beta, temperature,
token_clip, reduction="batchmean")`. This is the SDPO channel, intact. **It has
full logits** — the prerequisite PRIME-RL lacks.
**Decisive property:** TRL hands the subclass `model` and `inputs` and lets it
return any scalar; full `.logits` are available for both the student and the
hint-conditioned teacher forward. SDPO is a drop-in.
### 1.2 Substrate B — PRIME-RL parity recipe (`recipes/prime_rl/composer_loss.py`)
PRIME-RL's `CustomLossConfig` takes an importable `loss_fn(inputs:
LossInputs)` called **once per sample** on **1-D `(seq,)` tensors**. Channel 1
(DPPO + k1-style KL on the importance ratio) is **byte-for-byte parity-verified**
against upstream `default_loss_fn` and is an excellent Dr.-GRPO-adjacent PG loss.
But SDPO is **deferred by construction**:
```python
# composer_loss.py, lines 257-268
teacher_lp = getattr(inputs, "teacher_logprobs", None)
if alpha_sdpo > 0:
raise NotImplementedError(
"SDPO channel in the PRIME-RL recipe is deferred. PRIME-RL v0.5 "
"exposes (seq,) log-probs through LossInputs but not full vocabulary "
"logits, and SDPO/OPSD requires the full distribution. ...")
```
`generalized_jsd_loss` calls `log_softmax(dim=-1)` over the vocab axis. With
only a `(seq,)` log-prob vector there is **no vocab axis** — softmax of a
1-element slice is identically 1.0 and `log` is 0, i.e. a mathematically
degenerate, silently-zero channel (the Wave-13 finding the docstring cites). So
SDPO in PRIME-RL is blocked **until upstream exposes per-token full logits**, not
a thing we can paper over.
### 1.3 Recommendation
**Host the SDPO aux channel in the TRL `GRPOTrainer` subclass.** Rationale:
1. **Logits available** — the one hard requirement SDPO has and PRIME-RL lacks.
2. **The override already exists** with the exact additive shape; we
re-point channel 1 at Dr. GRPO and tighten the teacher forward (§4).
3. **Single-process, CPU-runnable** — matches the existing smoke harness, so the
SDPO-on Dr.-GRPO step is testable today (§6) without PRIME-RL's 3-actor mesh.
4. PRIME-RL stays the **scale/parity** path for channel-1-only runs; SDPO lands
there for free the moment `LossInputs.teacher_logits` (full distribution)
exists upstream — the adapter is otherwise ready.
> One caveat to fix while we're here: the current `ComposerReplicationTrainer`
> channel 1 is *vanilla* GRPO (`super()._compute_loss`). The Composer target is
> **Dr. GRPO** (`research/10`): length-standardization removed, **no std-dev
> advantage normalization**, **k1** (`−log r`) KL, Adam, single-epoch. §3 + §4
> pin those into the subclass; SDPO rides on top unchanged.
---
## 2. The exact attach point + data flow
SDPO attaches **inside one Dr. GRPO update step, after the PG+KL loss is formed,
before backward**. It is one extra additive scalar. Concretely, per minibatch:
```
┌─────────────────────── one Dr. GRPO update step (single-epoch) ──────────────────────┐
rollout ──▶ │ Channel 1 (Dr. GRPO): │
trajectory │ advantages = (R - group_mean) # NO /std, NO length-standardization │
(group of K)│ logπ_new = model(input_ids).logprobs # the on-policy student forward (grad) │
│ log_r = logπ_new - logπ_old # log importance ratio (old = rollout-time) │
│ pg = -(advantages * exp(log_r))[resp_mask] │
│ kl = (-log_r)[resp_mask] # k1 estimator, NOT k3 │
│ L_drgrpo = (pg + beta_kl * kl).sum() │
│ │
│ Channel 2 (SDPO) — SAME minibatch, reuses the student forward where possible: │
│ error sites ◀── reuse ingestion structural `tool_error` (research/07 §5) │
│ │ (turn.get("tool_error") is not None; single source of truth) │
│ ▼ │
│ HintGenerator.generate(ErrorContext) ──▶ hint text (research/07 §6, layered) │
│ │ │
│ ▼ data_collator splices hint at the error turn: │
│ ctx_teacher_input_ids (hint system-msg + recovery turn, chat-template aligned) │
│ input_ids (placeholder-of-equal-token-length so shapes match) │
│ sdpo_loss_mask (1 on post-hint recovery tokens only) │
│ │ │
│ ▼ │
│ student_logits = model(input_ids).logits # grad │
│ with no_grad: teacher_logits = model(ctx_teacher_input_ids).logits # stop-grad │
│ L_sdpo = generalized_jsd_loss(student, teacher, │
│ labels=sdpo_loss_mask, beta=jsd_beta, │
│ temperature=1.0, token_clip=0.05) # masked to error turn │
│ │
│ total = L_drgrpo + beta_sdpo * L_sdpo ──▶ .backward() ──▶ Adam.step() │
└────────────────────────────────────────────────────────────────────────────────────────┘
```
Key flow facts:
- **Error-site detection is not re-invented.** The ingestion layer already sets
`turn["tool_error"] = <error_kind>` (structural `is_error:true` flag first,
string-tag fallback), and the collator's `_is_error_turn` keys on exactly that
(`research/07` §5). The trainer **consumes** the collator's
`ctx_teacher_input_ids` / `sdpo_loss_mask`; it does not detect errors itself.
- **HintGenerator is called at collation time**, not in the loss. Per
`research/07` §6.1, the generator's only job is to produce the text spliced
into the teacher context; the collator's `_build_hint_injected_trace` does the
splice and the equal-length student alignment
(`_build_aligned_student_for_sdpo`). The trainer sees finished tensors.
- **The teacher forward is on the live weights**, hint-conditioned, `no_grad`.
It is *not* a separate model and *not* a re-rollout (`research/07` §1.3). One
extra forward per SDPO minibatch.
- **The JSD is masked to the error turn** via `sdpo_loss_mask` (post-hint
recovery tokens only), so SDPO supervises *exactly* the turn the hint targets,
leaving the rest of the trajectory to channel 1.
---
## 3. Reconciling with Dr. GRPO specifics
`research/10` pins the algorithm. SDPO must coexist without perturbing any of it:
| Dr. GRPO property (`research/10` §2) | Where it lives | SDPO interaction |
|---|---|---|
| **No std-dev advantage normalization** | advantage estimator | **None.** SDPO never touches advantages. Keep `A = R - group_mean` (no `/std`). |
| **Length-standardization term removed** | PG reduction | **None.** SDPO is a separate head; do not re-introduce a `1/|y|` factor via SDPO's reduction either (use `batchmean` over masked error-turn tokens, which is SDPO's own normalization, independent of trajectory length). |
| **k1 KL = `−log r`** (NOT k3) | GRPO KL-to-ref term | **Distinct from SDPO.** The GRPO k1 KL regularizes the policy toward the *reference/old* policy on all response tokens. SDPO's JSD pulls the policy toward the *hint-conditioned self-teacher* on error-turn tokens. Two different targets, two different token sets, two different weights (`beta_kl` vs `beta_sdpo`). Never merge them. |
| **Single-epoch (a prompt is never trained twice)** | outer loop | **This is what makes SDPO clean.** The teacher forward happens on the *same minibatch being updated this step* — the student logits and the hint-conditioned teacher logits are both from the current weights on the current rollout, so the distilled KL is genuinely **on-policy** (SDPO's defining property). No stale-teacher / replay-buffer drift to reconcile. |
| **Adam, full-parameter, async rollouts** | optimizer / infra | **None.** SDPO adds gradient only through the student forward; Adam consumes the summed gradient transparently. Async/off-policy weight sync (PipelineRL-style) affects channel 1's `logπ_old`; SDPO's teacher is the *current* weights so it is unaffected. |
**The one thing to get right:** SDPO's JSD is **SEPARATE** from the GRPO
KL-to-ref. In the loss expression `total = L_drgrpo + beta_sdpo*L_sdpo`, the
`L_drgrpo` already *contains* its own `beta_kl * k1_kl`. Do not let `beta_sdpo`
masquerade as a KL coefficient or vice-versa; they are logged separately
(`loss/grpo_kl` vs `loss/sdpo_jsd`).
---
## 4. Implementation handles — `ComposerGRPOTrainer(GRPOTrainer)`
A focused subclass that (a) forces channel 1 into the Dr. GRPO regime and (b)
adds the SDPO head. This refines the existing `ComposerReplicationTrainer`; the
SDPO method is lifted almost verbatim from `composer_trainer.py:_compute_sdpo_loss`
(it is already correct), and the Dr. GRPO config is pinned via `GRPOConfig`.
### 4.1 The loss-override surface (version-robust)
The repo already overrides `_compute_loss(self, model, inputs)` — the internal
per-step loss hook TRL's `GRPOTrainer` exposes, and what this subclass keeps
using. Recent TRL wraps that in the HF `Trainer.compute_loss(self, model,
inputs, return_outputs=False, num_items_in_batch=None)`. To be robust to either
surface, override **`_compute_loss`** (present across the versions this repo
targets) and additionally provide a thin `compute_loss` shim that delegates, so
the subclass works whether TRL calls the internal or the public method:
```python
def compute_loss(self, model, inputs, return_outputs=False, num_items_in_batch=None):
loss = self._compute_loss(model, inputs) # our composed loss
return (loss, None) if return_outputs else loss
```
If a future TRL drops `_compute_loss`, move the channel-1 call to
`super().compute_loss(model, inputs, return_outputs=True,
num_items_in_batch=num_items_in_batch)[0]` inside `_compute_loss` — the SDPO
add-on is unaffected.
### 4.2 The sketch (~70 LoC)
```python
# composer_replication/trainer/composer_grpo_trainer.py
from __future__ import annotations
from typing import Any
import logging, torch
try:
from trl import GRPOTrainer, GRPOConfig # noqa: F401
_TRL = True
except ImportError: # doc/test import without TRL
GRPOTrainer = object; _TRL = False
from composer_replication.opsd import generalized_jsd_loss
logger = logging.getLogger(__name__)
def make_dr_grpo_config(**overrides: Any) -> "GRPOConfig":
"""Dr. GRPO regime (research/10 §2): no std-norm, no length-standardization,
k1 KL, single-epoch, Adam. We pin what GRPOConfig exposes and assert the
rest. TRL flag names drift across versions, so set defensively + log."""
cfg_kwargs = dict(
num_iterations=1, # single-epoch: a prompt is never re-trained
scale_rewards=False, # << NO std-dev advantage normalization (Dr. GRPO)
loss_type="dr_grpo", # TRL's Dr. GRPO loss_type: drops length-standardization;
# if absent in your TRL, fall back to "grpo" and
# override the reduction (see assert below).
optim="adamw_torch", # Adam(W); Composer 2 uses Adam for RL
beta=0.0, # GRPO KL-to-ref coeff; set >0 to enable the k1 term
)
cfg_kwargs.update(overrides)
return GRPOConfig(**cfg_kwargs)
class ComposerGRPOTrainer(GRPOTrainer): # type: ignore[misc,valid-type]
"""Dr. GRPO + SDPO (on-policy KL at error turns). SDPO is an ADDITIVE head;
it never touches advantages or the GRPO-KL-to-ref term."""
def __init__(self, *args: Any, beta_sdpo: float = 0.0, sdpo_jsd_beta: float = 0.5,
sdpo_temperature: float = 1.0, sdpo_token_clip: float | None = 0.05,
sdpo_warmup_steps: int = 0, beta_sdpo_max: float | None = None,
**kwargs: Any):
if not _TRL:
raise ImportError("ComposerGRPOTrainer requires TRL: pip install -e .[train]")
super().__init__(*args, **kwargs)
self.beta_sdpo = beta_sdpo
self.beta_sdpo_max = beta_sdpo_max if beta_sdpo_max is not None else beta_sdpo
self.sdpo_warmup_steps = sdpo_warmup_steps
self.sdpo_jsd_beta = sdpo_jsd_beta
self.sdpo_temperature = sdpo_temperature
self.sdpo_token_clip = sdpo_token_clip
# Dr. GRPO sanity pins (loud, not silent): if the TRL version ignored a
# flag, surface it rather than train vanilla GRPO by accident.
if getattr(self.args, "scale_rewards", True):
logger.warning("Dr. GRPO requires scale_rewards=False (no std-norm); "
"GRPOConfig.scale_rewards=%s — advantages may be std-normalized.",
getattr(self.args, "scale_rewards", None))
def _beta_sdpo_now(self) -> float:
"""Linear warmup so SDPO doesn't swamp the early policy gradient (§5)."""
step = getattr(getattr(self, "state", None), "global_step", 0) or 0
if self.sdpo_warmup_steps <= 0:
return self.beta_sdpo
frac = min(1.0, step / float(self.sdpo_warmup_steps))
return self.beta_sdpo + frac * (self.beta_sdpo_max - self.beta_sdpo)
def _compute_loss(self, model, inputs):
drgrpo = super()._compute_loss(model, inputs) # channel 1 (Dr. GRPO, k1 KL)
sdpo = self._compute_sdpo_loss(model, inputs) # channel 2 (additive)
beta = self._beta_sdpo_now()
total = drgrpo + beta * sdpo
if self.state.global_step % getattr(self.args, "logging_steps", 50) == 0:
self.log({"loss/grpo": float(drgrpo.detach()),
"loss/sdpo_jsd": float(sdpo.detach()),
"loss/beta_sdpo": beta, "loss/total": float(total.detach())})
return total
def _compute_sdpo_loss(self, model, inputs):
if (self._beta_sdpo_now() == 0.0
or "ctx_teacher_input_ids" not in inputs
or inputs["ctx_teacher_input_ids"].numel() == 0):
return torch.zeros((), device=next(model.parameters()).device, requires_grad=True)
student = model(input_ids=inputs["input_ids"]).logits # grad
with torch.no_grad():
teacher = model(input_ids=inputs["ctx_teacher_input_ids"]).logits # stop-grad
if student.shape != teacher.shape: # collator alignment guard
logger.warning("SDPO shape mismatch student=%s teacher=%s; skipping step.",
student.shape, teacher.shape)
return torch.zeros((), device=student.device, requires_grad=True)
return generalized_jsd_loss(student_logits=student, teacher_logits=teacher,
labels=inputs.get("sdpo_loss_mask"),
beta=self.sdpo_jsd_beta, temperature=self.sdpo_temperature,
token_clip=self.sdpo_token_clip, reduction="batchmean")
def compute_loss(self, model, inputs, return_outputs=False, num_items_in_batch=None):
loss = self._compute_loss(model, inputs)
return (loss, None) if return_outputs else loss
```
### 4.3 How error-turn batches reach the trainer
**Reuse `ComposerDataCollator` verbatim** — it already emits
`ctx_teacher_input_ids` + `sdpo_loss_mask` and (critically) the
**equal-length student** via `_build_aligned_student_for_sdpo` (the placeholder
trick that keeps `student_logits.shape == teacher_logits.shape` so the JSD gate
passes; the Gemini-W19 alias bug is already handled there). Wiring:
```python
gen = default_layered(judge_client=small_model).as_collator_hook() # research/07 §6
collator = ComposerDataCollator(tokenizer=tok,
config=CollatorConfig(hint_generator=gen, enable_sdpo=True,
enable_replay_dpo=False))
trainer = ComposerGRPOTrainer(model=model, args=make_dr_grpo_config(...),
train_dataset=ds, data_collator=collator,
beta_sdpo=0.1, sdpo_warmup_steps=50, sdpo_token_clip=0.05,
reward_funcs=[my_rlvr_reward])
```
> **GRPO-rollout vs collator note.** TRL's `GRPOTrainer` generates rollouts
> internally and forms its own `inputs` (prompt + completions + advantages). For
> SDPO the error sites come from the *rollout trajectory itself* (tool errors in
> the just-generated completions), so the SDPO tensors must be built **from the
> live rollout**, not from a static dataset. Two equivalent integration modes:
> (1) **post-rollout hook** — override `_generate_and_score_completions` (or the
> rollout collation step) to run the structural `tool_error` detector +
> `HintGenerator` + `ComposerDataCollator._build_sdpo_fields` on the generated
> completions and stash `ctx_teacher_input_ids`/`sdpo_loss_mask` into `inputs`;
> (2) **offline-trace mode** (what the smoke uses) — feed pre-ingested
> error-bearing traces through the collator as the dataset, exercising the exact
> loss path on CPU. Mode (2) is the test; mode (1) is production. The
> `_compute_sdpo_loss` body is identical for both — it only reads the two SDPO
> keys.
---
## 5. Weighting, scheduling, and guardrails
So SDPO informs without swamping the policy gradient:
1. **Scale.** Start `beta_sdpo = 0.1` (the library default `alpha_sdpo`), not the
`1.0` the smoke uses (the smoke over-weights deliberately to *prove the path
fires*). The Dr. GRPO PG loss is a `sum()` over response tokens; SDPO is a
`batchmean` JSD over error-turn tokens — different magnitudes. **Normalize
first:** log `loss/grpo` and `loss/sdpo_jsd` separately for the first ~50
steps and pick `beta_sdpo` so `beta_sdpo·sdpo_jsd ≈ 0.1–0.3 × |grpo|` at
steady state. Do **not** assume `0.1` is calibrated across reductions.
2. **Warmup.** Linear `beta_sdpo` warmup over `sdpo_warmup_steps` (50–200) via
`_beta_sdpo_now()`. Early in training the policy is far from any sensible
distribution; a strong distillation pull then fights exploration. Let Dr. GRPO
establish a reward signal, then ramp SDPO in.
3. **Per-token JSD clip = 0.05** (`sdpo_token_clip`, the OPSD `--jsd_token_clip`
default, `research/07` §1.1/§7). Prevents a few high-divergence **stylistic**
tokens at the error turn from dominating the distillation gradient — exactly
what it exists for.
4. **Mask discipline.** SDPO supervises **only** `sdpo_loss_mask` tokens
(post-hint recovery). If the mask is all-ignore (empty-recovery error site,
~67% of real Claude traces under `strip_thinking`), the collator already drops
the row (`data_collator.py` L308) — the channel silently no-ops rather than
emitting a degenerate ~ln(2) signal.
**KL-explosion / teacher-student-drift guardrails:**
- **SDPO drift is bounded-by-construction.** Teacher = same weights + hint,
stop-grad. A *wrong* hint produces a noisier target at one masked turn, not a
corrupted reward (`research/07` §1.3). There is no replay buffer and no
separate teacher to drift apart — single-epoch keeps teacher and student on the
same weights.
- **Watch `loss/sdpo_jsd` for collapse-to-zero or blow-up.** A *good* hint should
*raise* divergence at the hinted turn (it shifts mass toward the fix); a
persistently ~0 JSD means the hint isn't moving the teacher (prune that hint
source, `research/07` §7 item 7). A diverging JSD means the clip is too loose or
`beta_sdpo` too high — cap `beta_sdpo` and/or lower `token_clip`.
- **Guard the GRPO k1 KL independently.** Dr. GRPO's own `beta` (KL-to-ref) is
the explosion guard for the *policy*; keep it at its tuned value. SDPO's `beta_sdpo`
must not be conflated with it (§3). If total loss NaNs, bisect by zeroing
`beta_sdpo` — if it persists, the bug is in channel 1, not SDPO.
- **Shape-gate is a hard stop, logged.** If collator alignment regresses,
`_compute_sdpo_loss` skips the step with a warning rather than training on
aliased pad tokens (the silent-degenerate failure mode).
---
## 6. CPU-testable vs GPU-only, and the smoke plan
### What is CPU-testable
- **The whole SDPO loss path** — student forward + hint-conditioned teacher
forward + masked JSD + `.backward()` + `Adam.step()` — on **Qwen2.5-0.5B** with
1–2 error-bearing rollouts. This is *exactly* what
`examples/sdpo_real_trace_train_smoke/run.py` already proves for the free
`compose_loss` composer; the new test wraps it in the Dr. GRPO step.
- **The additive composition** `total = drgrpo + beta_sdpo·sdpo` and the warmup
schedule (assert `beta_sdpo` ramps, assert `loss/sdpo_jsd>0` on ≥1 step, assert
a watched param moves).
- **Dr. GRPO config pins** — assert `scale_rewards=False`, `num_iterations=1`,
k1-KL path selected (unit-level, no GPU).
### What is GPU-only
- **Real TRL `GRPOTrainer` rollout generation** (vLLM/transformers generation at
batch size + group size K) — too slow on CPU for a live step; this is the
production "mode (1)" path in §4.3.
- **Async weight sync / off-policy control**, MoE router replay, multi-region
infra (`research/10` §4) — all out of scope for the SDPO channel test.
- **Convergence / quality** (does SDPO actually improve error-recovery) — needs a
real RL run.
### Minimal smoke plan (`examples/sdpo_drgrpo_step_smoke/run.py`)
Analogous to the existing SDPO smoke; gates:
1. Build a Dr. GRPO minibatch from 1–2 ingested **error-bearing** Qwen traces via
`ComposerDataCollator` (reuse `_discover_error_sessions` + the layered
`HintGenerator`); assert `sdpo_loss_mask` has ≥1 in-loss position.
2. Construct a **synthetic Dr. GRPO channel-1 loss** standing in for
`super()._compute_loss` (advantages = `R - group_mean`, **no /std**; k1 KL
`−log r`; `sum()` reduction; no length-standardization) so the test runs
**without** spinning up TRL's full rollout machinery on CPU — mirrors how the
existing smoke uses LM-CE as the GRPO stub. Optionally also run a real
`GRPOTrainer._compute_loss` path under a `@pytest.mark.gpu` guard.
3. `total = drgrpo_stub + beta_sdpo · _compute_sdpo_loss(...)`; `.backward()`;
`Adam.step()`.
4. **Gates (exit 0 = PASS):** (a) all losses finite across steps; (b)
`loss/sdpo_jsd > 0` on ≥1 step (SDPO fired — shape-gate passed, hint
contributed real signal); (c) a watched parameter moved; (d) `beta_sdpo`
warmup increases monotonically; (e) zeroing `beta_sdpo` reproduces the pure
Dr. GRPO stub loss bit-for-bit (proves SDPO is purely additive). Exit 2 = SKIP
(no error-bearing sessions / no chat-template model), matching the existing
smoke's contract.
This is ~$0, CPU, single-process, and closes the one unproven edge: **a live
Dr. GRPO update step with the SDPO channel on**, end-to-end on a real HF model.
---
## 7. Citations
- **In-repo (authoritative substrate):** `composer_replication/loss.py`
(`compose_loss` 3-channel composer + `generalized_jsd_loss` call);
`recipes/prime_rl/composer_loss.py` (PRIME-RL adapter; SDPO `NotImplementedError`
at L257-268; parity-verified channel 1); `recipes/prime_rl/prime_rl_recipe.md`
(LossInputs shape, log-probs-not-logits limitation);
`trainer/composer_trainer.py` (`ComposerReplicationTrainer._compute_loss` and
`_compute_sdpo_loss` — the existing, correct SDPO head);
`trainer/data_collator.py` (`ctx_teacher_input_ids` + `sdpo_loss_mask` +
`_build_aligned_student_for_sdpo` equal-length alignment; hint-AND-recovery
gate L308); `examples/sdpo_real_trace_train_smoke/run.py` (the proven CPU
forward+backward+step harness this design's smoke extends).
- **`research/10-composer2-techreport-mining.md`** — the Dr. GRPO target:
length-standardization removed, no std-dev advantage normalization, **k1**
(`−log r`) KL not k3, Adam, single-epoch (a prompt never trained twice).
arXiv:2603.24477 §4.1.
- **`research/07-sdpo-hint-generator.md`**`HintGenerator` Protocol + layered
composite, error-site detection alignment with ingestion `tool_error`, OPSD
`--jsd_token_clip` stabilizer, the "wrong hint is bounded-bad" property.
- **SDPO** — arXiv:2601.20802 (on-policy self-distillation; teacher = same model
conditioned on feedback, student stop-grad-free / teacher stop-grad, per-token
KL on the student trajectory). **OPSD** — arXiv:2601.18734 (privileged-info
teacher, generalized-JSD, token clip).
- **TRL**`huggingface/trl@main` `trl/trainer/grpo_trainer.py`
(`GRPOTrainer` loss-override surface; confirmed via
`mcp_exa_get_code_context_exa`). The `_compute_loss(self, model, inputs)`
internal hook is what this repo already overrides; the public
`compute_loss(self, model, inputs, return_outputs=False,
num_items_in_batch=None)` HF-Trainer wrapper is shimmed in §4.1 for
version-robustness. (A confirmatory DeepWiki lookup timed out; the §4.1 guard
is written to work under either surface.)
```