Skip to content
on-policy-self-distillation

claude-opus-5

on-policy-self-distillation — graded by the task's verifier against a measured baseline.

backend
modal
variant
claude claude-opus-5
metric
AIME24 avg@12 accuracy
actions
142
duration
8h 21m
recorded
2026-08-02

run idopsd-aime24-4gpus__r6P3Lfd

Everything claude-opus-5 did on this task, in order.

download JSON ↗
claude-codeclaude-opus-5135 turns20 narration142 action142 observation115 reasoning1 final
#126
narration

Removing the one untested code path (the curation filter) so the shipped diff contains only what I validated:

toolEdit
args
{
  "replace_all": false,
  "file_path": "/workspace/repo/opsd_train.py",
  "old_string": "    # ---------------------------------------------------------------------\n    # Target-aligned curation of the (fixed) training pool.\n    # Within a 100-step budget the student only ever sees ~3.2k of the 29.4k\n    # problems, so *which* problems supply the per-token teacher signal matters.\n    # We keep competition-style problems with a single integer final answer (the pool\n    # also contains multiple-choice letters and symbolic expressions, which the\n    # benchmark never asks for), optionally restricted by reference-derivation length.\n    # ---------------------------------------------------------------------\n    if os.environ.get(\"OPSD_CURATE\", \"0\") == \"1\":\n        import re as _re\n\n        _min_ref_tokens = int(os.environ.get(\"OPSD_CURATE_MIN_TOKENS\", \"0\"))\n\n        def _keep(example):\n            answer = str(example.get(\"Answer\", \"\")).strip()\n            if not _re.fullmatch(r\"\\d{1,4}\", answer):\n                return False\n            try:\n                ref_tokens = int(example.get(\"generated_token_count\", 0))\n            except (TypeError, ValueError):\n                return False\n            return ref_tokens >= _min_ref_tokens\n\n        n_before = len(train_dataset)\n        train_dataset = train_dataset.filter(_keep)\n        print(\n            f\"[curation] integer-answer + reference-CoT>={_min_ref_tokens} tokens: \"\n            f\"{n_before} -> {len(train_dataset)} examples\"\n        )\n\n    trainer = OPSDTrainer(",
  "new_string": "    trainer = OPSDTrainer("
}
Edit result
The file /workspace/repo/opsd_train.py has been updated successfully. (file state is current in your context — no need to Read it back)

