File size: 30,722 Bytes
1e05592
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
#!/usr/bin/env python3
"""
make_belief_cache_v2.py
═══════════════════════════════════════════════════════════════════════════════
Cache pre-VLM features for ablation matrix M0–M14 (CoT-Pool plan, Phase 0).

Modes
─────
  --cache_mode mean_pool      (legacy, sanity-equivalent to v1)
      output:  beliefs       [N, D]      fp16
  --cache_mode dual_pool      (M1: image vs text mean, separately)
      output:  beliefs_img   [N, D]      fp16
               beliefs_text  [N, D]      fp16
  --cache_mode per_frame      (M3-M5: time-axis preserved, spatial pooled)
      output:  beliefs_frame [N, F, D]   fp16     (F = MAX_FRAMES = 8)
               valid_frames  [N, F]      bool
               beliefs_text  [N, D]      fp16     (auxiliary text pool)
  --cache_mode spatial4x4     (M6-M11: time + 4Γ—4 spatial per frame)
      output:  beliefs_grid  [N, F, 16, D] fp16   (16 = 4Γ—4 spatial pooled)
               valid_frames  [N, F]        bool
               beliefs_text  [N, D]        fp16

All modes additionally save: tta_means [N] fp32, tta_vars [N] fp32,
                             schema_version=2, cache_mode, hidden_dim, n_frames.

Why fp16?
  β€’ Belief vectors come from a bf16/fp16 forward; fp32 storage is wasteful.
  β€’ Halves disk + IO; trainer can promote to fp32 at use-time if needed.

Storage budget (217k samples, D=2048, F=8)
  mean_pool     β‰ˆ   1.7 GB
  dual_pool     β‰ˆ   3.4 GB
  per_frame     β‰ˆ  13.5 GB
  spatial4x4    β‰ˆ 113   GB        (use mmap; do NOT load fully into RAM)

Index invariant (same as v1)
  cache[i] corresponds to manifest sample i in
  data/policy_labels/{split}.json["samples"][i].

Usage
─────
  cd PROJECT_ROOT
  python -m training.Policy.make_belief_cache_v2 \\
      --sft_checkpoint checkpoints/SFT/sft_v2/best \\
      --cache_mode     spatial4x4 \\
      --label_dir      data/policy_labels \\
      --out_dir        data/belief_cache_v2 \\
      --batch_size     4
"""

from __future__ import annotations

import argparse
import json
import logging
from pathlib import Path
from typing import Dict, List, Optional, Tuple

import torch
import torch.nn.functional as F
from torch.amp import autocast
from torch.utils.data import DataLoader
from tqdm import tqdm

import sys
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))

from training.Policy.policy_model import PolicyModel
from training.Policy.policy_dataset import PolicyDataset, policy_collate_fn, MAX_FRAMES

