File size: 13,311 Bytes
1f4cc19
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
# Setup actually used, and every deviation from the plan

Written during setup, updated as things changed. The point of this file is that
the final report should never be the first place a deviation is disclosed.

## Hardware (differs from the brief's assumption)

| | assumed | actual |
|---|---|---|
| GPU | ~48–56 GB (RTX 5090 / 6000 Ada class) | **RTX 5090, 32.6 GB** |
| CPU / RAM | β€” | 32 cores / 188 GB |
| Disk | β€” | 70 GB on `/workspace` |
| Driver / CUDA | β€” | 580.65.06 / CUDA 13.0, sm_120 (Blackwell) |

32 GB rather than ~50 GB is the single most consequential fact in this setup.
It is what forced the model split below and every batch-size decision.

## Software

| package | version | note |
|---|---|---|
| torch | 2.13.0+cu130 | upgraded by vLLM; `sm_120` present in arch list |
| vLLM | 0.27.1 | **above** TRL's supported range (0.17–0.26); works, warns |
| transformers | 5.15.0 | |
| trl | 1.10.0 | provides `multi_objective_aggregation` |
| peft | 0.20.0 | |
| liger-kernel | installed | fused RMSNorm/SwiGLU/RoPE + LM-head CE |
| sentence-transformers | 5.7.0 | `BAAI/bge-base-en-v1.5` |

## Deviations from the plan

**1. No Unsloth. TRL `GRPOTrainer` + vLLM colocate instead.**
The plan specifies Unsloth GRPO. Unsloth pins torch/vLLM versions that conflict
with the working sm_120 stack (torch 2.13 + vLLM 0.27.1), and downgrading risks
losing Blackwell support entirely β€” which took real effort to obtain. TRL 1.10's
GRPOTrainer supports vLLM colocate natively and additionally supports the exact
GDPO aggregation this study needs. Cost: no Unsloth memory savings, which is
partly why the batch geometry below is so tight.