[metadata] {"filePath": "/workspace/repo/opsd_train.py", "oldString": "    # ---------------------------------------------------------------------\n    # Target-aligned curation of the (fixed) training pool.\n    # Within a 100-step budget the student only ever sees ~3.2k of the 29.4k\n    # problems, so *which* problems supply the per-token teacher signal matters.\n    # We keep competition-style problems with a single integer final answer (the pool\n    # also contains multiple-choice letters and symbolic expressions, which the\n    # benchmark never asks for), optionally restricted by reference-derivation length.\n    # ---------------------------------------------------------------------\n    if os.environ.get(\"OPSD_CURATE\", \"0\") == \"1\":\n        import re as _re\n\n        _min_ref_tokens = int(os.environ.get(\"OPSD_CURATE_MIN_TOKENS\", \"0\"))\n\n        def _keep(example):\n            answer = str(example.get(\"Answer\", \"\")).strip()\n            if not _re.fullmatch(r\"\\d{1,4}\", answer):\n                return False\n            try:\n                ref_tokens = int(example.get(\"generated_token_count\", 0))\n            except (TypeError, ValueError):\n                return False\n            return ref_tokens >= _min_ref_tokens\n\n        n_before = len(train_dataset)\n        train_dataset = train_dataset.filter(_keep)\n        print(\n            f\"[curation] integer-answer + reference-CoT>={_min_ref_tokens} tokens: \"\n            f\"{n_before} -> {len(train_dataset)} examples\"\n        )\n\n    trainer = OPSDTrainer(", "newString": "    trainer = OPSDTrainer(", "originalFile": null, "structuredPatch": [{"oldStart": 266, "oldLines": 36, "newStart": 266, "newLines": 6, "lines": ["     dataset = load_dataset(\"siyanzhao/Openthoughts_math_30k_opsd\")", "     train_dataset = dataset[\"train\"]", " ", "-    # ---------------------------------------------------------------------", "-    # Target-aligned curation of the (fixed) training pool.", "-    # Within a 100-step budget the student only ever sees ~3.2k of the 29.4k", "-    # problems, so *which* problems supply the per-token teacher signal matters.", "-    # We keep competition-style problems with a single integer final answer (the pool", "-    # also contains multiple-choice letters and symbolic expressions, which the", "-    # benchmark never asks for), optionally restricted by reference-derivation length.", "-    # ---------------------------------------------------------------------", "-    if os.environ.get(\"OPSD_CURATE\", \"0\") == \"1\":", "-        import re as _re", "-", "-        _min_ref_tokens = int(os.environ.get(\"OPSD_CURATE_MIN_TOKENS\", \"0\"))", "-", "-        def _keep(example):", "-            answer = str(example.get(\"Answer\", \"\")).strip()", "-            if not _re.fullmatch(r\"\\d{1,4}\", answer):", "-                return False", "-            try:", "-                ref_tokens = int(example.get(\"generated_token_count\", 0))", "-            except (TypeError, ValueError):", "-                return False", "-            return ref_tokens >= _min_ref_tokens", "-", "-        n_before = len(train_dataset)", "-        train_dataset = train_dataset.filter(_keep)", "-        print(", "-            f\"[curation] integer-answer + reference-CoT>={_min_ref_tokens} tokens: \"", "-            f\"{n_before} -> {len(train_dataset)} examples\"", "-        )", "-", "     trainer = OPSDTrainer(", "         model=model_args.model_name_or_path,", "         args=training_args,"]}], "userModified": false, "replaceAll": false}
#127
reasoning (redacted)

encrypted chain-of-thought — recorded, not exposed

toolBashVerify syntax after cleanup
args
{
  "command": "git diff --stat; python -c \"import ast;ast.parse(open('/workspace/repo/opsd_train.py').read());ast.parse(open('/workspace/repo/data_collator.py').read());ast.parse(open('/workspace/repo/opsd_trainer.py').read());print('syntax OK')\"; tail -1 /workspace/runs/tr6.evallog | tr '\\r' '\\n' | tail -1 | cut -c1-45",
  "description": "Verify syntax after cleanup"
}
Bash result
README.md        |   4 +-
 data_collator.py |  26 ++++++-
 opsd_trainer.py  | 206 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 3 files changed, 230 insertions(+), 6 deletions(-)
syntax OK
Loading safetensors checkpoint shards:   0% C

[stdout]
 README.md        |   4 +-
 data_collator.py |  26 ++++++-
 opsd_trainer.py  | 206 ++++++++++++++++++++++++++++++++++++++++++++++++++++++-
 3 files changed, 230 insertions(+), 6 deletions(-)
syntax OK
Loading safetensors checkpoint shards:   0% C
[metadata] {"noOutputExpected": false}
#128
reasoning (redacted)

encrypted chain-of-thought — recorded, not exposed

toolBashVerify Answer column plumbing
args
{
  "command": "git diff opsd_trainer.py | grep -A6 \"required_columns\"",
  "description": "Verify Answer column plumbing"
}
Bash result
required_columns = [
             "problem",
             "solution",
+            "Answer",
         ]
         if self._signature_columns is None:
             self._signature_columns = required_columns
@@ -389,6 +400,8 @@ class OPSDTrainer(SFTTrainer):
         logits_are_probs=False,
         top_k=None,
         token_clip=None,
+        token_trust_region=False,
+        neg_gain=1.0,

[stdout]
         required_columns = [
             "problem",
             "solution",
+            "Answer",
         ]
         if self._signature_columns is None:
             self._signature_columns = required_columns
@@ -389,6 +400,8 @@ class OPSDTrainer(SFTTrainer):
         logits_are_probs=False,
         top_k=None,
         token_clip=None,
+        token_trust_region=False,
+        neg_gain=1.0,
[metadata] {"noOutputExpected": false}
#129
reasoning (redacted)

encrypted chain-of-thought — recorded, not exposed

