File size: 43,439 Bytes
5cb93ca
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
"""Cut from amos/decoder.py: only the definitions amos.serve reaches (see sources.json)."""
import argparse
import glob
import hashlib
import itertools
import json
import math
import os
import random
import shutil
import string
import time
from pathlib import Path
import torch

ANSWER = "<answer>"

IMAGE_PAD = "<|image_pad|>"

LETTERS = string.ascii_uppercase + string.ascii_lowercase  # the first 52 option codes; runs before amos_codes use these alone

KINDS = ("noul", "choice", "score")  # answered by option letters

HEADED = ("span", "region")  # answered by Heads

SPAN_LENGTH_PENALTY = 1.6

SPAN_CAP = 30

HINTS = {"score": " (levels from lowest to highest)", "span": " (quote the case)", "region": " (point to it in the image)"}

IMAGE_PIXELS = int(os.environ.get("AMOS_IMAGE_PIXELS", 1 << 20))

HEADS = "heads.pt"

REGION_KEEP = 0.5  # a point's box: cells joined to the peak holding this share of its probability (vision.peak_box)

SYSTEM = ("You make decisions about a case. Read the case, then answer every question with the letter of the option "
          "that is most likely right.")

def options(q: dict) -> list[str]:
    """Option lines in answer order; yes/no is yes first, so its target is [P(yes), P(no)]."""
    if q["type"] == "noul":
        c = q.get("criteria") or {}
        return [f"yes: {c['true']}" if c.get("true") else "yes", f"no: {c['false']}" if c.get("false") else "no"]
    return list(q["options"])

def render(state: str, questions: list[dict], images=(), codes=LETTERS) -> list[dict]:
    """Chat messages for a case. Images (the state's after "Case:", image options where they are listed, a pick-any
    option's image after its question) make the user message a list of text and image items; without any it is plain
    text."""
    parts = ["Case:\n", *images, f"{state}\n\nQuestions:\n"]
    for i, q in enumerate(questions, start=1):
        parts += ["\n" if i > 1 else "", f"q{i}. {q['text']}" + HINTS.get(q["type"], "")]
        if "subject" in q:
            parts += [" ", q["subject"]]
        for j, o in enumerate(options(q) if q["type"] in KINDS else ()):
            parts += [f"\n{codes[j]}) ", o]
    merged = []
    for x in parts:
        if isinstance(x, str) and merged and isinstance(merged[-1], str):
            merged[-1] += x
        else:
            merged.append(x)
    user = merged[0] if len(merged) == 1 else [{"type": "text", "text": x} if isinstance(x, str) else {"type": "image", "image": x}
                                               for x in merged if x != ""]
    answers = "\n".join(f"q{i}: {ANSWER}" for i in range(1, len(questions) + 1))
    reply = {"role": "assistant", "content": answers}
    if len(questions) == 1 and questions[0].get("reasoning"):  # a trace goes in the thinking block, as think() reads
        reply["reasoning_content"] = questions[0]["reasoning"]
    return [{"role": "system", "content": SYSTEM}, {"role": "user", "content": user}, reply]

def expand(questions: list[dict]) -> tuple[list[dict], list[int]]:
    """Pick-any questions become one yes/no per option ("subject") in the same prompt, each with its own answer ->
    (questions, index of each one's original question)."""
    flat, owner = [], []
    for k, q in enumerate(questions):
        if q["type"] != "multi":
            flat.append(q)
            owner.append(k)
            continue
        for j, o in enumerate(q["options"]):
            flat.append({"type": "noul", "text": q["text"] + " Does this option apply?", "subject": o,
                         **({"target": q["target"][j]} if "target" in q else {})})
            owner.append(k)
    return flat, owner

PLAIN_TEMPLATE = ("{% for m in messages %}{{ m['role'] }}:\n{% if m['content'] is string %}{{ m['content'] }}{% else %}"
                  "{% for x in m['content'] %}{{ x['text'] if x['type'] == 'text' else '' }}{% endfor %}{% endif %}"
                  "\n\n{% endfor %}")

def option_codes(tokenizer, n: int) -> list[str]:
    """The first n option codes: the 52 letters, then two-letter codes the tokenizer keeps as one token."""
    two = ("".join(p) for p in itertools.product(string.ascii_uppercase, repeat=2))
    codes = list(LETTERS)[:n] + [c for c in two if len(tokenizer.encode(c, add_special_tokens=False)) == 1][:max(0, n - 52)]
    if len(codes) != n:
        raise ValueError(f"the tokenizer has only {len(codes)} one-token option codes, not {n}")
    return codes

