File size: 28,399 Bytes
bb23b91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f8d1cb3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb23b91
21a3a16
bb23b91
 
 
 
 
21a3a16
 
 
 
bb23b91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21a3a16
 
 
 
bb23b91
 
 
 
 
f8d1cb3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb23b91
 
f8d1cb3
bb23b91
 
 
 
 
 
 
 
 
 
 
 
21a3a16
 
 
bb23b91
 
 
 
 
 
 
 
 
 
 
f8d1cb3
 
 
 
 
 
 
 
bb23b91
 
 
 
 
 
 
 
 
 
 
f8d1cb3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb23b91
 
 
 
 
 
 
 
 
 
 
f8d1cb3
 
 
bb23b91
 
 
 
f8d1cb3
bb23b91
 
 
 
f8d1cb3
21a3a16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb23b91
 
 
 
21a3a16
 
 
bb23b91
f8d1cb3
 
 
 
 
 
 
 
 
21a3a16
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
bb23b91
 
 
 
 
 
 
 
 
 
 
21a3a16
f8d1cb3
 
bb23b91
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21a3a16
 
bb23b91
f8d1cb3
 
bb23b91
 
 
 
 
 
f8d1cb3
bb23b91
 
 
 
 
21a3a16
f8d1cb3
 
 
bb23b91
 
f8d1cb3
 
 
 
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
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
# coding=utf-8
# Copyright 2024 The Google Research Authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""Transformer LM trainer."""

import functools

from clu import periodic_actions
from flax.training import common_utils
from flax.training import train_state
import jax
from jax import numpy as jnp
import numpy as np
import optax
import ml_collections

from train import model


def get_state(config, net, initial_variables):
    """Get the train state given an experiment config, a model and initial variables.

    Args:
        config: A ConfigDict containing the configuration for the experiment.
        net: The model to use for training.
        initial_variables: The initial variables for the model.

    Returns:
        A tuple containing the train state and the learning rate schedule.
    """
    # Learning rate schedule
    lr_scheduler_fn = functools.partial(
        lr_scheduler,
        learning_rate=config.learning_rate,
        warmup_tokens=config.warmup_tokens,
        final_tokens=config.max_steps,
        config=config,
    )
    # Optimizer
    optim_fn = optax.adamw(
        lr_scheduler_fn, weight_decay=config.weight_decay, b1=0.9, b2=0.95
    )
    # Clip the gradients to prevent exploding gradients.
    optimizer = optax.chain(optax.clip_by_global_norm(1), optim_fn)

    # Initialize the train state
    state = train_state.TrainState.create(
        apply_fn=net.apply, params=initial_variables["params"],
        tx=optimizer
        )

    return state, lr_scheduler_fn

def lr_scheduler(
    n_tokens: int, learning_rate: float, warmup_tokens: int, final_tokens: int,
    config: ml_collections.ConfigDict,
) -> float:
    """Learning rate scheduler, adapted from Mikhail Grankin.

    The learning rate schedule is cosine decay with a warmup period.

    The learning rate starts at 0 and linearly increases to the given learning
    rate over the warmup period. After the warmup period, the learning rate
    decays according to a cosine schedule, with the given learning rate as the
    maximum value.

    Args:
        n_tokens: The number of tokens processed so far.
        learning_rate: The initial learning rate.
        warmup_tokens: The number of tokens to warm up over.
        final_tokens: The total number of tokens to process.
        config: A ConfigDict containing the configuration for the learning rate
            schedule.

    Returns:
        The learning rate at the given point in the schedule.
    """
    # Decay the learning rate based on our progress.
    progress = (n_tokens - warmup_tokens) / max(
        1, final_tokens - warmup_tokens,
    )
    lr_mult = jnp.where(
        n_tokens < warmup_tokens,
        # Linear warmup.
        n_tokens / jnp.fmax(1, warmup_tokens),
        # Cosine learning rate decay.
        jnp.fmax(config.end_lr_factor, 0.5 * (1.0 + jnp.cos(np.pi * progress))),
    )
    return learning_rate * lr_mult