toolBashPoll tr6 eval
args
{
  "command": "sleep 115; sleep 115; sleep 115; tail -1 /workspace/runs/tr6.evallog | tr '\\r' '\\n' | tail -1 | cut -c1-45; cat /workspace/.timer/remaining_secs",
  "description": "Poll tr6 eval",
  "timeout": 370000
}
Bash result
Processed prompts:  12%|█▎        | 12/96
5624

[stdout]
Processed prompts:  12%|█▎        | 12/96
5624
[metadata] {"noOutputExpected": false}
#130
reasoning (redacted)

encrypted chain-of-thought — recorded, not exposed

toolBashPoll tr6 eval
args
{
  "command": "sleep 115; sleep 115; sleep 115; sleep 115; grep FINAL /workspace/runs/tr6.evallog || tail -1 /workspace/runs/tr6.evallog | tr '\\r' '\\n' | tail -1 | cut -c1-40",
  "description": "Poll tr6 eval",
  "timeout": 480000
}
Bash result
Processed prompts:  14%|█▍        |

[stdout]
Processed prompts:  14%|█▍        |
[metadata] {"noOutputExpected": false}
#131
reasoning (redacted)

encrypted chain-of-thought — recorded, not exposed

toolBashPoll tr6 eval
args
{
  "command": "sleep 115; sleep 115; sleep 115; sleep 115; grep FINAL /workspace/runs/tr6.evallog || tail -1 /workspace/runs/tr6.evallog | tr '\\r' '\\n' | tail -1 | cut -c1-40; cat /workspace/.timer/remaining_secs",
  "description": "Poll tr6 eval",
  "timeout": 480000
}
Bash result
Processed prompts:  75%|█████�
4692

[stdout]
Processed prompts:  75%|█████�
4692
[metadata] {"noOutputExpected": false}
#132
reasoning (redacted)

encrypted chain-of-thought — recorded, not exposed

toolBashWait for tr6 result
args
{
  "command": "while [ ! -f /tmp/tr6.done ]; do sleep 20; done; grep FINAL /workspace/runs/tr6.evallog",
  "description": "Wait for tr6 result",
  "timeout": 600000
}
Bash result
FINAL avg@12 accuracy: 56.67% (204/360 solutions correct on 30 problems)

[stdout]
FINAL avg@12 accuracy: 56.67% (204/360 solutions correct on 30 problems)
[metadata] {"noOutputExpected": false}
#133
narration

tr6 → 56.67. Three independent runs of the shipped config, all above the anchor.

toolBashAggregate final results
args
{
  "command": "python - <<'EOF'\nimport json, statistics as s\nruns={'tr4':None,'tr5':None,'tr6':None}\nfor k in runs:\n    d=json.load(open(f'/workspace/eval_results/{k}.json'))\n    runs[k]=(d['average_at_n_pct'],d['pass_at_n_pct'],d['format_rate_pct'])\nv=[runs[k][0] for k in ['tr4','tr5','tr6']]\nprint('privileged-answer runs:',v)\nprint('mean %.2f  sd %.2f  sem %.2f  vs anchor 54.22 -> %+.2f'%(s.mean(v),s.stdev(v),s.stdev(v)/3**.5,s.mean(v)-54.22))\nprint('pass@12:',[runs[k][1] for k in runs],' format:',[runs[k][2] for k in runs])\nEOF\ncat /workspace/submission/recipe.env; cat /workspace/.timer/remaining_secs",
  "description": "Aggregate final results"
}
Bash result
privileged-answer runs: [55.55555555555556, 59.166666666666664, 56.666666666666664]
mean 57.13  sd 1.85  sem 1.07  vs anchor 54.22 -> +2.91
pass@12: [76.66666666666667, 76.66666666666667, 76.66666666666667]  format: [99.16666666666667, 99.72222222222223, 99.72222222222223]
# OPSD recipe. Only the LR schedule is pinned; everything else is the paper's setting.
# Measured: constant (train_capped.sh's default) scores 50.56 vs 54.22 for linear decay,
# so the schedule must be pinned explicitly.
lr_scheduler_type=linear
4511