logging.basicConfig(level=logging.INFO,
                    format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger("Policy.make_cache_v2")


SCHEMA_VERSION = 2

# ─────────────────────────────────────────────────────────────────────────────
# Helpers β€” per-image token slicing
# ─────────────────────────────────────────────────────────────────────────────

def _get_spatial_merge_size(model: PolicyModel) -> int:
    """Read spatial_merge_size from VLM vision config.  Qwen2.5-VL = 2."""
    base = model.sft.get_base_model()
    cfg = getattr(base, "config", None)
    vc = getattr(cfg, "vision_config", None) if cfg is not None else None
    sms = getattr(vc, "spatial_merge_size", None) if vc is not None else None
    if sms is None:
        logger.warning("Could not read vision_config.spatial_merge_size; "
                       "defaulting to 2 (Qwen2.5-VL).")
        sms = 2
    return int(sms)


def _per_image_token_counts(image_grid_thw: torch.Tensor,
                            spatial_merge_size: int) -> List[int]:
    """
    For each image i in this batch, how many LLM-visible visual tokens it emits.
      count_i = t_i * h_i * w_i // (spatial_merge_size**2)
    """
    counts: List[int] = []
    sms2 = spatial_merge_size * spatial_merge_size
    for row in image_grid_thw.tolist():
        t, h, w = row[0], row[1], row[2]
        c = (t * h * w) // sms2
        counts.append(int(c))
    return counts


def _spatial_pool_image(tokens: torch.Tensor,
                        h_post: int,
                        w_post: int,
                        out_hw: int = 4) -> torch.Tensor:
    """
    tokens   : [n_tok, D] flattened post-merger spatial sequence for ONE image
    h_post   : post-merger height = h // spatial_merge_size
    w_post   : post-merger width  = w // spatial_merge_size
    out_hw   : target spatial side (4 β†’ 4Γ—4 = 16 outputs)

    Returns  : [out_hw*out_hw, D]
    """
    n_tok, D = tokens.shape
    assert n_tok == h_post * w_post, \
        f"token count {n_tok} != h_post*w_post={h_post * w_post}"
    # β†’ [1, D, h_post, w_post]
    grid = tokens.transpose(0, 1).reshape(1, D, h_post, w_post)
    pooled = F.adaptive_avg_pool2d(grid.float(), (out_hw, out_hw))   # promote to fp32 for AAP
    # β†’ [out_hw*out_hw, D]
    pooled = pooled.reshape(D, out_hw * out_hw).transpose(0, 1)
    return pooled.to(tokens.dtype)


# ─────────────────────────────────────────────────────────────────────────────
# Per-sample feature extraction
# ─────────────────────────────────────────────────────────────────────────────

def _split_sample_visual_tokens(
    hidden_states_b: torch.Tensor,            # [L, D]   one sample's tokens
    input_ids_b:     torch.Tensor,            # [L]
    attention_mask_b: torch.Tensor,           # [L]
    image_grid_thw_b: torch.Tensor,           # [n_img_in_sample, 3]
    image_token_id:   int,
    spatial_merge_size: int,
) -> Tuple[List[torch.Tensor], List[Tuple[int, int]]]:
    """
    Split a single sample's hidden states into per-image chunks.

    Returns
    -------
    chunks  : list of length n_img,  each [count_i, D]   (image-token hiddens)
    shapes  : list of (h_post, w_post) per image
    """
    # 1. Find positions of image_token_id within VALID region.
    valid = attention_mask_b > 0
    is_img = (input_ids_b == image_token_id) & valid
    img_positions = torch.nonzero(is_img, as_tuple=False).squeeze(-1)
    n_img_tokens = int(img_positions.numel())

    counts = _per_image_token_counts(image_grid_thw_b, spatial_merge_size)
    expected_total = sum(counts)

    if n_img_tokens != expected_total:
        raise RuntimeError(
            f"Visual-token count mismatch: input_ids has {n_img_tokens} "
            f"image-token positions, but image_grid_thw expects {expected_total}. "
            f"image_grid_thw rows: {image_grid_thw_b.tolist()}"
        )

    # 2. Slice hidden_states at those positions (already contiguous per Qwen layout).
    img_hidden = hidden_states_b[img_positions]   # [n_img_tokens, D]

    # 3. Partition into per-image chunks; remember (h_post, w_post).
    chunks: List[torch.Tensor] = []
    shapes: List[Tuple[int, int]] = []
    cursor = 0
    for i, c in enumerate(counts):
        chunks.append(img_hidden[cursor:cursor + c])
        t = int(image_grid_thw_b[i, 0].item())
        h = int(image_grid_thw_b[i, 1].item())
        w = int(image_grid_thw_b[i, 2].item())
        # Qwen2.5-VL still images: t==1, post-merger spatial = (h//sms, w//sms).
        # If t > 1 (rare for our pipeline of single frames), we collapse t into
        # the "n_tok" sequence and re-derive spatial as h_post*w_post*t per image.
        # For our use case t=1 always β€” assert and proceed.
        if t != 1:
            raise RuntimeError(
                f"Unexpected image_grid_thw t={t} (>1). This pipeline assumes "
                f"per-frame image inputs, not video tensors."
            )
        h_post = h // spatial_merge_size
        w_post = w // spatial_merge_size
        shapes.append((h_post, w_post))
        cursor += c

    return chunks, shapes


def _extract_features_for_batch(
    model:              PolicyModel,
    inputs:             Dict[str, torch.Tensor],
    cache_mode:         str,
    spatial_merge_size: int,
    image_token_id:     int,
    n_frames:           int,
) -> Dict[str, torch.Tensor]:
    """
    Run one VLM forward and return (CPU, fp16 where appropriate) tensors
    for the requested cache_mode. All outputs have leading dim B.

    Returns dict with keys depending on cache_mode (see file header).
    """
    # Move tensors to device
    moved: Dict[str, torch.Tensor] = {}
    for k, v in inputs.items():
        if not isinstance(v, torch.Tensor):
            moved[k] = v
            continue
        if k == "pixel_values":
            moved[k] = v.to(model.device, dtype=model.sft.dtype, non_blocking=True)
        else:
            moved[k] = v.to(model.device, non_blocking=True)

    base = model.sft.get_base_model()
    core = getattr(base, "model", None)

    # Run base text+vision encoder; get last hidden state
    with autocast(device_type="cuda", dtype=model._amp_dtype, enabled=True):
        if core is not None:
            out = core(
                input_ids       = moved["input_ids"],
                attention_mask  = moved.get("attention_mask"),
                pixel_values    = moved.get("pixel_values"),
                image_grid_thw  = moved.get("image_grid_thw"),
                use_cache       = False,
                return_dict     = True,
            )
            hs = out.last_hidden_state if hasattr(out, "last_hidden_state") else out[0]
        else:
            out = base(
                input_ids       = moved["input_ids"],
                attention_mask  = moved.get("attention_mask"),
                pixel_values    = moved.get("pixel_values"),
                image_grid_thw  = moved.get("image_grid_thw"),
                use_cache       = False,
                return_dict     = True,
                output_hidden_states = True,
            )
            hs = out.hidden_states[-1]

        # TTA for downstream compatibility β€” uses the canonical pooled belief.
        belief_canon = model.sft.belief_aggregator(
            hs,
            moved.get("attention_mask"),
            moved.get("input_ids"),
        )
        # belief_aggregator may produce 2D for dual_pool β€” but we use the
        # ORIGINAL training strategy here (whatever the SFT ckpt has).  The
        # tta_head was trained against THAT strategy, so feed it the canonical.
        tta_mean, tta_logvar = model.sft.tta_head(belief_canon)

    tta_var = torch.exp(tta_logvar.float().clamp(-20.0, 20.0))
    tta_mean = tta_mean.float()

    B = hs.shape[0]
    D = hs.shape[-1]
    attn = moved.get("attention_mask")
    ids  = moved.get("input_ids")
    igt  = moved.get("image_grid_thw")    # [total_images_in_batch, 3]

    out_dict: Dict[str, torch.Tensor] = {
        "tta_means": tta_mean.detach().cpu(),
        "tta_vars":  tta_var.detach().cpu(),
    }

    # ── mean_pool (legacy) ────────────────────────────────────────────────────
    if cache_mode == "mean_pool":
        if attn is not None:
            m = attn.unsqueeze(-1).to(hs.dtype)
            beliefs = (hs * m).sum(dim=1) / m.sum(dim=1).clamp(min=1e-6)
        else:
            beliefs = hs.mean(dim=1)
        out_dict["beliefs"] = beliefs.detach().to(torch.float16).cpu()
        return out_dict

    # ── dual_pool (image-mean, text-mean) ─────────────────────────────────────
    if cache_mode == "dual_pool":
        is_img = (ids == image_token_id)
        if attn is not None:
            valid = attn > 0
            is_img = is_img & valid
            is_text = (~is_img) & valid
        else:
            is_text = ~is_img

        def _mm(mask_b: torch.Tensor) -> torch.Tensor:
            m = mask_b.unsqueeze(-1).to(hs.dtype)
            s = (hs * m).sum(dim=1)
            denom = m.sum(dim=1).clamp(min=1e-6)
            return s / denom

        b_img = _mm(is_img)
        b_txt = _mm(is_text)
        out_dict["beliefs_img"]  = b_img.detach().to(torch.float16).cpu()
        out_dict["beliefs_text"] = b_txt.detach().to(torch.float16).cpu()
        return out_dict

    # ── per_frame / spatial4x4 β€” both need per-image splitting ────────────────
    if cache_mode in ("per_frame", "spatial4x4"):
        if igt is None:
            raise RuntimeError(
                f"cache_mode={cache_mode} requires image_grid_thw, but the "
                f"processor did not emit it (no images in batch?)."
            )

        # We need to know which (sample, frame) slot each row of image_grid_thw
        # belongs to. The processor concatenates images in batch order; per
        # sample the count equals number of frames passed in. Recover via the
        # number of distinct image-token RUNS in that sample's input_ids.
        # Simpler & more robust: per sample count = number of PIL images we
        # passed.  But here we no longer have access to that; recover from
        # contiguous groups in input_ids.
        #
        # For Qwen2.5-VL each image's tokens form a contiguous run prefixed
        # and suffixed by special <|vision_start|>/<|vision_end|> tokens. We
        # only need image_token_id runs to count images per sample.

        igt_cursor = 0
        beliefs_frame: Optional[torch.Tensor] = None
        beliefs_grid:  Optional[torch.Tensor] = None
        if cache_mode == "per_frame":
            beliefs_frame = torch.zeros(B, n_frames, D, dtype=torch.float16)
        else:  # spatial4x4
            beliefs_grid = torch.zeros(B, n_frames, 16, D, dtype=torch.float16)
        valid_frames = torch.zeros(B, n_frames, dtype=torch.bool)
        beliefs_text = torch.zeros(B, D, dtype=torch.float16)

        for b in range(B):
            ids_b   = ids[b]
            attn_b  = attn[b] if attn is not None else torch.ones_like(ids_b)
            hs_b    = hs[b]

            # Count contiguous runs of image_token_id (= number of images in this sample)
            valid = attn_b > 0
            is_img_b = (ids_b == image_token_id) & valid
            # diff to find run boundaries
            x = is_img_b.to(torch.int8)
            diff = torch.cat([x.new_zeros(1), x[1:] - x[:-1]])
            n_runs = int((diff == 1).sum().item())

            if n_runs == 0:
                # No images for this sample β€” leave zeros, valid_frames stays False
                # Still compute text mean.
                m_text = valid.unsqueeze(-1).to(hs_b.dtype)
                t_mean = (hs_b * m_text).sum(dim=0) / m_text.sum(dim=0).clamp(min=1e-6)
                beliefs_text[b] = t_mean.detach().to(torch.float16).cpu()
                continue

            # Slice this sample's image_grid_thw rows
            igt_b = igt[igt_cursor:igt_cursor + n_runs]
            igt_cursor += n_runs

            chunks, shapes = _split_sample_visual_tokens(
                hs_b, ids_b, attn_b, igt_b,
                image_token_id, spatial_merge_size,
            )

            n_imgs_use = min(len(chunks), n_frames)
            for f in range(n_imgs_use):
                tok_f = chunks[f]
                h_post, w_post = shapes[f]
                if cache_mode == "per_frame":
                    pooled = tok_f.float().mean(dim=0).to(torch.float16)
                    beliefs_frame[b, f] = pooled.detach().cpu()
                else:  # spatial4x4
                    grid = _spatial_pool_image(tok_f, h_post, w_post, out_hw=4)
                    beliefs_grid[b, f] = grid.detach().to(torch.float16).cpu()
                valid_frames[b, f] = True

            # text mean (non-image valid tokens)
            is_text_b = (~is_img_b) & valid
            m_text = is_text_b.unsqueeze(-1).to(hs_b.dtype)
            denom = m_text.sum(dim=0).clamp(min=1e-6)
            t_mean = (hs_b * m_text).sum(dim=0) / denom
            beliefs_text[b] = t_mean.detach().to(torch.float16).cpu()

        if cache_mode == "per_frame":
            out_dict["beliefs_frame"] = beliefs_frame
        else:
            out_dict["beliefs_grid"]  = beliefs_grid
        out_dict["valid_frames"] = valid_frames
        out_dict["beliefs_text"] = beliefs_text
        return out_dict

    raise ValueError(f"Unknown cache_mode: {cache_mode}")


# ─────────────────────────────────────────────────────────────────────────────
# Cache builder
# ─────────────────────────────────────────────────────────────────────────────

def _flush_chunk(accumulators: Dict[str, List[torch.Tensor]],
                 chunk_dir: Path, chunk_idx: int) -> int:
    """Concat the in-memory batches and atomically save one chunk file.
    Returns number of samples in the chunk."""
    if not accumulators:
        return 0
    part = {k: torch.cat(v, dim=0) for k, v in accumulators.items()}
    n = next(iter(part.values())).shape[0]
    tmp = chunk_dir / f"chunk_{chunk_idx:05d}.pt.tmp"
    fin = chunk_dir / f"chunk_{chunk_idx:05d}.pt"
    torch.save(part, tmp)
    tmp.rename(fin)
    return int(n)


def _scan_chunks(chunk_dir: Path) -> Tuple[int, int]:
    """Return (n_chunks, n_samples_total) present on disk (sorted)."""
    if not chunk_dir.exists():
        return 0, 0
    files = sorted(chunk_dir.glob("chunk_*.pt"))
    # Drop stray .tmp
    for t in chunk_dir.glob("*.tmp"):
        t.unlink(missing_ok=True)
    n_samples = 0
    for f in files:
        try:
            d = torch.load(f, map_location="cpu", weights_only=True)
            n_samples += int(next(iter(d.values())).shape[0])
        except Exception as e:
            logger.warning(f"  [resume] chunk {f.name} unreadable ({e}); dropping")
            f.unlink(missing_ok=True)
    return len(list(chunk_dir.glob("chunk_*.pt"))), n_samples


def _merge_chunks(chunk_dir: Path) -> Dict[str, torch.Tensor]:
    """Load all chunks in order and concatenate into a single cache dict."""
    files = sorted(chunk_dir.glob("chunk_*.pt"))
    if not files:
        return {}
    acc: Dict[str, List[torch.Tensor]] = {}
    for f in files:
        d = torch.load(f, map_location="cpu", weights_only=True)
        for k, v in d.items():
            acc.setdefault(k, []).append(v)
    return {k: torch.cat(lst, dim=0) for k, lst in acc.items()}


@torch.no_grad()
def build_cache(
    model:              PolicyModel,
    loader:             DataLoader,
    split_name:         str,
    cache_mode:         str,
    spatial_merge_size: int,
    image_token_id:     int,
    n_frames:           int,
    chunk_dir:          Optional[Path] = None,
    chunk_size:         int = 200,
    expected_n:         Optional[int] = None,
) -> Dict[str, torch.Tensor]:
    """
    If chunk_dir is provided, save a chunk every `chunk_size` batches and resume
    by scanning existing chunks. `expected_n` is the total sample count (used to
    sanity-check resume alignment).
    """
    model.eval()
    batch_size = loader.batch_size or 1

    # ── Resume detection ────────────────────────────────────────────────────
    start_batch = 0
    chunk_idx = 0
    if chunk_dir is not None:
        chunk_dir.mkdir(parents=True, exist_ok=True)
        n_chunks, n_done = _scan_chunks(chunk_dir)
        if n_chunks > 0:
            # Each chunk (except possibly the last from a previous partial run)
            # contains `chunk_size * batch_size` samples. We skip exactly that
            # many batches so the DataLoader resumes at the next untouched one.
            start_batch = n_chunks * chunk_size
            chunk_idx = n_chunks
            logger.info(
                f"  [resume] found {n_chunks} chunk(s) with {n_done} samples; "
                f"skipping first {start_batch} batches"
            )
            if expected_n is not None and n_done >= expected_n:
                logger.info(f"  [resume] chunks already cover all {expected_n} "
                            f"samples; merging")
                return _merge_chunks(chunk_dir)

    accumulators: Dict[str, List[torch.Tensor]] = {}
    batches_since_flush = 0
    processed_batches = 0

    pbar = tqdm(loader, desc=f"cache[{cache_mode}]{split_name}", ncols=80, leave=True)
    for bi, batch in enumerate(pbar):
        if bi < start_batch:
            # Still need to let DataLoader workers produce the item (cheap β€” CPU
            # image load only β€” and keeps ordering deterministic).
            continue
        inputs = model._build_inputs(batch["images"], batch["metadata"])
        feats = _extract_features_for_batch(
            model, inputs, cache_mode,
            spatial_merge_size, image_token_id, n_frames,
        )
        for k, v in feats.items():
            accumulators.setdefault(k, []).append(v)
        batches_since_flush += 1
        processed_batches += 1

        if chunk_dir is not None and batches_since_flush >= chunk_size:
            n_flush = _flush_chunk(accumulators, chunk_dir, chunk_idx)
            pbar.set_postfix_str(f"chunk={chunk_idx} +{n_flush}")
            accumulators = {}
            batches_since_flush = 0
            chunk_idx += 1

    # Final partial chunk
    if chunk_dir is not None and accumulators:
        n_flush = _flush_chunk(accumulators, chunk_dir, chunk_idx)
        logger.info(f"  [chunk] final partial flushed (+{n_flush})")
        accumulators = {}
        chunk_idx += 1

    # ── Assemble final cache ────────────────────────────────────────────────
    if chunk_dir is not None:
        cache = _merge_chunks(chunk_dir)
    else:
        cache = {k: torch.cat(lst, dim=0) for k, lst in accumulators.items()}

    # NaN/Inf sanity
    for k, t in cache.items():
        if t.dtype.is_floating_point:
            n_nan = int(torch.isnan(t).sum().item())
            n_inf = int(torch.isinf(t).sum().item())
            if n_nan or n_inf:
                logger.warning(
                    f"  {split_name}/{k}: {n_nan} NaN, {n_inf} Inf "
                    f"(out of {t.numel()} elems)"
                )

    n = next(iter(cache.values())).shape[0]
    nbytes = sum(t.element_size() * t.numel() for t in cache.values())
    logger.info(
        f"  {split_name}: cached {n} samples  "
        f"keys={list(cache.keys())}  size={nbytes / 1e9:.2f} GB"
    )
    return cache


# ─────────────────────────────────────────────────────────────────────────────
# Main
# ─────────────────────────────────────────────────────────────────────────────

def main():
    ap = argparse.ArgumentParser("make_belief_cache_v2")
    ap.add_argument("--sft_checkpoint", default="checkpoints/SFT/sft_v2/best")
    ap.add_argument("--label_dir",      default="data/policy_labels")
    ap.add_argument("--out_dir",        default="data/belief_cache_v2")
    ap.add_argument("--cache_mode",     required=True,
                    choices=["mean_pool", "dual_pool", "per_frame", "spatial4x4"])
    ap.add_argument("--batch_size",     type=int, default=4,
                    help="Smaller for spatial4x4 (more GPU memory for hidden states)")
    ap.add_argument("--num_workers",    type=int, default=2)
    ap.add_argument("--splits",         nargs="+", default=["train", "val"])
    ap.add_argument("--split",          default=None,
                    help="Shortcut for a single split; overrides --splits when set")
    ap.add_argument("--manifest",       default=None,
                    help="Explicit manifest path; overrides label_dir/{split}.json")
    ap.add_argument("--out",            default=None,
                    help="Explicit output .pt path; overrides out_dir/cache_mode/{split}.pt")
    ap.add_argument("--n_frames",       type=int, default=MAX_FRAMES,
                    help="Number of frames per clip (8, 16, 24, ...)")
    ap.add_argument("--sampling",       default="original",
                    choices=["original", "uniform", "last_biased", "last_2s"],
                    help="Frame-index resampling scheme (cf. plan Stage K)")
    ap.add_argument("--source_filter",  default="all",
                    choices=["all", "nexar", "multisrc", "dada", "dad"],
                    help="Restrict samples to a data source (Stage K multi-source variants)")
    ap.add_argument("--debug",          action="store_true",
                    help="Smoke-test on 16 samples per split")
    ap.add_argument("--debug_samples",  type=int, default=16)
    ap.add_argument("--overwrite",      action="store_true")
    ap.add_argument("--chunk_size",     type=int, default=200,
                    help="Flush a chunk to disk every N batches (resume-safe). "
                         "0 disables chunked save.")
    ap.add_argument("--keep_chunks",    action="store_true",
                    help="Keep {out}.chunks/ dir after successful merge "
                         "(default: delete on success).")
    args = ap.parse_args()

    if args.split is not None:
        args.splits = [args.split]

    odir = Path(args.out_dir) / args.cache_mode
    odir.mkdir(parents=True, exist_ok=True)

    # Monkey-patch module-level MAX_FRAMES so _extract_features_for_batch sees it
    # (per_frame / spatial4x4 preallocate buffers based on this).
    import training.Policy.policy_dataset as pds
    pds.MAX_FRAMES = args.n_frames

    logger.info("Loading SFTModel (frozen) for feature extraction...")
    model = PolicyModel(args.sft_checkpoint, use_bf16=True)
    sms = _get_spatial_merge_size(model)
    img_tok_id = model.sft.belief_aggregator.image_token_id
    if img_tok_id is None:
        img_tok_id = 151655
    logger.info(f"  spatial_merge_size = {sms}")
    logger.info(f"  image_token_id     = {img_tok_id}")
    logger.info(f"  hidden_dim         = {model.hidden_dim}")
    logger.info(f"  cache_mode         = {args.cache_mode}")
    logger.info(f"  n_frames           = {args.n_frames}")
    logger.info(f"  sampling           = {args.sampling}")
    logger.info(f"  source_filter      = {args.source_filter}")

    for split in args.splits:
        if args.manifest is not None:
            label_path = Path(args.manifest)
        else:
            label_path = Path(args.label_dir) / f"{split}.json"
        if not label_path.exists():
            logger.warning(f"  {label_path} not found β€” skipping {split}")
            continue

        if args.out is not None:
            out_path = Path(args.out)
            out_path.parent.mkdir(parents=True, exist_ok=True)
        else:
            out_path = odir / f"{split}.pt"
        if out_path.exists() and not args.overwrite:
            logger.info(f"  Cache exists: {out_path} β€” skip (use --overwrite to rebuild)")
            continue

        ds = PolicyDataset(
            manifests     = [label_path],
            split         = split,
            debug         = args.debug,
            debug_samples = args.debug_samples,
            n_frames      = args.n_frames,
            sampling      = args.sampling,
            source_filter = args.source_filter,
        )
        if len(ds) == 0:
            logger.warning(f"  {split}: dataset empty after filtering β€” skipping")
            continue
        loader = DataLoader(
            ds,
            batch_size  = args.batch_size,
            shuffle     = False,
            num_workers = args.num_workers,
            collate_fn  = policy_collate_fn,
            pin_memory  = True,
        )

        chunk_dir = None
        if args.chunk_size > 0:
            chunk_dir = out_path.parent / (out_path.stem + ".chunks")
        cache = build_cache(
            model, loader, split,
            args.cache_mode, sms, img_tok_id, args.n_frames,
            chunk_dir=chunk_dir,
            chunk_size=args.chunk_size,
            expected_n=len(ds),
        )

        # Preserve sample IDs / labels in meta for downstream alignment
        ids = [s.get("video_id") for s in ds.samples]
        labels = [int(s.get("action_label", -1)) for s in ds.samples]

        meta = {
            "schema_version": SCHEMA_VERSION,
            "cache_mode":     args.cache_mode,
            "hidden_dim":     model.hidden_dim,
            "n_frames":       args.n_frames,
            "sampling":       args.sampling,
            "source_filter":  args.source_filter,
            "n_samples":      int(next(iter(cache.values())).shape[0]),
            "spatial_merge_size": sms,
            "image_token_id": int(img_tok_id),
            "sft_checkpoint": str(args.sft_checkpoint),
            "label_path":     str(label_path),
            "ids":            ids,
            "action_labels":  labels,
        }
        cache_to_save = {k: v for k, v in cache.items() if k != "__meta__"}
        cache_to_save["meta"] = meta

        tmp_path = out_path.with_suffix(out_path.suffix + ".tmp")
        torch.save(cache_to_save, tmp_path)
        tmp_path.rename(out_path)
        logger.info(f"  Saved β†’ {out_path}")
        with open(out_path.with_suffix(".meta.json"), "w") as f:
            meta_slim = {k: v for k, v in meta.items()
                         if k not in ("ids", "action_labels")}
            meta_slim["n_ids"] = len(ids)
            json.dump(meta_slim, f, indent=2)

        if chunk_dir is not None and chunk_dir.exists() and not args.keep_chunks:
            import shutil
            shutil.rmtree(chunk_dir)
            logger.info(f"  Removed chunk dir {chunk_dir}")

    logger.info("\nbelief_cache_v2 complete.")


if __name__ == "__main__":
    main()