**2. FlashInfer disabled.**
vLLM's bundled FlashInfer misreads the CUDA runtime (`SM 12.x requires
CUDA >= 12.9`) and then rejects an sm_120 card as *below* sm_75, killing
EngineCore at warm-up. Worked around with `VLLM_ATTENTION_BACKEND=FLASH_ATTN`
and `VLLM_USE_FLASHINFER_SAMPLER=0`. Verified equivalent output vs `TRITON_ATTN`.

**3. Model split: Qwen3-4B for the frontier, Qwen3-8B for scaling only.**
The user asked about "Qwen3.5-9B". Its config shows 24 `linear_attention` layers
+ 8 `full_attention` (3:1 hybrid, with `mamba_ssm_dtype`), a vision tower, an MTP
head and a 248k vocab. It is neither transformer-only nor text-only, and the
LoRA-GRPO path through TRL/vLLM for linear-attention layers is unproven. Rejected.
`Qwen/Qwen3-8B` is pure `Qwen3ForCausalLM` and is the transformer-only ~9B-class
option (there is no `Qwen3-8B-Instruct-2507`).

8B is infeasible for the *online* arms on 32 GB, and this is arithmetic, not
preference. GRPO with colocated vLLM needs two resident copies of the weights:

```
Qwen3-8B GRPO:  16.4 (trainer) + 16.4 (vLLM) = 32.8 GB > 32 GB, before any KV cache
Qwen3-4B GRPO:   8.05 (trainer) + 8.05 (vLLM) = 16.1 GB
```

LoRA does not help here: it shrinks optimizer state (~98 GB β†’ ~1.2 GB for 8B)
but the frozen base still has to be resident. DPO never generates during
training, so it needs one copy and gets its reference model free by disabling
adapters β€” which is why 8B DPO fits at ~21 GB while 8B GRPO cannot.

Decision: all six primary arms on Qwen3-4B-Instruct-2507 sharing one pool, plus
E3-8B/E4-8B on a separate 8B pool as a scaling arm.

**4. Judge: per-story absolute scoring, not batched relative scoring.**
The prior run sent 16 samples per call and asked for relative scores. Quality is
compared against a fixed gate `tau` and across models at eval, so it must be
absolutely calibrated. Per-story also makes the cache effective (identical
stories recur across arms; each is paid for once).

**5. Judge model `deepseek/deepseek-v4-flash-0731` with reasoning DISABLED.**
It is a reasoning model; left alone it spends its entire output budget on
`reasoning` and returns `content: null`. Calibration caught this at **4/166 calls
succeeding**, with every failure silently becoming a neutral 5.0 β€” a judge that
scores everything identically while appearing to work. `reasoning:{enabled:false}`
fixes it and is 25Γ— cheaper and 5Γ— faster (30 output tokens vs 764).

**6. Judge rubric revised once (v3 β†’ v4).** Allowed by the brief. Strengthened
the finishedness check into an explicit first step. See
`01_judge_calibration.md`.

**7. Judge truncation-sensitivity reclassified as informational.**
A_complete βˆ’ B_cut_matched = +1.33, below the +2.0 I set. Truncation is caught
deterministically with 100% recall by `gates.check()`, and only gate-passing
stories are ever sent to the judge, so judge truncation sensitivity cannot
influence any gradient. The blocking checks (incoherence +2.25, repetition
+4.67, non-saturation sd 0.76) all pass.

**8. Batch geometry, forced by 32 GB.**

| knob | plan | actual | why |
|---|---|---|---|
| G | 8 | 8 | unchanged |
| max_completion_length | 768 | **1024** | *raised*; see below |
| per_device_train_batch_size | β€” | 2 | forward micro-batch; peak is the `2 Γ— seq Γ— 151936` logits tensor |
| gradient_accumulation_steps | β€” | 8 | keeps generation_batch = 16 = 2 prompts Γ— G |
| vllm_gpu_memory_utilization | β€” | 0.33 | ~10.8 GB: 8.05 weights + KV + workspace |

Only `generation_batch_size` must be divisible by `num_generations`; the
micro-batch need not be. So the forward can be shrunk without changing GRPO
group semantics β€” advantages are still computed over full groups of 8.

**9. Token budget RAISED, not lowered.** The plan says 768. A 500-word story is
~650 Qwen tokens, so 768 leaves no headroom and would have re-created the exact
truncation bug that killed the prior run. Set to 1024 (~790 words). The length
gate rejects anything over 600 words (~780 tokens), so 1024 sits *above the
entire accept region*: no story that could ever be scored is truncated by it.
Nothing downstream truncates either β€” the judge and the embedder both receive
complete story text.

**10. Aggregation is GDPO, and it is native.** The brief allowed a single
conditioned scalar as a v1 fallback. Not needed: TRL 1.10's
`multi_objective_aggregation="normalize_then_sum"` is a line-for-line
implementation of GDPO (arXiv 2601.05242, Liu et al., NVIDIA) β€” group-wise
normalization per reward channel, then batch-wise advantage normalization. E1
and E2 therefore run true decoupled normalization. Consequence: Ξ± and Ξ³ weight
*standardized* channels, so Ξ±=0.5 means half an SD of diversity credit per SD of
quality, and no manual rescaling of `d_i` into the 0–10 judge range is needed.

**11. `n_clusters` as specified does not work; `eff_rank` added as the primary
mode metric.** The plan asks for "k-means over embeddings per prompt,
silhouette-selected k (crude mode count)". Measured on synthetic 16-point sets,
silhouette cannot separate collapse from spread at all:

| configuration | best k | silhouette |
|---|---|---|
| fully collapsed (noise 0.001) | 4 | 0.202 |
| fully spread (random) | 7 | 0.195 |
| two clear modes | 2 | **0.976** |

k-means partitions isotropic data regardless of spread, so the metric would have
reported ~4 modes for collapsed output and ~7 for diverse output β€” fabricated
structure, in a headline column. Two changes: `SILHOUETTE_MIN` raised 0.05 β†’
0.50, making `n_clusters` a *conservative* count of well-separated modes
(returns 1 unless structure is unmistakable); and **effective rank**
(exp-entropy of the Gram spectrum, Roy & Vetterli) added as the primary
continuous mode measure β€” 1.00 identical, 2.12 two clusters, 13.29 spread, with
no threshold to tune.

**12. Liger disabled to recover the entropy metric.** `use_liger_kernel=True`
routes TRL to `compute_liger_loss`, which logs only `[clip_ratio, kl]` β€” the
`entropy` metric exists solely on the `_compute_loss` path. Liger had been
enabled to fix an OOM at micro-batch 2; micro-batch is now 1 (halving the logits
tensor), so it is no longer needed. vLLM's budget trimmed 0.32 β†’ 0.29 to cover
the difference. Per-token policy entropy cannot be recovered from a finished
run, and entropy collapse is a first-class failure signal, so this trade was
worth the memory.

**13. Entropy is treated asymmetrically.** A large *fall* is strong evidence the
policy is going deterministic and is flagged as tainting any diversity claim in
that run. A *rise* is only permissive β€” a policy can raise per-token entropy
while staying inside one narrative mode. The prior run demonstrates the
dissociation directly (surface variation up, semantic diversity down). Entropy
is never optimized.

**14. Learning rate raised 2e-6 β†’ 3e-6.** Smoke showed KL β‰ˆ 6e-4 at step 6 under
2e-6; across 300 steps that risked a null result in *every* arm, which would be
uninformative. 3e-6 is still inside the plan's 1e-6..5e-6 band. Too-high instead
risks instability, which the guardrail catches and which is itself a finding.

**15. Eval runs one subprocess per model.** vLLM v1 keeps EngineCore in a child
process and `del llm` does not reliably reclaim its GPU memory, so a 7-model
in-process loop would OOM on model 2 after model 1 succeeded. Process isolation
makes teardown unconditional and stops one model's crash from taking down the
whole eval; completed models are cached as row files and skipped on re-run.

## Fallbacks taken (per the plan's ordered list)

- G 8β†’6: **not taken**
- max_new_tokens 768β†’512: **not taken** (raised to 1024 instead)
- steps 300β†’200: not taken yet
- policy 4B: taken as the primary, by the VRAM argument above
- eval prompts 50β†’30: not taken

## Bugs found and fixed during setup

1. `asyncio.Semaphore` built in `__init__` binds to the first event loop;
   `score_many_sync()` calls `asyncio.run()` once per training step, so it would
   have raised "bound to a different event loop" from step 2 onward and turned
   every judge call into a neutral 5.0. Now built per-call.
2. Judge failures were silent. Added `health()` / `assert_healthy()` so a
   degenerate judge halts training instead of quietly flattening the reward.
3. Prior run's completeness check used straight quotes only; curly `”` endings
   scored 0.3 instead of 1.0. Fixed in `gates.ends_cleanly`.
4. Calibration cells A and B originally differed in model *and* truncation,
   confounding the measurement. Replaced with a matched cell (cell A's own
   stories, cut mid-word).
5. TRL 1.10 removed `max_prompt_length` (GRPOConfig *and* DPOConfig) and
   `warmup_ratio` (DPOConfig). Caught by a CPU dry-run that constructs every
   config object before the pipeline needs them, rather than by a stage failing
   hours in.
6. `m_i <= log(1+eps) ~ 0` is always negative, so gating an ineligible sample to
   `0.0` in the marginal channel would have handed it the *highest* diversity
   credit in its group β€” a reward-hacking channel of our own construction. Fixed
   by flooring ineligible samples to the minimum among eligible ones, a rule
   that is correct regardless of a channel's sign convention. Covered by
   `test_gated_sample_never_outranks_in_marginal_channel`.
7. self-BLEU could exceed 1.0 by ~1e-10 on exact matches due to smoothing;
   clamped.

## Test suite

56 tests across four files, all passing:

| file | n | covers |
|---|---|---|
| `test_diversity.py` | 15 | deviation, log-det, marginal contributions, greedy subset, and the E1-vs-E2 hypothesis asserted directly |
| `test_rewards.py` | 10 | channel construction, quality gating, tau conditioning, the marginal sign trap, degenerate groups, memoization |
| `test_pairs.py` | 14 | DivPO emb/prob selection (incl. the probability inversion), skip logic, E3 multi-positive rows, negative rotation, weight normalization |
| `test_eval_metrics.py` | 17 | distinct-4, self-BLEU directionality, cluster count, effective rank, top-k entropy renormalization |

## Open recommendation: beta (KL coefficient) should be 0 in a future iteration

Measured mid-run on E0 at beta=0.02:

```
KL penalty term (beta x KL) = 0.000509
|loss| mean                 = 0.118202
-> KL contributes 0.4% of total loss magnitude
```

**Empirically beta=0.02 is already near-inert here.** Dropping it to 0.01 or
0.005 would change nothing detectable; the interesting choice is 0 vs non-zero.

**The principled argument for 0 is stronger than the magnitude argument.** The
reference policy *is* the mode-collapsed distribution under study β€” base
Qwen3-4B has effective rank 2.006 out of 16. A KL penalty regularizes the policy
*toward* that reference, i.e. toward the exact pathology the experiment is
trying to escape. In reasoning-RL, KL earns its place by preventing degenerate
output; here that job is done better by the programmatic gates (completeness,
length window, 4-gram loop detection, entropy floor, non-ASCII), which have no
such conflict of interest. Dr. GRPO and much recent work already drop KL
entirely.

**Not changed for this study**, because beta must be identical across arms for
E0 to function as a control, and switching now would force a third restart of
E0 (at step 123/300) to buy a difference worth 0.4% of the loss. Recorded as the
first change to make in a follow-up run.

Related: an earlier note in this log attributed the KL curve's rise-then-settle
shape to "the KL penalty pulling the policy back". That was wrong β€” a term worth
0.4% of the loss cannot do that. The likelier explanation is the policy
converging on a reward optimum near the reference.