[stdout]
privileged-answer runs: [55.55555555555556, 59.166666666666664, 56.666666666666664]
mean 57.13  sd 1.85  sem 1.07  vs anchor 54.22 -> +2.91
pass@12: [76.66666666666667, 76.66666666666667, 76.66666666666667]  format: [99.16666666666667, 99.72222222222223, 99.72222222222223]
# OPSD recipe. Only the LR schedule is pinned; everything else is the paper's setting.
# Measured: constant (train_capped.sh's default) scores 50.56 vs 54.22 for linear decay,
# so the schedule must be pinned explicitly.
lr_scheduler_type=linear
4511
[metadata] {"noOutputExpected": false}
#134
reasoning (redacted)

encrypted chain-of-thought — recorded, not exposed

toolEdit
args
{
  "replace_all": false,
  "file_path": "/workspace/submission/METHOD.md",
  "old_string": "| paper recipe (dev rerun / 5-run anchor) | 54.44 / **54.22** | 76.67 | 96.94 |\n| **+ verified privileged answer (this method)** | **55.56 / 59.17 → mean 57.37** | 76.67 / 76.67 | 99.17 / 99.72 |\n\npass@12 is unchanged — the method does not expand the set of solvable problems; it makes\nthe model *reliably* land the answers it can already reach, which is what avg@12 measures.",
  "new_string": "| paper recipe (dev rerun / 5-run anchor) | 54.44 / **54.22** | 76.67 | 96.94 |\n| **+ verified privileged answer (this method)**, 3 runs | 55.56, 59.17, 56.67 → **mean 57.13** (sd 1.85, sem 1.07) | 76.67, 76.67, 76.67 | 99.17, 99.72, 99.72 |\n\n+2.91 over the anchor, ~2.7 sem, with all three runs above both the anchor and the dev\nrerun of the paper's recipe.\n\npass@12 is *identical* in every run (76.67, same as the paper's recipe) — the method does\nnot expand the set of solvable problems; it makes the model reliably land the answers it\ncan already reach, which is exactly what avg@12 measures. Format rate rises 96.94 → ~99.7."
}
Edit result
The file /workspace/submission/METHOD.md has been updated successfully. (file state is current in your context — no need to Read it back)