def get_metrics_report_progress(config, workdir, writer):
    """
    Get the metrics for reporting progress during training.

    Args:
        config: The configuration for the experiment.
        workdir: The directory for storing the logs.
        writer: The writer object for recording the metrics.

    Returns:
        hooks: List of hooks for tracking progress.
        report_progress: Object for reporting progress.
        train_metrics: List of training metrics.
    """
    hooks = []

    # Initialize the report progress object
    report_progress = periodic_actions.ReportProgress(
        num_train_steps=config.max_steps, writer=writer)

    # Add metrics for profiling if the process index is 0
    if jax.process_index() == 0:
        hooks += [report_progress,
                  periodic_actions.Profile(logdir=workdir, num_profile_steps=5)]
    
    # Initialize the list of training metrics
    train_metrics = []
    
    return hooks, report_progress, train_metrics


def get_input_start_index(batch, config):
    inputs = jax.tree_util.tree_map(np.asarray, batch[0])
    puzzles = jax.tree_util.tree_map(np.asarray, batch[1])
    start_index = jax.tree_util.tree_map(np.asarray, batch[2])
    levels = jax.tree_util.tree_map(np.asarray, batch[3])
    cand_targets = jax.tree_util.tree_map(np.asarray, batch[4])
    return inputs, puzzles, start_index, levels, cand_targets


