File size: 17,122 Bytes
a381a62
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Attention layouts for non-recurrent visual latent reasoning chains.

The sequence is laid out as::

    [multimodal prompt ; clean question ; LOOK_1 ; THINK_1 ; ... ; answer]

All rows are processed once by the native VLM decoder.  A block-sparse causal
graph makes LOOK rows the only latent rows with access to the multimodal
prefix, while THINK rows integrate one visual read without directly seeing
that prefix.  Answer rows can see the clean question and THINK rows, but never
the multimodal prefix or LOOK rows.  Consequently the image-dependent answer
path is

    image -> LOOK_k -> THINK_k -> answer,

without a tied recurrent cell, a visual-state update, or an aggregation
module.

The transition-conditioned variant tightens the graph without adding a
module.  The first LOOK/THINK pair bootstraps from the complete prompt and the
clean question.  Every later pair sees the causal latent prefix, while its
LOOK row receives only visual placeholder rows from the multimodal prefix.
Thus later pairs cannot independently re-solve the original image/question
prompt; their task semantics must arrive through earlier latent states.  The
last THINK is the sole latent exposed to answer rows and therefore acts as the
native-transformer aggregation state.
"""

from __future__ import annotations

from dataclasses import dataclass

import torch


@dataclass(frozen=True)
class PerceiveDeliberateLayout:
    """Physical row layout of one padded training/generation sequence."""

    multimodal_length: int
    question_length: int
    num_pairs: int
    answer_input_length: int = 0

    def __post_init__(self) -> None:
        if self.multimodal_length < 1:
            raise ValueError("multimodal_length must be positive")
        if self.question_length < 1:
            raise ValueError("question_length must be positive")
        if self.num_pairs < 1:
            raise ValueError("num_pairs must be positive")
        if self.answer_input_length < 0:
            raise ValueError("answer_input_length must be non-negative")

    @property
    def num_latent_tokens(self) -> int:
        return 2 * self.num_pairs

    @property
    def question_start(self) -> int:
        return self.multimodal_length

    @property
    def latent_start(self) -> int:
        return self.multimodal_length + self.question_length

    @property
    def answer_start(self) -> int:
        return self.latent_start + self.num_latent_tokens

    @property
    def sequence_length(self) -> int:
        return self.answer_start + self.answer_input_length

    @property
    def question_slice(self) -> slice:
        return slice(self.question_start, self.latent_start)

    @property
    def answer_slice(self) -> slice:
        return slice(self.answer_start, self.sequence_length)

    @property
    def look_indices(self) -> tuple[int, ...]:
        return tuple(self.latent_start + 2 * index for index in range(self.num_pairs))

    @property
    def think_indices(self) -> tuple[int, ...]:
        return tuple(index + 1 for index in self.look_indices)


def build_perceive_deliberate_mask(
    layout: PerceiveDeliberateLayout,
    multimodal_attention_mask: torch.Tensor,
    question_attention_mask: torch.Tensor,
    answer_input_attention_mask: torch.Tensor | None = None,
    *,
    multimodal_visual_mask: torch.Tensor | None = None,
    local_visual_chain: bool = False,
    transition_conditioned: bool = False,
    question_visible_through_pair: int = 1,
    disable_chain_links: bool = False,
    disable_late_visual: bool = False,
    final_pair_only: bool = False,
) -> torch.BoolTensor:
    """Build the strict LOOK/THINK/answer visibility graph.

    The returned boolean mask has shape ``[B, 1, L, L]`` and follows PyTorch
    SDPA semantics: ``True`` means that the query may attend to the key.

    In the original graph, LOOK_k sees the valid multimodal prompt, the clean
    question, and (for k>1) only THINK_{k-1}; THINK_k sees the clean question
    and LOOK_k.  In the transition-conditioned graph, pair 1 keeps those
    bootstrap inputs, but every later latent sees its complete causal latent
    prefix, later LOOK rows see only visual placeholder keys, and later THINK
    rows normally receive no independent question shortcut.  For a controlled
    curriculum/intervention, ``question_visible_through_pair`` may temporarily
    retain the clean-question edge through a later pair; ``1`` is the strict
    inference graph.  Answer rows see only the final THINK in that variant.
    Every row also sees itself so padded query
    rows remain numerically defined; padded rows are never exposed as keys to
    semantic rows.
    """

    if multimodal_attention_mask.ndim != 2:
        raise ValueError("multimodal_attention_mask must have shape [B, L_mm]")
    if question_attention_mask.ndim != 2:
        raise ValueError("question_attention_mask must have shape [B, L_q]")
    batch_size = multimodal_attention_mask.shape[0]
    expected_mm = (batch_size, layout.multimodal_length)
    expected_q = (batch_size, layout.question_length)
    if tuple(multimodal_attention_mask.shape) != expected_mm:
        raise ValueError(
            "multimodal mask/layout mismatch: "
            f"expected {expected_mm}, got {tuple(multimodal_attention_mask.shape)}"
        )
    if tuple(question_attention_mask.shape) != expected_q:
        raise ValueError(
            "question mask/layout mismatch: "
            f"expected {expected_q}, got {tuple(question_attention_mask.shape)}"
        )
    if layout.answer_input_length:
        if answer_input_attention_mask is None:
            raise ValueError("answer_input_attention_mask is required")
        expected_answer = (batch_size, layout.answer_input_length)
        if tuple(answer_input_attention_mask.shape) != expected_answer:
            raise ValueError(
                "answer mask/layout mismatch: "
                f"expected {expected_answer}, got "
                f"{tuple(answer_input_attention_mask.shape)}"
            )
    elif answer_input_attention_mask is not None and answer_input_attention_mask.numel():
        raise ValueError("received answer mask for an empty answer-input segment")

    device = multimodal_attention_mask.device
    length = layout.sequence_length
    visible = torch.zeros(
        batch_size, length, length, dtype=torch.bool, device=device
    )
    mm_valid = multimodal_attention_mask.bool()
    q_valid = question_attention_mask.bool()
    visual_valid = None
    if transition_conditioned or local_visual_chain:
        if not 1 <= question_visible_through_pair <= layout.num_pairs:
            raise ValueError(
                "question_visible_through_pair must lie in "
                f"[1,{layout.num_pairs}]"
            )
        if multimodal_visual_mask is None:
            raise ValueError(
                "multimodal_visual_mask is required for the "
                "transition-conditioned graph"
            )
        if tuple(multimodal_visual_mask.shape) != expected_mm:
            raise ValueError(
                "visual mask/layout mismatch: "
                f"expected {expected_mm}, got "
                f"{tuple(multimodal_visual_mask.shape)}"
            )
        visual_valid = multimodal_visual_mask.bool()
        if bool((visual_valid & ~mm_valid).any()):
            raise ValueError("visual keys must be a subset of valid multimodal keys")

    # The source multimodal prompt keeps the native causal graph.  Padded query
    # rows are harmless and receive a self edge below.
    mm_causal = torch.ones(
        layout.multimodal_length,
        layout.multimodal_length,
        dtype=torch.bool,
        device=device,
    ).tril()
    visible[:, : layout.multimodal_length, : layout.multimodal_length] = (
        mm_causal.unsqueeze(0) & mm_valid[:, None, :]
    )

    # The duplicated question is deliberately text-only: it is causally
    # connected only to preceding valid rows of its own segment.
    q_causal = torch.ones(
        layout.question_length,
        layout.question_length,
        dtype=torch.bool,
        device=device,
    ).tril()
    visible[
        :, layout.question_slice, layout.question_slice
    ] = q_causal.unsqueeze(0) & q_valid[:, None, :]

    if local_visual_chain:
        # Homogeneous one-pass latent chain.  Visual memory is persistent and
        # available at every position, while task state has exactly one local
        # predecessor edge.  No latent may consume the complete causal prefix.
        latent_indices = range(layout.latent_start, layout.answer_start)
        for latent_offset, latent in enumerate(latent_indices):
            visible[:, latent, : layout.multimodal_length] = visual_valid
            if latent_offset == 0:
                visible[:, latent, layout.question_slice] = q_valid
            else:
                visible[:, latent, latent - 1] = True

        if layout.answer_input_length:
            answer_valid = answer_input_attention_mask.bool()
            answer_causal = torch.ones(
                layout.answer_input_length,
                layout.answer_input_length,
                dtype=torch.bool,
                device=device,
            ).tril()
            visible[:, layout.answer_slice, layout.question_slice] = q_valid[:, None, :]
            visible[:, layout.answer_slice, layout.answer_start - 1] = True
            visible[:, layout.answer_slice, layout.answer_slice] = (
                answer_causal.unsqueeze(0) & answer_valid[:, None, :]
            )

        diagonal = torch.arange(length, device=device)
        visible[:, diagonal, diagonal] = True
        return visible.unsqueeze(1)

    for pair_index, (look, think) in enumerate(
        zip(layout.look_indices, layout.think_indices)
    ):
        if final_pair_only and pair_index != layout.num_pairs - 1:
            continue

        if transition_conditioned and not final_pair_only:
            # Pair 1 bootstraps the causal latent prefix.  Later pairs cannot
            # recover the task from the original prompt: only image placeholder
            # rows remain visible outside the latent prefix.
            if pair_index == 0:
                visible[:, look, : layout.multimodal_length] = mm_valid
                visible[:, look, layout.question_slice] = q_valid
                visible[:, think, layout.question_slice] = q_valid
            else:
                if not disable_late_visual:
                    visible[:, look, : layout.multimodal_length] = visual_valid
                if not disable_chain_links:
                    visible[:, look, layout.latent_start:look] = True
                    visible[:, think, layout.latent_start:look] = True
                if pair_index < question_visible_through_pair:
                    visible[:, look, layout.question_slice] = q_valid
                    visible[:, think, layout.question_slice] = q_valid

            # The local LOOK -> THINK edge is never an inter-pair chain-link
            # intervention and therefore remains present in every arm.
            visible[:, think, look] = True
        else:
            # Original graph, also used by the final-pair-only capacity
            # control: each active pair may independently consume prompt + Q.
            if not disable_late_visual or pair_index == 0 or final_pair_only:
                visible[:, look, : layout.multimodal_length] = mm_valid
            visible[:, look, layout.question_slice] = q_valid
            if pair_index and not disable_chain_links and not final_pair_only:
                visible[:, look, layout.think_indices[pair_index - 1]] = True

            # Deliberation: no original multimodal key can be consumed here.
            visible[:, think, layout.question_slice] = q_valid
            visible[:, think, look] = True

    if layout.answer_input_length:
        answer_valid = answer_input_attention_mask.bool()
        answer_causal = torch.ones(
            layout.answer_input_length,
            layout.answer_input_length,
            dtype=torch.bool,
            device=device,
        ).tril()
        visible[:, layout.answer_slice, layout.question_slice] = q_valid[:, None, :]
        answer_thinks = (
            [layout.think_indices[-1]]
            if final_pair_only or transition_conditioned
            else list(layout.think_indices)
        )
        visible[:, layout.answer_slice, answer_thinks] = True
        visible[:, layout.answer_slice, layout.answer_slice] = (
            answer_causal.unsqueeze(0) & answer_valid[:, None, :]
        )

    # Avoid all-masked softmax rows for physical padding.  Semantic queries do
    # not receive padded keys because every segment assignment above uses its
    # validity mask.
    diagonal = torch.arange(length, device=device)
    visible[:, diagonal, diagonal] = True
    return visible.unsqueeze(1)


def build_perceive_deliberate_positions(
    layout: PerceiveDeliberateLayout,
    multimodal_position_ids: torch.LongTensor,
    multimodal_attention_mask: torch.Tensor,
    question_attention_mask: torch.Tensor,
    answer_input_attention_mask: torch.Tensor | None = None,
) -> torch.LongTensor:
    """Extend native multimodal M-RoPE with logical text positions.

    The clean question keeps ordinary relative text positions but is shifted
    after the largest valid multimodal M-RoPE coordinate.  Latent and answer
    rows then continue from each item's *logical* question length, independent
    of right padding.  All three M-RoPE coordinates are equal for these
    non-spatial rows.
    """

    if multimodal_position_ids.ndim != 3 or multimodal_position_ids.shape[0] != 3:
        raise ValueError("multimodal_position_ids must have shape [3, B, L_mm]")
    batch_size = multimodal_attention_mask.shape[0]
    if tuple(multimodal_position_ids.shape[1:]) != (
        batch_size,
        layout.multimodal_length,
    ):
        raise ValueError("multimodal position/layout mismatch")

    mm_valid = multimodal_attention_mask.bool()
    masked_mm = multimodal_position_ids.masked_fill(~mm_valid.unsqueeze(0), -1)
    continuation = masked_mm.amax(dim=(0, 2)).clamp_min(-1) + 1  # [B]

    q_valid = question_attention_mask.bool()
    q_relative = q_valid.long().cumsum(dim=-1) - 1
    q_relative = q_relative.masked_fill(~q_valid, 0)
    q_positions = continuation[:, None] + q_relative
    q_lengths = q_valid.sum(dim=-1)

    latent_relative = torch.arange(
        layout.num_latent_tokens,
        device=multimodal_position_ids.device,
    )
    latent_positions = (
        continuation[:, None] + q_lengths[:, None] + latent_relative[None, :]
    )

    segments = [multimodal_position_ids, q_positions.unsqueeze(0).expand(3, -1, -1)]
    segments.append(latent_positions.unsqueeze(0).expand(3, -1, -1))
    if layout.answer_input_length:
        if answer_input_attention_mask is None:
            raise ValueError("answer_input_attention_mask is required")
        answer_relative = torch.arange(
            layout.answer_input_length,
            device=multimodal_position_ids.device,
        )
        answer_positions = (
            continuation[:, None]
            + q_lengths[:, None]
            + layout.num_latent_tokens
            + answer_relative[None, :]
        )
        segments.append(answer_positions.unsqueeze(0).expand(3, -1, -1))
    return torch.cat(segments, dim=-1)


def build_answer_decode_mask(
    layout: PerceiveDeliberateLayout,
    question_attention_mask: torch.Tensor,
    generated_attention_mask: torch.Tensor,
    *,
    local_visual_chain: bool = False,
    final_pair_only: bool = False,
    transition_conditioned: bool = False,
) -> torch.BoolTensor:
    """Visibility for one cached answer query after the latent prefill.

    ``generated_attention_mask`` includes the current answer token.  Physical
    multimodal and LOOK cache rows remain present but are invisible.
    """

    if generated_attention_mask.ndim != 2:
        raise ValueError("generated_attention_mask must have shape [B, N]")
    batch_size, generated_length = generated_attention_mask.shape
    if tuple(question_attention_mask.shape) != (
        batch_size,
        layout.question_length,
    ):
        raise ValueError("question mask/layout mismatch")
    total_keys = layout.answer_start + generated_length
    visible = torch.zeros(
        batch_size, 1, 1, total_keys, dtype=torch.bool,
        device=question_attention_mask.device,
    )
    visible[:, 0, 0, layout.question_slice] = question_attention_mask.bool()
    if local_visual_chain:
        visible[:, 0, 0, layout.answer_start - 1] = True
    else:
        answer_thinks = (
            [layout.think_indices[-1]]
            if final_pair_only or transition_conditioned
            else list(layout.think_indices)
        )
        visible[:, 0, 0, answer_thinks] = True
    visible[:, 0, 0, layout.answer_start:] = generated_attention_mask.bool()
    return visible


__all__ = [
    "PerceiveDeliberateLayout",
    "build_answer_decode_mask",
    "build_perceive_deliberate_mask",
    "build_perceive_deliberate_positions",
]