[metadata] {"filePath": "/workspace/submission/METHOD.md", "oldString": "| paper recipe (dev rerun / 5-run anchor) | 54.44 / **54.22** | 76.67 | 96.94 |\n| **+ verified privileged answer (this method)** | **55.56 / 59.17 → mean 57.37** | 76.67 / 76.67 | 99.17 / 99.72 |\n\npass@12 is unchanged — the method does not expand the set of solvable problems; it makes\nthe model *reliably* land the answers it can already reach, which is what avg@12 measures.", "newString": "| paper recipe (dev rerun / 5-run anchor) | 54.44 / **54.22** | 76.67 | 96.94 |\n| **+ verified privileged answer (this method)**, 3 runs | 55.56, 59.17, 56.67 → **mean 57.13** (sd 1.85, sem 1.07) | 76.67, 76.67, 76.67 | 99.17, 99.72, 99.72 |\n\n+2.91 over the anchor, ~2.7 sem, with all three runs above both the anchor and the dev\nrerun of the paper's recipe.\n\npass@12 is *identical* in every run (76.67, same as the paper's recipe) — the method does\nnot expand the set of solvable problems; it makes the model reliably land the answers it\ncan already reach, which is exactly what avg@12 measures. Format rate rises 96.94 → ~99.7.", "originalFile": "# OPSD + Verified Privileged Answer\n\n## Method\n\nOPSD's teacher is the *same frozen initial policy* as the student; its only edge is the\n**privileged information** in its context (the reference solution). The per-token forward\nKL therefore only teaches the student whatever that privileged context actually buys the\nteacher at each position.\n\nIn the released recipe the ground-truth final answer is only *implicit*: it sits somewhere\ninside a long reference write-up, and the transition prompt explicitly tells the teacher\nnot to copy or paraphrase that write-up. So the teacher's advantage is spread over style\nand derivation-shape tokens, and is weakest where it matters most for a graded benchmark:\nthe decisive numeric commitments.\n\nThis method states the verified final answer explicitly in the teacher's context, right\nafter the reference solution and again as the last thing before generation (`data_collator.py`).\nThe teacher still reasons from scratch over the student's own on-policy trajectory; it is\nsimply better calibrated on the tokens that decide the sample's correctness — intermediate\nquantities consistent with the true answer, and the final `\\boxed{}` commitment.\n\nNothing else changes: the loss, the clip, the fixed initial-policy teacher, the sampler,\nthe dataset, the step budget and the batch size are all the paper's.\n\n## Measured on AIME24 avg@12 (dev, seed 42, 100 steps @ global batch 32)\n\n| config | avg@12 | pass@12 | format |\n|---|---|---|---|\n| paper recipe (dev rerun / 5-run anchor) | 54.44 / **54.22** | 76.67 | 96.94 |\n| **+ verified privileged answer (this method)** | **55.56 / 59.17 → mean 57.37** | 76.67 / 76.67 | 99.17 / 99.72 |\n\npass@12 is unchanged — the method does not expand the set of solvable problems; it makes\nthe model *reliably* land the answers it can already reach, which is what avg@12 measures.\n\n## Rejected alternatives (each cost a full train+eval cycle)\n\n| probe | rationale | avg@12 |\n|---|---|---|\n| per-token trust region instead of the per-vocab clamp | restore the mode-covering half of the KL that the clamp discards (~75 % of positive mass) | 48.06 |\n| `jsd_token_clip` 0.05 → 0.01 | push harder on the clamp's mass-concentrating asymmetry | 46.11 |\n| constant LR instead of linear decay (2× effective distance) | the paper's own AIME24 curve is still rising at step 100 | 50.56 |\n\nThe objective and the optimizer schedule are at a local optimum: perturbing either\ndirection of the clip, or travelling further at fixed steps, loses 4–8 points. The\nteacher's privileged context was the axis the authors left unexplored.\n\n## Note on `recipe.env`\n\n`lr_scheduler_type=linear` must be pinned: `train_capped.sh`'s own default is `constant`,\nwhich measures 50.56 here versus 54.22 for the linear decay the paper's run used.\n", "structuredPatch": [{"oldStart": 27, "oldLines": 11, "newStart": 27, "newLines": 15, "lines": [" | config | avg@12 | pass@12 | format |", " |---|---|---|---|", " | paper recipe (dev rerun / 5-run anchor) | 54.44 / **54.22** | 76.67 | 96.94 |", "-| **+ verified privileged answer (this method)** | **55.56 / 59.17 → mean 57.37** | 76.67 / 76.67 | 99.17 / 99.72 |", "+| **+ verified privileged answer (this method)**, 3 runs | 55.56, 59.17, 56.67 → **mean 57.13** (sd 1.85, sem 1.07) | 76.67, 76.67, 76.67 | 99.17, 99.72, 99.72 |", " ", "-pass@12 is unchanged — the method does not expand the set of solvable problems; it makes", "-the model *reliably* land the answers it can already reach, which is what avg@12 measures.", "++2.91 over the anchor, ~2.7 sem, with all three runs above both the anchor and the dev", "+rerun of the paper's recipe.", " ", "+pass@12 is *identical* in every run (76.67, same as the paper's recipe) — the method does", "+not expand the set of solvable problems; it makes the model reliably land the answers it", "+can already reach, which is exactly what avg@12 measures. Format rate rises 96.94 → ~99.7.", "+", " ## Rejected alternatives (each cost a full train+eval cycle)", " ", " | probe | rationale | avg@12 |"]}], "userModified": false, "replaceAll": false}
#135
reasoning (redacted)

encrypted chain-of-thought — recorded, not exposed