def train_one_step(p_train_step, config, state, step, dropout_rngs, train_data_iter):
    """
    Single step of the training loop.

    Args:
        p_train_step: The training step function.
        config: The experiment configuration.
        state: The train state.
        step: The step number.
        dropout_rngs: The dropout random number generator.
        train_data_iter: The iterator for the train data.

    Returns:
        The updated train state and train metrics.
    """
    with jax.profiler.StepTraceAnnotation("train", step_num=step):
        # Get the next batch from the iterator
        batch = next(train_data_iter)
        # Extract the inputs, start index and difficulty level from the batch
        inputs, _, start_index, levels, cand_targets = get_input_start_index(batch, config)
        # Shard across the devices
        inputs = common_utils.shard(jax.tree_util.tree_map(np.asarray, inputs))
        start_index = common_utils.shard(jax.tree_util.tree_map(np.asarray, start_index))
        levels = common_utils.shard(jax.tree_util.tree_map(np.asarray, levels))
        # Explicit reshape (not common_utils.shard) so a zero-width slot dim
        # (K=0 no-latent baseline: cand_targets is (bs, 0, 81)) shards without
        # the ambiguous -1 inference. Identical to shard() when K>0.
        cand_targets = np.asarray(cand_targets)
        _nd = jax.local_device_count()
        cand_targets = cand_targets.reshape(
            (_nd, cand_targets.shape[0] // _nd) + cand_targets.shape[1:])

        # Run the training step
        state, metrics, _ = p_train_step(
            state, inputs, start_index, levels, cand_targets, dropout_rng=dropout_rngs
        )

    return state, metrics


def _shard_train_batch(batch, config):
    """Unpack a dataset batch and shard it the way train_one_step does."""
    inputs, _, start_index, levels, cand_targets = get_input_start_index(
        batch, config)
    inputs = common_utils.shard(jax.tree_util.tree_map(np.asarray, inputs))
    start_index = common_utils.shard(
        jax.tree_util.tree_map(np.asarray, start_index))
    levels = common_utils.shard(jax.tree_util.tree_map(np.asarray, levels))
    cand_targets = np.asarray(cand_targets)
    nd = jax.local_device_count()
    cand_targets = cand_targets.reshape(
        (nd, cand_targets.shape[0] // nd) + cand_targets.shape[1:])
    return inputs, start_index, levels, cand_targets


def apply_grads(state, grads):
    """Replicated optimizer step. Used after averaging per-depth grads."""
    return state.apply_gradients(grads=grads)


def train_depth_mix_step(grad_step_for, p_apply, config, state, step,
                         dropout_rngs, mix_ds, n_depths):
    """One optimizer step whose CE is the mean over depths 1..n_depths.

    Depth d is run with d latent passes on a batch whose value tokens are
    stage-(d-1) instances, drawn from `mix_ds` (a SudokuDataset, not tf.data).
    Gradients are summed and divided by n_depths, then applied once, so the
    update is joint rather than a serial overwrite.
    """
    n_depths = int(max(n_depths, 1))
    bs = int(config.minibatch_size)
    acc = None
    metrics_list = []
    with jax.profiler.StepTraceAnnotation("train", step_num=step):
        for d in range(1, n_depths + 1):
            batch = mix_ds.sample_instance_batch(bs, d - 1)
            inputs, start_index, levels, cand_targets = _shard_train_batch(
                batch, config)
            grads, metrics, _ = grad_step_for(d)(
                state, inputs, start_index, levels, cand_targets,
                dropout_rng=dropout_rngs)
            acc = (grads if acc is None
                   else jax.tree_util.tree_map(lambda a, b: a + b, acc, grads))
            metrics_list.append(metrics)
        acc = jax.tree_util.tree_map(lambda a: a / n_depths, acc)
        state = p_apply(state, acc)

    def _mean_metric(key):
        vals = [m[key] for m in metrics_list if key in m]
        if not vals:
            return None
        return sum(vals) / float(len(vals))

    metrics = dict(metrics_list[-1])
    for key in ("loss", "ce_loss", "aux_loss", "leak_loss", "latent_sigma"):
        v = _mean_metric(key)
        if v is not None:
            metrics[key] = v
    return state, metrics


def build_latent_state(inputs, start_index, levels, config, num_passes,
                       apply_fn, rngs=None, trunc_passes=0):
    """Build the continuous latent thoughts (Coconut/ATC-style recurrence).

    Pass 0 seeds z_1 from the last-layer hidden at the last clue token; pass j
    reads the hidden at latent slot j to produce z_{j+1}. Slot j is active for
    an example only when j < k, with k = clip(level - 2, 1, K) (difficulty-
    matched latent budget). By default gradients flow through all passes
    (full BPTT). If trunc_passes=T>0, the forward chain is unchanged but
    thoughts older than the last T CoTs are stop-gradient'd, so the loss
    only backprops through those last T recurrent steps.

    Returns (latent_vals, slot_pos, active_full).
    """
    num_slots = int(config.num_latent_slots)
    bs = inputs.shape[0]
    bidx = jnp.arange(bs)

    si3 = 3 * start_index.reshape(-1)                      # (bs,)
    slot_pos = si3[:, None] + jnp.arange(num_slots)[None, :]   # (bs, K)
    if getattr(config, "cand_slot_mode", "level") == "depth":
        # Every slot the recurrence fills is active, uniformly over the batch.
        k = jnp.full((bs,), max(min(num_passes, num_slots), 1), dtype=jnp.int32)
    else:
        k = jnp.clip(levels.reshape(-1) - 2, 1, num_slots)  # (bs,)
    active_full = jnp.arange(num_slots)[None, :] < k[:, None]  # (bs, K)

    latent_vals = jnp.zeros((bs, num_slots, config.emb_dim), dtype=config.dtype)

    for j in range(num_passes):
        act_j = active_full & (jnp.arange(num_slots)[None, :] < j)
        _, hidden, _ = apply_fn(inputs, latent_vals, slot_pos, act_j, rngs)
        src = si3 - 1 + j   # j=0: last clue token; j>0: latent slot j-1
        z = hidden[bidx, src]
        # Truncated BPTT: keep the full forward chain, but detach CoTs
        # before the last T so earlier thoughts are treated as constants.
        if trunc_passes > 0 and j < num_passes - trunc_passes:
            z = jax.lax.stop_gradient(z)
        latent_vals = latent_vals.at[:, j].set(z.astype(config.dtype))

    return latent_vals, slot_pos, active_full


def build_latent_state_noisy(inputs, start_index, levels, config, num_passes,
                             apply_fn, sample_slot, rng, trunc_passes=0):
    """One sample: noise h1, pass, noise h2, pass, ..., then readout."""
    num_slots = int(config.num_latent_slots)
    bs = inputs.shape[0]
    bidx = jnp.arange(bs)
    si3 = 3 * start_index.reshape(-1)
    slot_pos = si3[:, None] + jnp.arange(num_slots)[None, :]
    if getattr(config, "cand_slot_mode", "level") == "depth":
        k = jnp.full((bs,), max(min(num_passes, num_slots), 1), dtype=jnp.int32)
    else:
        k = jnp.clip(levels.reshape(-1) - 2, 1, num_slots)
    active_full = jnp.arange(num_slots)[None, :] < k[:, None]
    latent_vals = jnp.zeros((bs, num_slots, config.emb_dim), dtype=config.dtype)
    keys = jax.random.split(rng, num_passes)
    last_sigma = jnp.zeros((), jnp.float32)

    for j in range(num_passes):
        act_j = active_full & (jnp.arange(num_slots)[None, :] < j)
        drop_k, noise_k = jax.random.split(keys[j])
        _, hidden, _ = apply_fn(
            inputs, latent_vals, slot_pos, act_j, {"dropout": drop_k})
        # Same read position as the deterministic recurrence and the gate:
        # j=0 is the last clue token, j>0 is latent slot j-1's hidden.
        z = hidden[bidx, si3 - 1 + j]
        if trunc_passes > 0 and j < num_passes - trunc_passes:
            z = jax.lax.stop_gradient(z)
        mu, log_sigma = sample_slot(z, j)
        log_sigma = jnp.clip(log_sigma, -12.0, 2.0)
        eps = jax.random.normal(noise_k, z.shape, jnp.float32)
        z = (mu + jnp.exp(log_sigma) * eps).astype(config.dtype)
        last_sigma = jnp.exp(log_sigma).mean()
        latent_vals = latent_vals.at[:, j].set(z)

    return latent_vals, slot_pos, active_full, last_sigma


def train_step(state, batch, start_index, levels, cand_targets, config,
               hyperparams, learning_rate_fn, num_passes, dropout_rng=None,
               backtrack=False, inst_slot=-1, apply_update=True):
    """One step of the training loop.

    Args:
        state: Train state.
        batch: Input batch (bs, 3*81 + K) with latent placeholder slots.
        start_index: Number of clue cells per example.
        levels: Puzzle difficulty level (3..8) per example.
        config: Model config.
        hyperparams: Hyperparameter dictionary.
        learning_rate_fn: Learning rate function.
        num_passes: Number of latent recurrence passes (= curriculum stage).
        dropout_rng: RNG used for dropout.
        inst_slot: Candidate-mask slot matching the stage whose instances supply
            this batch's value tokens (= curriculum stage - 1). Only the leak
            penalty reads it. -1 derives it from num_passes.

    Returns:
        A new train state, train metrics, and computed model predictions.
    """
    num_slots = int(config.num_latent_slots)
    # Extract inputs and labels from the batch
    inputs = batch[:, :-1]
    label = batch[:, 1:]

    # Update dropout_rng
    dropout_rng = jax.random.fold_in(dropout_rng, state.step)
    # Stochastic latents draw from their own stream. Only split when the arm is
    # on, so the deterministic control keeps its exact dropout sequence.
    n_mc = int(getattr(hyperparams, "latent_mc_samples", 0))
    use_noise = bool(getattr(config, "latent_noise", False)) and n_mc > 0
    noise_rng = None
    if use_noise:
        dropout_rng, noise_rng = jax.random.split(dropout_rng)
    mc_objective = str(getattr(hyperparams, "latent_mc_objective", "mean"))
    dropout_rng_dict = {"dropout": dropout_rng}

    def loss_fn(params):
        """Compute the loss function."""
        net = model.TransformerLMHeadModel(config)

        def apply_fn(x, lv, lp, la, rngs):
            return net.apply({"params": params}, x, latent_values=lv,
                             latent_positions=lp, latent_active=la,
                             rngs=rngs)

        # M independent samples of (noise h1, pass, noise h2, ..., readout).
        # Mean CE.
        sigma_mean = jnp.zeros((), jnp.float32)
        if use_noise:
            def _sample_slot(z, slot):
                return net.apply(
                    {"params": params}, z, slot,
                    method=model.TransformerLMHeadModel.latent_noise_dist_slot)

            def _one_draw(key):
                build_key, read_key = jax.random.split(key)
                def apply_draw(x, lv, lp, la, rngs):
                    return apply_fn(x, lv, lp, la, rngs)
                latent_vals, slot_pos, active_full, sig = (
                    build_latent_state_noisy(
                        inputs, start_index, levels, config, num_passes,
                        apply_draw, _sample_slot, build_key,
                        trunc_passes=int(getattr(hyperparams, "bptt_trunc", 0))))
                lg, _, cl = apply_draw(
                    inputs, latent_vals, slot_pos, active_full,
                    {"dropout": read_key})
                return lg, cl, sig

            pred_logits_mc, cand_logits_mc, sig_mc = jax.vmap(_one_draw)(
                jax.random.split(noise_rng, n_mc))
            pred_logits = pred_logits_mc.mean(axis=0)
            cand_logits = cand_logits_mc.mean(axis=0)
            sigma_mean = sig_mc.mean()
        else:
            latent_vals, slot_pos, active_full = build_latent_state(
                inputs, start_index, levels, config, num_passes, apply_fn,
                rngs=dropout_rng_dict,
                trunc_passes=int(getattr(hyperparams, "bptt_trunc", 0)))
            pred_logits, _, cand_logits = apply_fn(
                inputs, latent_vals, slot_pos, active_full, dropout_rng_dict)
            pred_logits_mc = pred_logits[None]

        label_one_hot = jax.nn.one_hot(label, num_classes=config.vocab_size)

        # The variables label_one_hot and pred_logits both are 3-dimensional tensors with
        # first axis corresponding to batch size, second correspondingn to sequence length 
        # and third corresponding to the row/column/value at a particular cell
        assert label_one_hot.shape == pred_logits.shape, ("one hot label shape",
                                                        label_one_hot.shape,
                                                        label.shape,
                                                        pred_logits.shape)
        
        # Calculate the cross-entropy loss along the last axis, per MC draw.
        # ce_loss is (M, bs, L); M is 1 on the deterministic path, so the
        # arithmetic below reduces to the original single-forward loss exactly.
        pred_logits_sol = pred_logits[:, :, :]
        label_one_hot_sol = label_one_hot[:, :, :]

        ce_loss = optax.softmax_cross_entropy(
            logits=pred_logits_mc, labels=label_one_hot_sol[None]
        )

        # Apply masking to the loss: supervise only the solution region,
        # which now starts K latent slots after the clue block.
        pos = jnp.arange(label.shape[1])[None, :]                   # (1, L)
        base = 3 * jnp.asarray(start_index).reshape(-1, 1) + num_slots
        mask = pos >= base

        # Split the supervised tokens by role. The solution region is a stream of
        # (row, col, value) triples, so the value token of empty cell t sits at
        # base+3t+2; ce_loss[i] scores the token at i+1, hence the shift below.
        # Two of every three supervised tokens are locations, and they demand the
        # solver's particular linearization of a wave whose cells are actually
        # order-interchangeable -- pressure the promotion gate (a teacher-forced,
        # value-only CE(q||p)) never measures. Down-weighting them spends the
        # capacity on the value distribution instead.
        is_value = ((pos + 1 - base) % 3) == 2
        loc_weight = float(getattr(hyperparams, "loc_loss_weight", 1.0))
        value_weight = float(getattr(hyperparams, "value_loss_weight", 1.0))
        token_w = jnp.where(is_value, value_weight, loc_weight) * mask

        # Per-token mean (not per-example sum): keeps the LM CE on the same
        # O(1) scale as the per-digit BCE below, so aux_cand_weight~1 actually
        # balances the two instead of the candidate signal being swamped.
        # The denominator stays the *unweighted* supervised count so that
        # re-weighting moves the location gradient without silently rescaling the
        # value gradient too, and weights of 1.0 reproduce the plain mean exactly.
        ce_denom = jnp.maximum(mask.sum(), 1.0)
        # Per-draw scalar first, then reduce over the draws. "mean" is the
        # Monte-Carlo estimate of E_z[CE]; "min" charges only the best draw,
        # which is what lets separate draws specialize instead of all being
        # pulled to the same target.
        per_draw_ce = (ce_loss * token_w[None]).sum(axis=(1, 2)) / ce_denom
        if use_noise and mc_objective == "min":
            avg_ce_loss = per_draw_ce.min()
        else:
            avg_ce_loss = per_draw_ce.mean()

        # ---- Leak penalty: probability mass placed outside the candidate set --
        # The gate is CE(q||p) <= log|S|, and CE(q||p) = -log(in-set mass)
        # + H(q) + KL(q||p_renormalized). Measured on a stage-3 checkpoint the
        # leak term is what grows with curriculum depth (0.03 nats at stage 3,
        # 0.07 at stage 4, 0.13 at stage 5) while the within-set shape error
        # stays flat, so it accounts for the whole deepening deficit. Nothing
        # else in this objective penalizes it: the LM CE only ever rewards the
        # one sampled digit, and the candidate head that used to supply the set
        # boundary is off (aux_cand_weight=0) on the instance arm. This term
        # minimizes exactly the measured quantity, -log(sum of p over S).
        leak_weight = float(getattr(hyperparams, "leak_weight", 0.0))
        if leak_weight > 0.0:
            slot = inst_slot if inst_slot >= 0 else num_passes - 1
            slot = int(min(max(slot, 0), num_slots - 1))
            # At a value position i the row/col tokens sit at i-1 and i, so the
            # cell this position is answering is inputs[i-1]*9 + inputs[i].
            prev_tok = jnp.concatenate(
                [jnp.zeros((inputs.shape[0], 1), inputs.dtype),
                 inputs[:, :-1]], axis=1)
            cell = jnp.clip(prev_tok.astype(jnp.int32) * 9
                            + inputs.astype(jnp.int32), 0, 80)
            bits_at = jnp.take_along_axis(
                cand_targets[:, slot, :].astype(jnp.int32), cell, axis=1)
            dv = jnp.arange(config.vocab_size)
            # Token ids are the digits themselves, so digit d lives at index d.
            in_set = (
                ((bits_at[:, :, None] >> jnp.clip(dv - 1, 0, 8)[None, None, :])
                 & 1) > 0) & ((dv >= 1) & (dv <= 9))[None, None, :]
            # Shifted softmax in fp32: p <= 1 and Z >= 1, so both logs are safe
            # without a logsumexp import.
            lg = pred_logits_sol.astype(jnp.float32)
            pe = jnp.exp(lg - lg.max(axis=-1, keepdims=True))
            z_all = pe.sum(axis=-1)
            z_in = (pe * in_set).sum(axis=-1)
            leak = jnp.log(z_all) - jnp.log(jnp.maximum(z_in, 1e-30))
            # Only real empty cells: clue cells are sentinel-zeroed in the masks.
            leak_m = mask & is_value & (bits_at > 0)
            leak_loss = ((leak * leak_m).sum()
                         / jnp.maximum(leak_m.sum(), 1.0))
        else:
            leak_loss = jnp.zeros((), dtype=jnp.float32)

        # ---- Auxiliary multi-candidate BCE loss on the latent slots ----
        # cand_targets: (bs, K, 81) int bitmask (bit d-1 set <=> digit d is a
        # candidate at that slot's stage; slot j already aligned to the puzzle's
        # k=level-2 budget in the data pipeline). Expand to (bs,K,81,9) multi-hot.
        aux_weight = float(getattr(hyperparams, "aux_cand_weight", 1.0))
        if aux_weight == 0.0:
            # Instance arm: the superposition lives in the varying targets, not
            # in a set head. Drop the whole BCE graph so nothing but the LM CE
            # shapes the latents.
            zero = jnp.zeros((), dtype=pred_logits.dtype)
            total = avg_ce_loss + leak_weight * leak_loss
            return total, (pred_logits, avg_ce_loss, zero, leak_loss,
                           sigma_mean)

        bits = jnp.arange(9)
        cand_multi_hot = ((cand_targets[..., None].astype(jnp.int32)
                           >> bits) & 1).astype(cand_logits.dtype)  # (bs,K,81,9)
        # Positive-weighted BCE. Candidate masks are sparse (~1-3 of 9 digits
        # "on"), so plain BCE collapses to predicting all-zeros. Up-weighting
        # the positive (candidate-present) term by pos_weight counteracts the
        # imbalance and forces the head to actually predict the candidate set.
        pos_weight = float(getattr(hyperparams, "aux_pos_weight", 5.0))
        log_p = jax.nn.log_sigmoid(cand_logits)                     # log sigmoid(x)
        log_1mp = jax.nn.log_sigmoid(-cand_logits)                  # log(1 - sigmoid(x))
        bce = -(pos_weight * cand_multi_hot * log_p
                + (1.0 - cand_multi_hot) * log_1mp)                 # (bs,K,81,9)
        # Only the empty cells are supervised: clue cells were sentinel-zeroed
        # in the data pipeline, so any cell whose target row is all-zero is a
        # clue and must not contribute. This makes the effective target
        # (#empty cells) x 9 per puzzle rather than 81 x 9.
        cell_mask = (cand_targets > 0).astype(cand_logits.dtype)    # (bs, K, 81)
        # Delta weighting: consecutive stages are near-duplicates (at K=12 only
        # ~14% of cells change per stage, so ~97% of the target bits are copies
        # of the previous slot). Weighting the unchanged cells below 1 stops the
        # objective from being satisfied by echoing slot j-1. Slot 0 has no
        # predecessor, so it is fully weighted.
        delta_bg = float(getattr(hyperparams, "aux_delta_bg", 1.0))
        if delta_bg != 1.0:
            changed = jnp.concatenate(
                [jnp.ones_like(cand_targets[:, :1], dtype=bool),
                 cand_targets[:, 1:] != cand_targets[:, :-1]], axis=1)
            cell_w = cell_mask * (delta_bg + (1.0 - delta_bg)
                                  * changed.astype(cand_logits.dtype))
        else:
            cell_w = cell_mask
        cell_denom = jnp.maximum(cell_w.sum(axis=2), 1e-6)          # (bs, K)
        bce_per_cell = bce.mean(axis=3)                             # (bs, K, 81), mean over 9 digits
        bce_per_slot = ((bce_per_cell * cell_w).sum(axis=2)
                        / cell_denom)                               # (bs, K), mean over empty cells
        if backtrack:
            # Strict stage-replay: supervise ONLY the stage-t readout = the last
            # active latent slot per example (index k-1), whose candidate target
            # is that stage's grid. Earlier slots and the LM CE are dropped, so
            # this step purely re-derives "apply f exactly t times -> stage-t
            # readout." Injection above still spans all active slots, so the
            # recurrence reaching slot k-1 is intact.
            k_per = active_full.sum(axis=1)                         # (bs,)
            last_idx = (k_per - 1)[:, None]                         # (bs, 1)
            slot_sel = ((jnp.arange(num_slots)[None, :] == last_idx)
                        & active_full)                             # (bs, K)
            slot_active = slot_sel.astype(bce_per_slot.dtype)
        else:
            slot_active = active_full.astype(bce_per_slot.dtype)    # (bs, K)
        aux_denom = jnp.maximum(slot_active.sum(), 1.0)
        avg_aux_loss = (bce_per_slot * slot_active).sum() / aux_denom

        # Backtrack (replay) steps train only from the readout (no LM CE).
        ce_term = 0.0 if backtrack else avg_ce_loss
        leak_term = 0.0 if backtrack else leak_weight * leak_loss
        total_loss = ce_term + aux_weight * avg_aux_loss + leak_term

        return total_loss, (pred_logits, avg_ce_loss, avg_aux_loss, leak_loss,
                            sigma_mean)

    # Compute the learning rate and perform gradient descent
    step = state.step
    lr = learning_rate_fn(step)
    (loss, aux), grads = jax.value_and_grad(loss_fn,
                                            has_aux=True)(state.params)
    pred_logits, ce_loss, aux_loss, leak_loss, sigma_mean = aux
    grads = jax.lax.pmean(grads, "batch")
    metrics = {
        "step": step, "loss": loss, "learning_rate": lr,
        "ce_loss": ce_loss,
        "aux_loss": aux_loss,
        "leak_loss": leak_loss,
        # Mean of the learned per-slot sigma. This is the number that tells us
        # whether the noise survives training or is driven to zero.
        "latent_sigma": sigma_mean,
        "pred_logits": pred_logits, "weights": inputs.shape[0]
    }
    if apply_update:
        new_state = state.apply_gradients(grads=grads)
        return new_state, metrics, pred_logits
    return grads, metrics, pred_logits