class Codec:
    def __init__(self, tokenizer, image_processor=None, n_codes: int = len(LETTERS)):
        self.tok, self.image_processor = tokenizer, image_processor
        if getattr(tokenizer, "chat_template", "") is None:
            tokenizer.chat_template = PLAIN_TEMPLATE
        if ANSWER not in tokenizer.get_added_vocab():
            tokenizer.add_special_tokens({"additional_special_tokens": [ANSWER]})
        self.marker = tokenizer.convert_tokens_to_ids(ANSWER)
        self.codes = option_codes(tokenizer, n_codes)
        ids = [tokenizer.encode(x, add_special_tokens=False) for x in self.codes]
        if any(len(i) != 1 for i in ids):
            raise ValueError("every option letter must be one token")
        self.letters = [i[0] for i in ids]
        self.pad = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else tokenizer.eos_token_id
        # A unified model (Gemma 4) comes with its whole processor, which turns each image placeholder into its own run
        # of tokens; Qwen comes with an image processor alone, and the placeholder is expanded here
        self.unified = image_processor is not None and hasattr(image_processor, "image_token")
        pad = image_processor.image_token if self.unified else IMAGE_PAD
        self.image_pad = tokenizer.convert_tokens_to_ids(pad) if image_processor is not None else None
        self.echo = False  # set by load_heads when the quote head was trained on an echoed case (see encode_case)
        # the tokenizer's added tokens (the answer marker, chat roles, <think>, image placeholders) as user text would
        # tokenize as the tokens themselves and break the prompt; defuse() swaps their "<" for a look-alike
        self.specials = sorted((t for t in tokenizer.get_added_vocab() if t.startswith("<")), key=len, reverse=True)

    def defuse(self, text):
        """User text with any added-token string made ordinary text; same length, so character offsets still hold."""
        if not isinstance(text, str) or "<" not in text:
            return text
        for t in self.specials:
            text = text.replace(t, "‹" + t[1:])
        return text

    def safe(self, state: str, questions: list[dict]) -> tuple[str, list[dict]]:
        """state and questions with every user-written string defused (images and other values pass through)."""
        d = self.defuse
        qs = [{**q, **{k: d(q[k]) for k in ("text", "subject") if k in q},
               **({"options": [d(o) for o in q["options"]]} if "options" in q else {}),
               **({"criteria": {k: d(v) for k, v in q["criteria"].items()}} if isinstance(q.get("criteria"), dict) else {})}
              for q in questions]
        return d(state), qs

    def encode(self, state: str, questions: list[dict]) -> tuple[list[int], list[int]]:
        """-> (token ids, the position whose next-token logits answer each question)."""
        state, questions = self.safe(state, questions)
        text = self.tok.apply_chat_template(render(state, questions, codes=self.codes), tokenize=False,
                                            enable_thinking=False)
        ids = self.tok(text, add_special_tokens=False)["input_ids"]
        at = [i - 1 for i, t in enumerate(ids) if t == self.marker]
        if len(at) != len(questions):
            raise ValueError("one answer marker per question")
        return ids, at

    def reason_span(self, ids: list[int]) -> tuple[int, int]:
        """The tokens inside the thinking block, (first, end): the trace a reasoning record teaches the model to write."""
        start, end = (self.tok.convert_tokens_to_ids(t) for t in ("<think>", "</think>"))
        return ids.index(start) + 1, ids.index(end)

    def encode_case(self, state: str, questions: list[dict], images=()) -> tuple[list[int], list[int], dict]:
        """-> (token ids, answer positions, extra); without images the ids are exactly encode()'s. extra["state"] is
        (first state token, each state token's char offsets in the state). With images extra also holds the
        image processor's pixel_values and image_grid_thw, and "images": (first token, rows, cols) of each image's
        merged token grid; each image placeholder is expanded to its token count, as the model's processor does."""
        state, questions = self.safe(state, questions)
        messages = render(state, questions, images, self.codes)
        user = messages[1]["content"]
        pics = [] if isinstance(user, str) else [x["image"] for x in user if x["type"] == "image"]
        text = self.tok.apply_chat_template(messages, tokenize=False, enable_thinking=False)
        enc = self.tok(text, add_special_tokens=False, return_offsets_mapping=True)
        ids, spans = enc["input_ids"], enc["offset_mapping"]
        # an empty state has no text to find (and Gemma's template trims the newlines that would surround it)
        s0 = text.index(state + "\n\nQuestions:\n") if state else 0
        s1 = s0 + len(state)
        toks = [i for i, (a, b) in enumerate(spans) if a < s1 and b > s0]
        offsets = [(max(spans[i][0], s0) - s0, min(spans[i][1], s1) - s0) for i in toks]
        where, extra = list(range(len(ids))), {}
        if pics and self.unified:
            ids, where, extra = self._unified_images(text, ids, pics)
        elif pics:
            if self.image_processor is None:
                raise ValueError("this model has no vision tower")
            ip = self.image_processor
            v = ip(images=[p.convert("RGB") for p in pics], max_pixels=IMAGE_PIXELS, return_tensors="pt")
            grid, m = v["image_grid_thw"], ip.merge_size
            counts, out, where, starts = iter((grid.prod(-1) // m**2).tolist()), [], [], []
            for t in ids:
                where.append(len(out))
                if t == self.image_pad:
                    starts.append(len(out))
                    out += [t] * next(counts)
                else:
                    out.append(t)
            if len(starts) != len(pics):
                raise ValueError("one image placeholder per image")
            ids = out
            extra = {"pixel_values": v["pixel_values"], "image_grid_thw": grid,
                     "images": [(s, int(g[1]) // m, int(g[2]) // m) for s, g in zip(starts, grid)]}
        extra["state"] = (where[toks[0]] if toks else 0, offsets)
        at = [i - 1 for i, t in enumerate(ids) if t == self.marker]
        if len(at) != len(questions):
            raise ValueError("one answer marker per question")
        if self.echo and toks and any(q["type"] == "span" for q in questions):
            # Echo: the case again after every answer marker, and quotes are read from this copy. Reading left to
            # right, a case token cannot see what follows it ("Total due" after a line item), but every copied token
            # has seen the whole case and each question. Attention only looks back, so no earlier answer changes.
            sep = self.tok("\n\nThe case again:\n", add_special_tokens=False)["input_ids"]
            copy = self.tok(state, add_special_tokens=False, return_offsets_mapping=True)
            extra["state"] = (len(ids) + len(sep), [tuple(o) for o in copy["offset_mapping"]])
            ids = ids + sep + copy["input_ids"]
        return ids, at, extra

    def _unified_images(self, text: str, ids: list[int], pics) -> tuple[list[int], list[int], dict]:
        """A unified model's processor expands each image placeholder into <|image>, its image tokens, <image|>; ->
        (expanded ids, each original token's new position, the model's image inputs). The images have no separate
        grid of merged tokens here, so "images" is empty and the point and box heads do not apply."""
        ip = self.image_processor
        v = ip(text=text, images=[p.convert("RGB") for p in pics], return_tensors="pt", add_special_tokens=False)
        new = v["input_ids"][0].tolist()
        run = {self.image_pad, self.tok.convert_tokens_to_ids(ip.boi_token), self.tok.convert_tokens_to_ids(ip.eoi_token)}
        where, j = [], 0
        for t in ids:
            where.append(j)
            if t == self.image_pad:
                while j < len(new) and new[j] in run:
                    j += 1
            elif j < len(new) and new[j] == t:
                j += 1
            else:
                raise ValueError("the processor changed a text token")
        if j != len(new):
            raise ValueError("the processor added tokens after the text")
        return new, where, {"pixel_values": v["pixel_values"], "image_position_ids": v["image_position_ids"],
                            "mm_token_type_ids": v["mm_token_type_ids"][0], "images": []}

def load_model(path: str, device: str, *, dtype=None, vision=None):
    """-> (model, codec). vision=None loads the image-text model when the checkpoint has a vision tower and an image
    processor config beside it, else the text model alone. Qwen's image processor runs on PIL, never torchvision
    (not installed, and the Auto classes demand it), so training and serving resize alike on every machine."""
    from transformers import (
        AutoConfig,
        AutoModelForCausalLM,
        AutoModelForImageTextToText,
        AutoTokenizer,
        Qwen2VLImageProcessorPil,
    )

    if device == "cuda":  # as in amos.train: cuDNN attention fails to load in bf16 with the pip CUDA wheels
        torch.backends.cuda.enable_cudnn_sdp(False)

    tok = AutoTokenizer.from_pretrained(path)
    config = AutoConfig.from_pretrained(path)
    unified = config.model_type.startswith("gemma4")  # images through its own processor (which needs torchvision)
    if vision is None:
        vision = hasattr(config, "vision_config") and (Path(path) / ("processor_config.json" if unified else
                                                                       "preprocessor_config.json")).exists()
    processor = None
    if vision:
        from transformers import AutoProcessor

        processor = AutoProcessor.from_pretrained(path) if unified else Qwen2VLImageProcessorPil.from_pretrained(path)
    codec = Codec(tok, processor, getattr(config, "amos_codes", len(LETTERS)))
    if dtype is None:
        dtype = torch.float32 if getattr(config, "amos_export_dtype", None) == "float32" else torch.bfloat16
    from transformers.models.auto.modeling_auto import MODEL_FOR_CAUSAL_LM_MAPPING_NAMES

    # some image-text models (Muse Glimmer) have no causal-LM class: load the whole model, its vision tower unused
    causal = config.model_type in MODEL_FOR_CAUSAL_LM_MAPPING_NAMES
    model = (AutoModelForImageTextToText if vision or not causal else AutoModelForCausalLM).from_pretrained(
        path, config=config, dtype=dtype)
    if vision and not unified:
        patchify_as_matmul(model.model.visual.patch_embed)
    if model.get_input_embeddings().weight.shape[0] < len(tok):
        model.resize_token_embeddings(len(tok), mean_resizing=True)
    return model.to(device), codec

def patchify_as_matmul(patch_embed):
    """Qwen's patch embedding is a conv3d whose kernel is the whole flattened patch, so it is one matmul with the same
    weights; run it as one. cuDNN's conv3d fails to load on some hosts and PyTorch's own takes ~5 s per photo."""
    proj = patch_embed.proj
    patch_embed.forward = lambda x: torch.nn.functional.linear(x.to(proj.weight.dtype), proj.weight.flatten(1), proj.bias)

def noncausal(model) -> bool:
    """Whether the run was trained with bidirectional full attention (train --noncausal), so it must be read that way."""
    return bool(getattr(model.config, "amos_noncausal", False))

def bidirectional_mask(lengths: list[int], width: int, device) -> torch.Tensor:
    """[B, 1, L, L], True where the key is one of its row's real tokens: every query sees the whole case, never pads."""
    keys = torch.arange(width, device=device) < torch.tensor(lengths, device=device)[:, None]
    return keys[:, None, None, :].expand(-1, 1, width, -1)

PREFILL_CHUNK = 16384

def chunked_hidden(decoder, x: torch.Tensor) -> torch.Tensor:
    """Last hidden states [1, L, d] for one causal text row, read PREFILL_CHUNK tokens at a time."""
    from transformers import DynamicCache

    cache, out = DynamicCache(config=decoder.config), []
    for s in range(0, x.shape[1], PREFILL_CHUNK):
        out.append(decoder(input_ids=x[:, s : s + PREFILL_CHUNK], past_key_values=cache, use_cache=True).last_hidden_state)
    del cache
    return torch.cat(out, 1)

def layerwise_hidden(decoder, x: torch.Tensor) -> torch.Tensor:
    """Last hidden states [1, L, d] for one noncausal text row, read a layer at a time.

    A noncausal run's full-attention layers let every token see the whole case, so it cannot be read in chunks the way
    a causal one can. Instead the whole case goes through each layer before the next, PREFILL_CHUNK tokens at a time:
    a linear-attention layer is a forward recurrence, so its chunks carry its conv and recurrent state; a full-attention
    layer first takes keys and values for every token, then each chunk of queries attends to all of them. The same
    computation as one pass, holding one layer's activations for one chunk instead of every layer's for the whole case."""
    import torch.nn.functional as F
    from transformers import DynamicCache
    from transformers.models.qwen3_5.modeling_qwen3_5 import apply_rotary_pos_emb

    n, C = x.shape[1], PREFILL_CHUNK
    h = decoder.embed_tokens(x)
    pos = torch.arange(n, device=x.device).view(1, 1, -1).expand(4, 1, -1)
    cos, sin = decoder.rotary_emb(h, pos[1:])
    text_pos, cache = pos[0], DynamicCache(config=decoder.config)
    for i, layer in enumerate(decoder.layers[: decoder.config.num_hidden_layers]):
        if decoder.config.layer_types[i] == "linear_attention":
            for s in range(0, n, C):  # each chunk is overwritten after it is read; later chunks are still this layer's input
                h[:, s : s + C] = layer(h[:, s : s + C], position_embeddings=(cos[:, s : s + C], sin[:, s : s + C]),
                                        attention_mask=None, position_ids=text_pos[:, s : s + C], past_key_values=cache)
            continue
        at = layer.self_attn
        keys, values = [], []
        for s in range(0, n, C):
            hn = layer.input_layernorm(h[:, s : s + C])
            shape = (*hn.shape[:-1], -1, at.head_dim)
            k = at.k_norm(at.k_proj(hn).view(shape)).transpose(1, 2)
            _, k = apply_rotary_pos_emb(k, k, cos[:, s : s + C], sin[:, s : s + C])
            keys.append(k)
            values.append(at.v_proj(hn).view(shape).transpose(1, 2))
        keys, values = torch.cat(keys, 2), torch.cat(values, 2)
        for s in range(0, n, C):
            res = h[:, s : s + C]
            hn = layer.input_layernorm(res)
            shape = hn.shape[:-1]
            q, gate = torch.chunk(at.q_proj(hn).view(*shape, -1, at.head_dim * 2), 2, dim=-1)
            q = at.q_norm(q).transpose(1, 2)
            q, _ = apply_rotary_pos_emb(q, q, cos[:, s : s + C], sin[:, s : s + C])
            o = F.scaled_dot_product_attention(q, keys, values, scale=at.scaling, enable_gqa=True)
            o = at.o_proj(o.transpose(1, 2).reshape(*shape, -1) * torch.sigmoid(gate.reshape(*shape, -1)))
            mid = res + o
            h[:, s : s + C] = mid + layer.mlp(layer.post_attention_layernorm(mid))
        del keys, values
    del cache
    for s in range(0, n, C):
        h[:, s : s + C] = decoder.norm(h[:, s : s + C])
    return h

def hidden(model, codec, batch: list[tuple]) -> torch.Tensor:
    """Last hidden states [B, L, d] for a batch of (ids, answer positions, questions[, extra]).

    Sequences are right-padded without a mask: the model is causal (its linear-attention layers are recurrences that
    only run forward), so padding after a sequence cannot change anything read from inside it. A noncausal run's
    full-attention layers see the whole sequence, so they get a mask hiding the pads; the recurrences stay causal.
    Entries with images go through the image-text model, which fills image tokens from the vision tower and gives
    them 2-D positions."""
    width = max(len(e[0]) for e in batch)
    x = torch.full((len(batch), width), codec.pad, dtype=torch.long, device=model.device)
    for b, e in enumerate(batch):
        x[b, : len(e[0])] = torch.tensor(e[0], device=model.device)
    vis = [e[3] for e in batch if len(e) > 3 and "pixel_values" in e[3]]
    kw = {} if not noncausal(model) else {"attention_mask": {
        "full_attention": bidirectional_mask([len(e[0]) for e in batch], width, x.device), "linear_attention": None}}
    if not vis and len(batch) == 1 and width > PREFILL_CHUNK:
        return layerwise_hidden(model.get_decoder(), x) if kw else chunked_hidden(model.get_decoder(), x)
    if not vis:
        return model.get_decoder()(input_ids=x, **kw).last_hidden_state
    if codec.unified and kw:
        raise ValueError("noncausal attention is built for Qwen3.5's layer types, not Gemma 4's")
    if codec.unified:  # Gemma 4: image patches go through the same layers, placed by their token types and positions
        types = torch.zeros_like(x)
        for b, e in enumerate(batch):
            if len(e) > 3 and "mm_token_type_ids" in e[3]:
                types[b, : len(e[3]["mm_token_type_ids"])] = e[3]["mm_token_type_ids"].to(x.device)
        return model.model(input_ids=x, mm_token_type_ids=types,
                           pixel_values=torch.cat([v["pixel_values"] for v in vis]).to(model.device, model.dtype),
                           image_position_ids=torch.cat([v["image_position_ids"] for v in vis]).to(model.device)
                           ).last_hidden_state
    grid, types = torch.cat([v["image_grid_thw"] for v in vis]).to(model.device), (x == codec.image_pad).int()
    if kw:  # its rope index would read a dict mask as per-row padding, so the positions come unmasked, as causal
        kw["position_ids"] = model.model.get_rope_index(x, types, image_grid_thw=grid)[0]
    return model.model(input_ids=x, pixel_values=torch.cat([v["pixel_values"] for v in vis]).to(model.device),
                       image_grid_thw=grid, mm_token_type_ids=types, **kw).last_hidden_state

def finish_logits(model, logits: torch.Tensor) -> torch.Tensor:
    """The model's own last step from raw output-layer logits, as its forward would apply it: Granite divides by
    logits_scaling, Gemma caps with final_logit_softcapping. Qwen has neither, so its logits pass unchanged."""
    cfg = getattr(model.config, "text_config", None) or model.config
    if scaling := getattr(cfg, "logits_scaling", None):
        logits = logits / scaling
    if cap := getattr(cfg, "final_logit_softcapping", None):
        logits = cap * torch.tanh(logits / cap)
    return logits

def distributions(model, codec, batch: list[tuple], h: torch.Tensor | None = None) -> list[torch.Tensor]:
    """Log-probabilities over the option letters of each yes/no, choice and score question in a batch of (ids, answer
    positions, questions[, extra]), in order. Only the 52 letter logits are computed, from the tied output
    embeddings."""
    h = hidden(model, codec, batch) if h is None else h
    picks = [(b, p, q) for b, e in enumerate(batch) for p, q in zip(e[1], e[2]) if q["type"] in KINDS]
    if not picks:
        return []
    rows = torch.tensor([b for b, _, _ in picks], device=h.device)
    cols = torch.tensor([p for _, p, _ in picks], device=h.device)
    logits = finish_logits(model, h[rows, cols].float() @ model.get_output_embeddings().weight[codec.letters].float().T)
    return [logits[k, : len(options(q))].log_softmax(-1) for k, (_, _, q) in enumerate(picks)]

class BoxHead(torch.nn.Module):
    """A point's box, predicted rather than grown from cells: reads the question's vector, the image features pooled
    by the point head's own cell distribution, and that distribution's centre and spread, and moves the centre and
    sets the size. Cells alone draw a box around only the few cells the point head is sure of (a car's tail light
    rather than the car); this head is trained on overlap with the whole object."""

    def __init__(self, d: int, width: int = 512):
        super().__init__()
        self.net = torch.nn.Sequential(torch.nn.Linear(2 * d + 4, width), torch.nn.GELU(), torch.nn.Linear(width, 4))
        torch.nn.init.zeros_(self.net[-1].weight)  # starts as a half-image box on the cells' centre
        torch.nn.init.zeros_(self.net[-1].bias)

    def forward(self, hq: torch.Tensor, cells: torch.Tensor, logits: torch.Tensor, rows: int, cols: int) -> torch.Tensor:
        p = logits.detach().float().softmax(-1)  # the point head stays as it is; only the box learns
        ys, xs = torch.meshgrid((torch.arange(rows, device=p.device) + 0.5) / rows,
                                (torch.arange(cols, device=p.device) + 0.5) / cols, indexing="ij")
        xs, ys = xs.flatten(), ys.flatten()
        mx, my = (p * xs).sum(), (p * ys).sum()
        sx, sy = (p * (xs - mx) ** 2).sum().sqrt(), (p * (ys - my) ** 2).sum().sqrt()
        pooled = (p[:, None] * cells.float()).sum(0)
        o = self.net(torch.cat([hq.float(), pooled, torch.stack([mx, my, sx, sy])]).to(self.net[0].weight.dtype)).float()
        cx, cy = (mx + 0.5 * torch.tanh(o[0])).clamp(0, 1), (my + 0.5 * torch.tanh(o[1])).clamp(0, 1)
        w, hgt = torch.sigmoid(o[2]), torch.sigmoid(o[3])
        return torch.stack([cx - w / 2, cy - hgt / 2, cx + w / 2, cy + hgt / 2]).clamp(0, 1)

class Heads(torch.nn.Module):
    """Quote and point heads, as amos.model has them: a question's answer-position vector scored against the state's
    tokens (start and end of a quote) or against the first state image's tokens (one cell each); `keep` is the
    region readout's share (decode_head). With a BoxHead, a region's logits carry four more values: its box."""

    def __init__(self, d: int, kinds=HEADED, keep: float = REGION_KEEP, box: bool = False):
        super().__init__()
        self.kinds, self.keep = list(kinds), keep
        self.span = (SPAN_CAP, SPAN_LENGTH_PENALTY)  # the quote decoder's cap and penalty; serving sets the run's own
        self.span_q, self.span_k = torch.nn.Linear(d, 2 * d), torch.nn.Linear(d, 2 * d)
        self.region_q, self.region_k = torch.nn.Linear(d, d), torch.nn.Linear(d, d)
        for m in (self.span_q, self.span_k, self.region_q, self.region_k):
            torch.nn.init.normal_(m.weight, std=0.02)
            torch.nn.init.zeros_(m.bias)
        self.box = BoxHead(d) if box else None

    def forward(self, h: torch.Tensor, batch: list[tuple]) -> list[torch.Tensor]:
        """Logits of each span ([2, state tokens]) and region ([cells], then the 4 box edges when there is a box head)
        question in the batch, in order."""
        h, d, out = h.to(self.span_q.weight.dtype), h.shape[-1], []
        for b, e in enumerate(batch):
            for p, q in zip(e[1], e[2]):
                if q["type"] == "span":
                    first, offsets = e[3]["state"]
                    k = self.span_k(h[b, first : first + len(offsets)]).view(-1, 2, d)
                    out.append(torch.einsum("cd,lcd->cl", self.span_q(h[b, p]).view(2, d), k) / math.sqrt(d))
                elif q["type"] == "region":
                    if not e[3].get("images"):
                        raise ValueError("a region question needs a state image")
                    start, rows, cols = e[3]["images"][0]
                    cells = h[b, start : start + rows * cols]
                    x = self.region_k(cells) @ self.region_q(h[b, p]) / math.sqrt(d)
                    if self.box is not None:
                        x = torch.cat([x, self.box(h[b, p], cells, x, rows, cols).to(x.dtype)])
                    out.append(x)
        return out

def decode_head(q: dict, x: torch.Tensor, extra: dict, state: str, keep: float = REGION_KEEP,
                span: tuple[int, float] = (SPAN_CAP, SPAN_LENGTH_PENALTY)) -> dict:
    """A span or region result in amos.model.decode's format; a quote is always cut from the state, at most span[0]
    tokens long, each token past the start scaled by exp(-span[1]). A point is the most likely cell and the box of the
    cells around it holding `keep` of its probability, with the box's mass."""
    from types import SimpleNamespace

    from .model import decode_span
    from .vision import peak_box

    x = x.float().cpu()
    if q["type"] == "span":
        x = torch.cat([x.new_full((2, 1), -torch.inf), x], 1)  # decode_span skips a leading [CLS] slot
        a, z, _, p = decode_span(x[0], x[1], SimpleNamespace(offsets=extra["state"][1], state=state), span[1], span[0])
        # Qwen fuses a JSON string's closing quote with the last word ('detail."', 'approves.",'), so a quote cut at
        # token edges can end (or start) on an unmatched quote mark; drop it and what follows it
        while state[a:z].count('"') % 2:
            if state[z - 1] in '",}]':
                z -= 1
            elif state[a] in '"{[':
                a += 1
            else:
                break
        t = state[a:z]
        a, z = a + len(t) - len(t.lstrip()), z - (len(t) - len(t.rstrip()))
        return {"type": "span", "start": a, "end": max(a, z), "text": state[a : max(a, z)], "p": p}
    _, rows, cols = extra["images"][0]
    cells = x[: rows * cols].softmax(-1)
    if len(x) > rows * cols:  # a box head's box; its mass is the cells it covers
        box = [round(v, 4) for v in x[rows * cols :].tolist()]
        r0, r1 = int(box[1] * rows), max(int(box[1] * rows) + 1, math.ceil(box[3] * rows))
        c0, c1 = int(box[0] * cols), max(int(box[0] * cols) + 1, math.ceil(box[2] * cols))
        return {"type": "region", "cell": int(cells.argmax()), "box": box,
                "p": float(cells.view(rows, cols)[r0:r1, c0:c1].sum())}
    box, p = peak_box(cells, rows, cols, keep)
    return {"type": "region", "cell": int(cells.argmax()), "box": box, "p": p}

def load_heads(path, model, codec: Codec | None = None) -> Heads | None:
    """The trained heads saved beside a checkpoint, or None. A quote head trained on echoed cases switches the codec's
    echo on, since it reads nothing else."""
    f = Path(path) / HEADS
    if not f.exists():
        return None
    saved = torch.load(f, map_location="cpu", weights_only=True)
    heads = Heads(model.get_input_embeddings().weight.shape[1], saved["kinds"], saved.get("keep", REGION_KEEP),
                  box=any(k.startswith("box.") for k in saved["state"]))
    heads.load_state_dict(saved["state"])
    if codec is not None:
        codec.echo = bool(saved.get("echo", False))
    return heads.to(model.device).eval()

def tempered(lp: torch.Tensor, t: float | None = None) -> list[float]:
    """Probabilities from log-probabilities, sharpened (t < 1) or softened (t > 1) by a fitted temperature."""
    return (lp / t).softmax(-1).tolist() if t else lp.exp().tolist()

def rotate(q: dict, r: int) -> dict:
    """A choice question with its options rotated r places; anything else as it is (a scale's order is its meaning)."""
    if q["type"] != "choice" or not r:
        return q
    s = r % len(q["options"])
    return {**q, "options": q["options"][s:] + q["options"][:s]}

def read(model, codec, state: str, questions: list[dict], images=(), max_len: int | None = None,
         temperature: dict | None = None):
    """-> (option distributions for letter-answered questions, input tokens), one forward pass."""
    ids, at, extra = codec.encode_case(state, questions, images)
    if max_len and len(ids) > max_len:
        raise ValueError(f"maximum context length: request has {len(ids)} tokens, limit {max_len}")
    entry = [(ids, at, questions, extra)]
    t = temperature or {}
    picked = [q for q in questions if q["type"] in KINDS]
    return [tempered(lp, t.get(q["type"])) for lp, q in
            zip(distributions(model, codec, entry, hidden(model, codec, entry)), picked)], len(ids)

def wide_choice(model, codec, state: str, q: dict, images=(), max_len: int | None = None,
                temperature: dict | None = None):
    """A choice over more options than there are letters, in two passes -> (distribution, most input tokens).

    First the options are split into chunks, each asked as its own question; then the leading options of every chunk
    are asked again together. Finalists share their first-pass mass (each chunk weighted equally) in proportion to the
    second pass; every other option keeps its first-pass share, so the distribution sums to one and its top answer is
    the second pass's."""
    n, per = len(q["options"]), len(codec.codes)
    size = -(-n // -(-n // per))  # balanced chunks of at most `per`
    chunks = [list(range(s, min(s + size, n))) for s in range(0, n, size)]
    first, t1 = read(model, codec, state, [{**q, "options": [q["options"][i] for i in c]} for c in chunks], images, max_len,
                     temperature)
    prior = [0.0] * n
    for c, p in zip(chunks, first):
        for i, x in zip(c, p):
            prior[i] = x / len(chunks)
    keep = per // len(chunks)
    finalists = sorted(i for c, p in zip(chunks, first) for i, _ in sorted(zip(c, p), key=lambda t: -t[1])[:keep])
    (second,), t2 = read(model, codec, state, [{**q, "options": [q["options"][i] for i in finalists]}], images, max_len,
                         temperature)
    mass, out = sum(prior[i] for i in finalists), prior[:]
    for i, x in zip(finalists, second):
        out[i] = mass * x
    return out, max(t1, t2)

@torch.no_grad()
def sureness(r: dict) -> float:
    """How sure a yes/no, choice or score result is: the probability of its answer."""
    return max(r["p_yes"], 1 - r["p_yes"]) if r["type"] == "noul" else max(r["probs"])

@torch.no_grad()
def think_many(model, codec, state: str, qs: list[dict], max_tokens: int, temperature: dict | None = None,
               generate=None):
    """think for several questions about one case at once: their thoughts are generated as one left-padded batch, so a
    case with eight unsure questions costs about one generation instead of eight. generate(prefixes, max_tokens) ->
    [(text, tokens)] writes the thoughts elsewhere instead (a vLLM server: serve's AMOS_THINK_URL); the letters are
    still read here, so the answers are the same either way."""
    if noncausal(model):
        raise ValueError("a noncausal run cannot think: generation reads a causal model's cache")
    state, qs = codec.safe(state, qs)
    prefixes = [codec.tok.apply_chat_template(render(state, [q], codes=codec.codes)[:2], tokenize=False,
                                              add_generation_prompt=True, enable_thinking=True) for q in qs]
    if generate is not None:
        thoughts = generate(prefixes, max_tokens)
    else:
        ids = [codec.tok(p, add_special_tokens=False)["input_ids"] for p in prefixes]
        width, pad = max(map(len, ids)), codec.tok.pad_token_id
        batch = torch.tensor([[pad] * (width - len(x)) + x for x in ids], device=model.device)
        mask = torch.tensor([[0] * (width - len(x)) + [1] * len(x) for x in ids], device=model.device)
        out = model.generate(batch, attention_mask=mask, max_new_tokens=max_tokens, do_sample=False, use_cache=True,
                             pad_token_id=pad)
        thoughts = [(codec.tok.decode(row, skip_special_tokens=False).replace(codec.tok.pad_token or "\0", ""),
                     int((row != pad).sum())) for row in out[:, width:]]
    results = []
    for prefix, q, (text, n) in zip(prefixes, qs, thoughts):
        reasoning = text.split("</think>")[0].replace("<|im_end|>", "").strip()
        full = codec.tok(prefix + reasoning + "\n</think>\n\n" + f"q1: {ANSWER}", add_special_tokens=False)["input_ids"]
        at = [i - 1 for i, t in enumerate(full) if t == codec.marker]
        lp = distributions(model, codec, [(full, at, [q])])[0]
        results.append((tempered(lp, (temperature or {}).get(q["type"])), n))
    return results

def results(model, codec, state: str, questions: list[dict], images=(), heads: Heads | None = None,
            max_len: int | None = None, rotations: int = 1, temperature: dict | None = None,
            think_below: float | None = None, think_tokens: int = 512, generate=None) -> tuple[list[dict], int]:
    """One typed result per question in amos.model.decode's format (noul p_yes; choice/score probs, choice, expected;
    multi independent probs; span start/end/text/p; region cell/box/p), and the number of input tokens. A choice over
    more options than there are letters is answered on its own (wide_choice). rotations and temperature: see
    one_pass. think_below: a yes/no, choice or score answer less sure than this is asked again after the model
    thinks (think), as GLiDE spends compute only where a decision is uncertain; the result then carries "thought"."""
    if think_below and noncausal(model):  # on every request, not only the unsure ones
        raise ValueError("a noncausal run cannot think: generation reads a causal model's cache")
    out, tokens = _results(model, codec, state, questions, images, heads, max_len, rotations, temperature)
    if think_below and not images:
        unsure = [k for k, (q, r) in enumerate(zip(questions, out))
                  if q["type"] in KINDS and len(options(q)) <= len(codec.codes) and sureness(r) < think_below]
        for k, (p, n) in zip(unsure, think_many(model, codec, state, [questions[k] for k in unsure], think_tokens,
                                                temperature, generate) if unsure else []):
            q = questions[k]
            if q["type"] == "noul":
                out[k] = {"type": "noul", "p_yes": p[0], "thought": n}
            else:
                out[k] = {"type": q["type"], "probs": p, "choice": max(range(len(p)), key=p.__getitem__), "thought": n}
                if q["type"] == "score":
                    out[k]["expected"] = sum(i * x for i, x in enumerate(p))
    return out, tokens

def _results(model, codec, state: str, questions: list[dict], images=(), heads: Heads | None = None,
             max_len: int | None = None, rotations: int = 1, temperature: dict | None = None) -> tuple[list[dict], int]:
    wide = {k for k, q in enumerate(questions) if q["type"] == "choice" and len(q["options"]) > len(codec.codes)}
    out, tokens = [None] * len(questions), 0
    for k in sorted(wide):
        p, n = wide_choice(model, codec, state, questions[k], images, max_len, temperature)
        out[k], tokens = {"type": "choice", "probs": p, "choice": max(range(len(p)), key=p.__getitem__)}, max(tokens, n)
    rest = [k for k in range(len(questions)) if k not in wide]
    if rest:
        sub, n = one_pass(model, codec, state, [questions[k] for k in rest], images, heads, max_len, rotations,
                          temperature)
        for k, r in zip(rest, sub):
            out[k] = r
        tokens = max(tokens, n)
    return out, tokens

ROTATION_BATCH_TOKENS = 65536

def one_pass(model, codec, state: str, questions: list[dict], images=(), heads: Heads | None = None,
             max_len: int | None = None, rotations: int = 1, temperature: dict | None = None) -> tuple[list[dict], int]:
    """results() for questions that fit the letters, in one forward pass.

    rotations > 1 (and a choice question present): the case is also read with every choice question's options rotated,
    each rotation one more row of the same batched forward, and choice distributions are averaged in the original order.
    temperature: {question type: t} fitted by `decoder calibrate` (pick-any's per-option yes/no uses noul's)."""
    flat, owner = expand(questions)
    rotations = rotations if any(q["type"] == "choice" for q in flat) else 1
    entries = []
    for r in range(rotations):
        qs = [rotate(q, r) for q in flat]
        ids, at, extra = codec.encode_case(state, qs, images)
        if max_len and len(ids) > max_len:
            raise ValueError(f"maximum context length: request has {len(ids)} tokens, limit {max_len}")
        entries.append((ids, at, qs, extra))
    ids, extra = entries[0][0], entries[0][3]
    if heads is None and any(q["type"] in HEADED for q in flat):
        raise ValueError("this model has no span or region head")
    if sum(len(e[0]) for e in entries) <= ROTATION_BATCH_TOKENS:
        h = hidden(model, codec, entries)
        lps = distributions(model, codec, entries, h)
    else:  # a long case: one rotation at a time, so memory stays that of a single row
        h, lps = None, []
        for e in entries:
            he = hidden(model, codec, [e])
            lps += distributions(model, codec, [e], he)
            h = he if h is None else h
    head = iter(heads(h[:1], entries[:1]) if heads is not None else [])
    picked = [j for j, q in enumerate(flat) if q["type"] in KINDS]
    temps = temperature or {}
    mean = {}
    for r in range(rotations):
        for m, j in enumerate(picked):
            p, n = tempered(lps[r * len(picked) + m], temps.get(flat[j]["type"])), len(options(flat[j]))
            s = r % n if flat[j]["type"] == "choice" else 0
            acc = mean.setdefault(j, [0.0] * n)
            for i in range(n):  # rotated position (i - s) mod n holds original option i
                acc[i] += p[(i - s) % n] / rotations
    lps = iter(mean[j] for j in picked)
    out = [None] * len(questions)
    for q, k in zip(flat, owner):
        t = questions[k]["type"]
        if q["type"] in HEADED:
            out[k] = decode_head(q, next(head), extra, state, heads.keep, heads.span)
            continue
        p = next(lps)
        if t == "multi":
            out[k] = out[k] or {"type": t, "probs": []}
            out[k]["probs"].append(p[0])
        elif t == "noul":
            out[k] = {"type": t, "p_yes": p[0]}
        else:
            out[k] = {"type": t, "probs": p, "choice": max(range(len(p)), key=p.__getitem__)}
            if t == "score":
                out[k]["expected"] = sum(i * x for i, x in enumerate(p))
    return out, len(ids)

CALIBRATION = "calibration.json"

def calibration(run) -> dict | None:
    """The per-type temperatures `decoder calibrate` fitted for a run, or None."""
    f = Path(run) / CALIBRATION
    return json.loads(f.read_text()).get("temperature") if f.exists() else None

def span_decoding(run) -> tuple[int, float]:
    """The quote decoder's (cap, penalty) fitted for a run on held-out quote records, or the defaults."""
    f = Path(run) / CALIBRATION
    s = (json.loads(f.read_text()).get("span") or {}) if f.exists() else {}
    return int(s.get("cap", SPAN_CAP)), float(s.get("penalty", SPAN_LENGTH_PENALTY))

def multi_cut(run) -> float | None:
    """The pick-any cut fitted on our own held-out pick-any questions (an option is picked at p >= cut), or None."""
    f = Path(run) / CALIBRATION
    return json.loads(f.read_text()).get("multi_cut") if f.exists() else None