toolBashFinal submission verification
args
{
  "command": "grep -n \"teacher_answer_hint\" data_collator.py | head; echo \"=== recipe:\"; cat /workspace/submission/recipe.env; echo \"=== files:\"; ls /workspace/submission/; echo \"=== defaults check:\"; python -c \"\nimport os\nprint('TEACHER_ANSWER default ON:', os.environ.get('OPSD_TEACHER_ANSWER','1')=='1')\nprint('LOSS_FORM default:', os.environ.get('OPSD_LOSS_FORM','pointwise'))\nprint('NEG_GAIN default:', os.environ.get('OPSD_NEG_GAIN','1.0'))\nprint('DIAG default:', os.environ.get('OPSD_DIAG','0'))\"",
  "description": "Final submission verification"
}
Bash result
37:        self.teacher_answer_hint = os.environ.get("OPSD_TEACHER_ANSWER", "1") == "1"
108:                if self.teacher_answer_hint and answer:
=== recipe:
# OPSD recipe. Only the LR schedule is pinned; everything else is the paper's setting.
# Measured: constant (train_capped.sh's default) scores 50.56 vs 54.22 for linear decay,
# so the schedule must be pinned explicitly.
lr_scheduler_type=linear
=== files:
METHOD.md
recipe.env
=== defaults check:
TEACHER_ANSWER default ON: True
LOSS_FORM default: pointwise
NEG_GAIN default: 1.0
DIAG default: 0

[stdout]
37:        self.teacher_answer_hint = os.environ.get("OPSD_TEACHER_ANSWER", "1") == "1"
108:                if self.teacher_answer_hint and answer:
=== recipe:
# OPSD recipe. Only the LR schedule is pinned; everything else is the paper's setting.
# Measured: constant (train_capped.sh's default) scores 50.56 vs 54.22 for linear decay,
# so the schedule must be pinned explicitly.
lr_scheduler_type=linear
=== files:
METHOD.md
recipe.env
=== defaults check:
TEACHER_ANSWER default ON: True
LOSS_FORM default: pointwise
NEG_GAIN default: 1.0
DIAG default: 0
[metadata] {"noOutputExpected": false}
final summary

Done. Final submission:

Method: OPSD + verified privileged answer

OPSD's teacher is the student (same frozen initial policy) — its only edge is the privileged information in its context. In the released recipe the ground-truth answer is only implicit, buried in a long reference write-up that the transition prompt explicitly tells the teacher not to copy. So the teacher's advantage is spread over style and derivation-shape, and is weakest exactly where the benchmark is decided.

The change (data_collator.py): state the verified final answer explicitly in the teacher's context — once after the reference solution, once as the last thing before generation. The teacher still re-derives from scratch over the student's own on-policy rollout; it is just correctly calibrated on the decisive tokens (intermediate quantities consistent with the true answer, and the final \boxed{} commitment). opsd_trainer.py plumbs the Answer column through the signature-column filter. Loss, clip, fixed teacher, sampler, dataset, step budget and batch size are all untouched.

Results (AIME24 avg@12, 100 steps @ global batch 32, seed 42)

configavg@12pass@12format
paper recipe (dev rerun / 5-run anchor)54.44 / 54.2276.6796.94
this method, 3 independent runs55.56, 59.17, 56.67 → mean 57.13 (sd 1.85, sem 1.07)76.67 ×3~99.7

+2.91 over the anchor (~2.7 sem), all three runs above it. pass@12 is bit-identical across every run and equal to the paper's recipe — the method doesn't enlarge the solvable set, it makes the model reliably land answers it could already reach, which is the channel avg@12 actually measures.

Three probes were rejected first, each costing a full train+eval cycle: a gradient-preserving per-token trust region replacing the per-vocab clamp (48.06), jsd_token_clip 0.05→0.01 (46.11), and constant LR instead of linear decay, i.e. 2× effective distance at fixed steps (50.56). The objective and schedule sit at a local optimum — perturbing the clip in either direction, or travelling further, loses 4–8 points. That's what pointed at the teacher's context as the unexplored axis.

recipe.env pins only lr_scheduler_type=linear. That line is load-bearing: train_capped.sh's own default is constant, which I measured at 50.56 versus 54.22 for the linear decay the paper's run used.

Files: /workspace/repo/data_collator.py, /workspace/repo/opsd_trainer.py (plus env-gated loss diagnostics and the measured alternative loss form, both off by default), /workspace/submission/recipe.env, /workspace/submission/METHOD.md. opsd_train.py is unchanged and remains the entrypoint; train_capped.sh and all frozen inputs untouched.