B28 composite reward framework
status: scaffolding-shipped-2026-06-13 (training-loop integration pending)
owner: unassigned
created: 2026-06-13
parent_plan: docs/PLAN.md §3 Tier B (B28)
parent_learn: docs/learn/castform-rl-finetune.md (Steal #1)
related_prds: 5.1-reasoning-on-22M.md (GRPO consumer), B22-trajectory-recorder.md (the rollouts each Reward scores)
PRD — Composite reward with named dimensions
Goal
Ship a typed CompositeReward abstraction: a reward is a bag of
named scalar dimensions + weights → total. Every dimension is
independently logged, plotted, and inspectable. DPO / ES / GRPO
(5.1) all consume the same struct.
Stolen from Castform’s reward composition pattern (see
docs/learn/castform-rl-finetune.md §1). Different from existing
ES/DPO single-scalar paths.
Why now
- A1 specialist’s eventual DPO-refinement pass benefits immediately. A specialist’s reward is rarely “one thing” — it’s “tool-call is right” + “spoken_text is concise” + “didn’t hallucinate”. Hand- rolling that combination per recipe is the friction this removes.
- 5.1 GRPO PRD calls out RLVR for math + code with verifiable outcome rewards. Once that lands, “verifiable outcome” is one dimension; “did the rollout use the cheap tool first?” is another. Same composite abstraction supports both.
Scope — in
Sources/TinyGPTModel/CompositeReward.swift— types + aggregation. Already shipped in this PR:RewardDimension(name, score, weight)CompositeReward(dimensions, total computed property, logging)- JSONL round-trip for per-rollout dashboards
- Training-loop integration (the remaining work):
posttrainllm dpo --reward-fn <path>— load a user-defined reward fn from a Swift-shaped specification file (JSON with weights; individual scorers are still Swift code per recipe). The shipped.swiftrecipe files do the actual scoring; the--reward-fnarg just routes the aggregation.- ES same flag.
Sources/TinyGPT/Grpo.swift(5.1) consumes natively.
- Train-time logging: per-step JSONL row gains
composite_reward: {dim_a: …, dim_b: …, total: …}.
Scope — out
- Pluggable Python reward fns — Castform’s
BaseEnv.compute_rewardis in Python. We keep scoring in Swift for type safety + perf; Python harness scoring (existingeval_pace_unhappy.pystyle) emits its own JSONL that the Swift training loop reads as input. - Auto-weighting (learn the weights from data). Manual weights for V1.
- Reward shaping over time (decay one dimension’s weight as training progresses). V2.
Files (already shipped in this PR)
| File | Change |
|---|---|
native-mac/Sources/TinyGPTModel/CompositeReward.swift |
new — types + aggregation + JSON I/O |
native-mac/Tests/TinyGPTModelTests/CompositeRewardTests.swift |
new — aggregation + JSON round-trip tests |
Files still to touch (remaining B28 work)
| File | Change |
|---|---|
Sources/TinyGPT/Dpo.swift |
accept --reward-fn path; route the chosen/rejected scalar into a CompositeReward block |
Sources/TinyGPT/Es.swift |
same |
Sources/TinyGPT/Grpo.swift |
uses CompositeReward natively when 5.1 ships |
web/src/pages/train-viewer.astro (C10) |
render per-dimension lines when present in history JSONL |
docs/recipes/composite-reward.md |
new — recipe |
Acceptance criteria
Scaffolding (shipped this PR)
-
CompositeRewardaggregates weighted dimensions to a total matching a hand-checked formula. - JSON round-trip preserves dimension names + weights + scores.
- Unit tests pass under
xcrun swift test --filter CompositeRewardTests.
Full B28 ship (remaining)
-
posttrainllm dpo --reward-fn weights.json ...runs end-to-end on a fixture dataset; per-step history JSONL carries the composite reward block. - Train viewer (C10) renders per-dimension reward curves.
- One published recipe in
docs/recipes/composite-reward.md.
Reference patterns
Sources/TinyGPT/Es.swiftlines 187–227 — the existing centred-reward pattern; CompositeReward stacks above it.Sources/TinyGPT/Dpo.swiftSimPO/IPO loss paths — the margin-reward shape composite replaces with named dims.- Castform site — the public reward- composition copy.
Open questions
- Whether to require weights to sum to 1.0 (normalize) vs allow arbitrary weights. Recommendation: don’t normalize — the user knows what they mean; the total is what’s plotted.