diff --git "a/modeling_source_qwen3.py" "b/modeling_source_qwen3.py" new file mode 100644--- /dev/null +++ "b/modeling_source_qwen3.py" @@ -0,0 +1,4096 @@ +"""Raw-evidence/question recurrent reasoning on Qwen3-VL. + +The oracle-selected ``raw_evidence_question`` path keeps the complete frozen +visual state as immutable evidence ``E=V*`` and initializes the recurrent state +with the text-only question scaffold ``R0=Q*``. One shared Cross-Attention +transition reads ``E`` for four residual steps, then parameter-free trajectory +aggregation preserves every recurrent depth:: + + R0 = Q* + Rk = R{k-1} + CrossAttn(R{k-1}, E) + R_agg = mean(R1, R2, R3, R4) -> y + +During training only, ``R_agg`` is also restored toward the image-conditioned +question rows from the frozen multimodal lower pass. That target is already +available from the pass that builds ``V*`` and never enters the inference +decoder. Historical final-state, state-replacement, and question-anchor modes +remain loadable for checkpoint comparison. + +The multimodal upper continuation and its KV cache are discarded. The lower +replacement cache contains only text-only ``Q*`` rows; the same positions are +overwritten by the selected decoder latent above ``ell_star``. Thus both +halves have length ``N_q``: +there are no workspace slots, placeholder tokens, learned aggregation, or +direct ``E``-to-decoder connection in this path. + +The cross-attention uses Qwen's native 32-query/8-KV-head GQA projections and +starts from the first upper decoder layer's Q/K/V/O and RMSNorm weights. It is +still cross-attention, so the source layer's self-attention RoPE is not reused. +The current method has no workspace FFN. In residual mode, disabling a visual +read makes that step an identity; in replacement mode it writes zero on valid +question rows. A compatibility-only FFN can still be allocated for older +checkpoints. + +Historical slot-workspace implementations remain below for loading and +evaluating old checkpoints. Configuration validation makes those modules +mutually exclusive with ``raw_evidence_question``. + +Shapes: ``B`` batch, ``N_v`` visual tokens, ``N_q`` question tokens, and ``d`` +the native backbone width (4096). +""" + +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import dataclass, replace +import math + +import torch +import torch.nn as nn +from torch.utils.checkpoint import checkpoint +from transformers.cache_utils import Cache, DynamicCache +from transformers.modeling_outputs import ModelOutput +from transformers.models.qwen3_vl.modeling_qwen3_vl import ( + Qwen3VLForConditionalGeneration, + Qwen3VLPreTrainedModel, + Qwen3VLTextRMSNorm, +) + +from .configuration_source_qwen3 import CloseQwen3VLConfig +from .source_splitting import ( + embed_multimodal, + final_norm, + image_token_mask, + make_split_context, + run_layer_range, + select_tokens_padded, + vision_span_mask, +) + + +def _replace_selected_rows_from_padded( + full_state: torch.Tensor, + selection_mask: torch.Tensor, + padded_rows: torch.Tensor, + padding_mask: torch.Tensor, +) -> torch.Tensor: + """Inverse of ``select_tokens_padded`` on each example's valid rows.""" + if selection_mask.dtype != torch.bool or padding_mask.dtype != torch.bool: + raise TypeError("selection and padding masks must be boolean") + if full_state.ndim != 3 or padded_rows.ndim != 3: + raise ValueError("full_state and padded_rows must be [B, L, D]") + if selection_mask.shape != full_state.shape[:2]: + raise ValueError("selection_mask does not match full_state") + if padding_mask.shape != padded_rows.shape[:2]: + raise ValueError("padding_mask does not match padded_rows") + if full_state.shape[0] != padded_rows.shape[0] or ( + full_state.shape[-1] != padded_rows.shape[-1] + ): + raise ValueError("full and padded states have incompatible shapes") + selected_counts = selection_mask.sum(dim=-1) + padded_counts = (~padding_mask).sum(dim=-1) + if not torch.equal(selected_counts, padded_counts): + raise ValueError("selected-row counts disagree with padded valid counts") + output = full_state.clone() + for batch_index, count_tensor in enumerate(selected_counts): + count = int(count_tensor) + output[batch_index, selection_mask[batch_index]] = padded_rows[ + batch_index, :count + ] + return output + + +def _linear_qwen3_vision_patch_embed_forward( + patch_embed, + hidden_states: torch.Tensor, +) -> torch.Tensor: + """Evaluate Qwen3-VL's non-overlapping patch Conv3d as one GEMM. + + The official projection has kernel size equal to stride and receives one + already-flattened patch per row. It is therefore exactly a linear map over + each row. Avoiding cuDNN here is important for dynamic-resolution batches: + cuDNN otherwise spends tens of seconds planning a new Conv3d shape whenever + the total number of patches changes. + """ + projection = patch_embed.proj + target_dtype = projection.weight.dtype + flattened_weight = projection.weight.flatten(1) + flattened_input = hidden_states.reshape(-1, flattened_weight.shape[1]) + return torch.nn.functional.linear( + flattened_input.to(dtype=target_dtype), + flattened_weight, + projection.bias, + ) + + +def answer_cross_entropy( + logits: torch.Tensor, + labels: torch.Tensor, + *, + per_example: bool = False, +) -> torch.Tensor: + """Answer CE with either token- or example-balanced reduction. + + ``per_example=False`` exactly matches PyTorch's historical global valid-token + mean. ``True`` gives every non-empty sample equal weight regardless of its + answer length, preventing long caption answers from overwhelming one-token + visual-decision examples. + """ + if not per_example: + return nn.functional.cross_entropy( + logits.reshape(-1, logits.shape[-1]).float(), + labels.reshape(-1), + ignore_index=-100, + ) + bsz = labels.shape[0] + token_loss = nn.functional.cross_entropy( + logits.reshape(-1, logits.shape[-1]).float(), + labels.reshape(-1), + ignore_index=-100, + reduction="none", + ).view(bsz, -1) + valid = labels.reshape(bsz, -1).ne(-100) + counts = valid.sum(dim=-1) + nonempty = counts > 0 + if not bool(nonempty.any()): + return logits.float().sum() * 0.0 + sample_loss = (token_loss * valid).sum(dim=-1) / counts.clamp_min(1) + return sample_loss[nonempty].mean() + + +def _answer_nll_per_example( + logits: torch.Tensor, + labels: torch.Tensor, +) -> tuple[torch.Tensor, torch.Tensor]: + """Return length-normalized answer NLL and a non-empty-row mask. + + The CRR reliance intervention compares factual and swapped answers across + examples with very different target lengths. A per-token mean keeps that + score gap in the same units for one-token MC labels and caption answers. + """ + bsz = labels.shape[0] + token_loss = nn.functional.cross_entropy( + logits.reshape(-1, logits.shape[-1]).float(), + labels.reshape(-1), + ignore_index=-100, + reduction="none", + ).view(bsz, -1) + valid = labels.reshape(bsz, -1).ne(-100) + counts = valid.sum(dim=-1) + nll = (token_loss * valid).sum(dim=-1) / counts.clamp_min(1) + return nll, counts > 0 + + +def _different_answer_residual_donors( + labels: torch.Tensor, + question_attention_mask: torch.Tensor | None = None, +) -> tuple[torch.LongTensor, torch.BoolTensor]: + """Choose a deterministic batch-local donor with a different answer. + + Equal question lengths are preferred so that right-padded Q-shaped + residuals align exactly. Equal answer lengths are the second preference, + which usually keeps MC examples in the same formatting regime. Rows for + which the local batch contains no different answer are marked invalid and + do not contribute to the reliance loss. + """ + bsz = labels.shape[0] + rows = torch.arange(bsz, device=labels.device) + donors = rows.clone() + found = torch.zeros(bsz, dtype=torch.bool, device=labels.device) + if bsz < 2: + return donors, found + + same_answer = labels[:, None, :].eq(labels[None, :, :]).all(dim=-1) + answer_lengths = labels.ne(-100).sum(dim=-1) + if question_attention_mask is None: + question_lengths = torch.ones_like(answer_lengths) + else: + question_lengths = question_attention_mask.gt(0).sum(dim=-1) + + # Cyclic offsets avoid concentrating every negative on batch row zero. + # Compatibility is relaxed only after all offsets at the stricter level + # have been exhausted. + for compatibility in ("question", "answer", "any"): + for offset in range(1, bsz): + candidates = (rows + offset) % bsz + eligible = ~same_answer[rows, candidates] + if compatibility == "question": + eligible &= question_lengths.eq( + question_lengths.index_select(0, candidates) + ) + elif compatibility == "answer": + eligible &= answer_lengths.eq( + answer_lengths.index_select(0, candidates) + ) + choose = (~found) & eligible + donors[choose] = candidates[choose] + found |= choose + if bool(found.all()): + return donors, found + return donors, found + + +def _replacement_prefix_layout( + question_mask: torch.Tensor, num_slots: int +) -> tuple[torch.Tensor, torch.LongTensor, torch.LongTensor]: + """Mask and logical positions for the physical ``[q_pad; slots]`` layout. + + Questions are right-padded to a common physical width before the ``S`` slot + rows are appended. The slots must nevertheless occupy the *logical* + positions immediately after each item's last real question token. Using a + batch-wide ``arange(N_q + S)`` makes RoPE positions, and therefore answers, + depend on the longest question in the batch. + + Returns ``(prefix_mask, position_ids, logical_lengths)`` with shapes + ``[B,N_q+S]``, ``[3,B,N_q+S]``, and ``[B]``. ``logical_lengths`` is the + first answer-token position, i.e. ``question_length + S``. + """ + if question_mask.ndim != 2: + raise ValueError( + f"question_mask must have shape [B,N_q], got {tuple(question_mask.shape)}" + ) + if num_slots < 1: + raise ValueError("num_slots must be >= 1") + + keep = question_mask > 0 + b = keep.shape[0] + q_positions = keep.long().cumsum(dim=-1) - 1 + q_positions = q_positions.masked_fill(~keep, 0) + q_lengths = keep.sum(dim=-1).long() # [B] + slot_offsets = torch.arange(num_slots, device=question_mask.device) + slot_positions = q_lengths[:, None] + slot_offsets[None, :] # [B,S] + one_d = torch.cat([q_positions, slot_positions], dim=-1) # [B,N_q+S] + position_ids = one_d.unsqueeze(0).expand(3, b, -1) + slot_mask = torch.ones( + b, num_slots, dtype=question_mask.dtype, device=question_mask.device + ) + prefix_mask = torch.cat([question_mask, slot_mask], dim=-1) + return prefix_mask, position_ids, q_lengths + num_slots + +# --------------------------------------------------------------------------- +# workspace blocks +# --------------------------------------------------------------------------- + + +class _Block(nn.Module): + """Pre-norm block: optional cross-attention over ``context``, then FFN.""" + + def __init__(self, d_w: int, n_heads: int, ffn_mult: int, dropout: float, cross: bool): + super().__init__() + self.self_norm = nn.LayerNorm(d_w) + self.self_attn = nn.MultiheadAttention( + d_w, n_heads, dropout=dropout, batch_first=True + ) + self.cross = cross + if cross: + self.cross_norm_q = nn.LayerNorm(d_w) + self.cross_norm_kv = nn.LayerNorm(d_w) + self.cross_attn = nn.MultiheadAttention( + d_w, n_heads, dropout=dropout, batch_first=True + ) + self.ffn_norm = nn.LayerNorm(d_w) + self.ffn = nn.Sequential( + nn.Linear(d_w, ffn_mult * d_w), + nn.GELU(), + nn.Dropout(dropout), + nn.Linear(ffn_mult * d_w, d_w), + ) + + def forward( + self, + z: torch.Tensor, # [B, S, d_w] + context: torch.Tensor | None = None, # [B, N, d_w] + context_padding_mask: torch.Tensor | None = None, # [B, N] True == ignore + cond: torch.Tensor | None = None, # [1|B, S, d_w] norm 입력 조건 (carrier 미반영) + ) -> torch.Tensor: + # persistent slot-ID(advisor): cond는 attention/FFN을 조건화할 뿐 + # residual carrier에는 절대 실리지 않는다 — z' = z + F(LN(z + E)). + # cond=None이면 비트동일. + zc = z if cond is None else z + cond + h = self.self_norm(zc) + z = z + self.self_attn(h, h, h, need_weights=False)[0] + if self.cross: + if context is None: + raise ValueError("cross-attention block called without context") + zc = z if cond is None else z + cond + q = self.cross_norm_q(zc) + kv = self.cross_norm_kv(context) + z = z + self.cross_attn( + q, kv, kv, key_padding_mask=context_padding_mask, need_weights=False + )[0] + zc = z if cond is None else z + cond + return z + self.ffn(self.ffn_norm(zc)) + + +class _LowRankMHA(nn.Module): + """rank-r 부분공간 attention: Up(MHA(Down(q), Down(kv))) — d 유지, 파라미터 O(d·r).""" + + def __init__(self, d: int, r: int, n_heads: int): + super().__init__() + self.dq = nn.Linear(d, r, bias=False) + self.dkv = nn.Linear(d, r, bias=False) + self.attn = nn.MultiheadAttention(r, n_heads, dropout=0.0, batch_first=True) + self.up = nn.Linear(r, d, bias=False) + + def forward(self, q, kv, key_padding_mask=None): + out, _ = self.attn(self.dq(q), self.dkv(kv), self.dkv(kv), + key_padding_mask=key_padding_mask, need_weights=False) + return self.up(out) + + +class _LowRankBlock(nn.Module): + """_Block의 low-rank 동형: pre-norm + rank-r attention/FFN 잔차. + + ER v2 (advisor): d_w=d에서 full dense workspace의 파라미터 폭증을 막는 + "state width matching과 parameter explosion의 분리". cond 인터페이스는 + _Block과 동일 (미사용 시 비트동일 경로). + """ + + def __init__(self, d_w: int, n_heads: int, ffn_mult: int, dropout: float, + cross: bool, rank: int): + super().__init__() + self.self_norm = nn.LayerNorm(d_w) + self.self_attn = _LowRankMHA(d_w, rank, n_heads) + self.cross = cross + if cross: + self.cross_norm_q = nn.LayerNorm(d_w) + self.cross_norm_kv = nn.LayerNorm(d_w) + self.cross_attn = _LowRankMHA(d_w, rank, n_heads) + self.ffn_norm = nn.LayerNorm(d_w) + self.ffn = nn.Sequential( + nn.Linear(d_w, ffn_mult * rank), nn.GELU(), nn.Linear(ffn_mult * rank, d_w), + ) + + def forward(self, z, context=None, context_padding_mask=None, cond=None): + zc = z if cond is None else z + cond + h = self.self_norm(zc) + z = z + self.self_attn(h, h) + if self.cross: + if context is None: + raise ValueError("cross-attention block called without context") + zc = z if cond is None else z + cond + q = self.cross_norm_q(zc) + kv = self.cross_norm_kv(context) + z = z + self.cross_attn(q, kv, key_padding_mask=context_padding_mask) + zc = z if cond is None else z + cond + return z + self.ffn(self.ffn_norm(zc)) + + +def _make_block(config, cross: bool): + r = int(getattr(config, "workspace_low_rank", 0) or 0) + if r > 0: + return _LowRankBlock(config.workspace_width, config.workspace_num_heads, + config.workspace_ffn_mult, config.workspace_dropout, + cross, r) + return _Block(config.workspace_width, config.workspace_num_heads, + config.workspace_ffn_mult, config.workspace_dropout, cross) + + +class WorkspaceRead(nn.Module): + """``r_theta``: condition learnable slots on ``Q*``, then read ``V*``. + + ``Z^(0) = r_theta(Z_init, Q*, V*)``. ``V*`` is consumed only inside this + module: by :meth:`forward` (the initial read), and -- when + ``config.read_gating`` is on -- by :meth:`reread`, which applies the SAME + cross-attention weights to the evolved state under a scalar gate. With + gating off the read is single by construction and the caller discards + ``V*`` immediately after; either way no other module ever sees it. + """ + + def __init__(self, config: CloseQwen3VLConfig): + super().__init__() + d, d_w = config.backbone_width, config.workspace_width + self.slots = nn.Parameter(torch.randn(config.num_workspace_slots, d_w) * 0.02) + _eye = d == d_w # ER v2: 등폭이면 입사 projection(d→d) 자체가 불필요 + self.q_in = nn.Identity() if _eye else nn.Linear(d, d_w, bias=False) + self.v_in = nn.Identity() if _eye else nn.Linear(d, d_w, bias=False) + self.condition = _make_block(config, cross=True) + self.read = nn.ModuleList( + _make_block(config, cross=True) for _ in range(config.read_num_blocks) + ) + # v2: image-dependent from step 0. A random cross-attention starts out + # near-uniform, so its read is ~the mean of V* regardless of content and + # the optimizer can settle on ignoring vision (probe 20143). The + # shortcut injects an orthogonal projection of the LN'd pooled V* into + # every slot, so ignoring the image is never the zero-gradient default. + self.pool_in = None + if getattr(config, "read_shortcut", False): + self.pool_in = nn.Linear(d, d_w, bias=False) + # NOTE: post_init()'s HF sweep has overwritten this orthogonal init + # with N(0, initializer_range) in every run since v2 -- discovered + # while diagnosing the gate-init wipe (smoke 20552). Deliberately + # NOT flagged: v4/v5 trained under the overwritten init, and v6 + # must differ from v5 by the gate alone. Revisit only in a fresh + # baseline generation. + nn.init.orthogonal_(self.pool_in.weight) + self.out_norm = nn.LayerNorm(d_w) + # v6 candidate (read_gating): the SAME reader may be consulted again + # between transitions, scaled by a scalar gate per item. b_gamma = + # logit(eps): near-identity start (training begins ~at the proven + # read-once regime; opening is earned by the losses). No new reader + # parameters -- re-reads share v_in / read blocks / out_norm, so "how + # it reads" is identical and only "when" is learned. + self.gate = None + if getattr(config, "read_gating", False): + self.gate = nn.Linear(2 * d_w, 1) + nn.init.zeros_(self.gate.weight) + eps = float(getattr(config, "gate_init_eps", 0.02)) + eps = min(max(eps, 1e-6), 1 - 1e-6) + nn.init.constant_(self.gate.bias, math.log(eps / (1 - eps))) + # post_init()'s HF sweep re-inits every unflagged nn.Linear + # (weight ~ N(0, initializer_range), bias -> 0), which silently + # destroyed this init: with a random w over the unnormalised q̄ + # scale the gate saturated OPEN from step 0 (smoke 20552: gamma + # 0.98 at the first log, 1.0 thereafter -- the exact opposite of + # near-identity). The flag is respected by PreTrainedModel. + # _initialize_weights and keeps the custom init authoritative. + self.gate._is_hf_initialized = True + # v7 (read_residual_pure): always-on PURE visual residual reread. + # A^(k) = XAttn(LN(U^(k)) -> queries, V* -> keys/values) + # Z^(k) = U^(k) + W_o A^(k) + # No self-attention, no FFN, no output norm, no biases anywhere in the + # branch, W_o zero-initialised (exact identity at init -- stronger than + # v6's near-identity). Every atom of the update is a value vector of + # V*: the battery on ckpt-5724 showed the gated reader's worth was + # extra DEPTH, not vision; this branch cannot provide content-free + # depth, so any benefit is visual by construction. + self.pure_ln = None + if (getattr(config, "read_residual_pure", False) + or getattr(config, "final_verify", False) + or getattr(config, "mid_read_step", 0)): + self.pure_ln = nn.LayerNorm(d_w, bias=False) + self.pure_attn = nn.MultiheadAttention( + d_w, config.workspace_num_heads, batch_first=True, bias=False, + kdim=d, vdim=d, + ) + nn.init.zeros_(self.pure_attn.out_proj.weight) + if getattr(config, "pure_warm_start", False): + # copy visual-reading geometry from the initial reader: K/V + # from v_in ([d_w, d] both), Q from the first read block's + # query in-proj slice ([d_w, d_w]). + with torch.no_grad(): + self.pure_attn.k_proj_weight.copy_(self.v_in.weight) + self.pure_attn.v_proj_weight.copy_(self.v_in.weight) + qw = self.read[0].cross_attn.in_proj_weight[: config.workspace_width] + self.pure_attn.q_proj_weight.copy_(qw) + # post_init()'s sweep visits every SUBMODULE; a flag on the parent + # MHA does not shield its out_proj Linear (the gate-wipe lesson, + # caught again by tests/test_pure_residual.py). Flag the leaf. + self.pure_attn._is_hf_initialized = True + self.pure_attn.out_proj._is_hf_initialized = True + self.pure_ln._is_hf_initialized = True + + def forward( + self, + q_star: torch.Tensor, # [B, N_q, d] + v_star: torch.Tensor, # [B, N_v, d] + q_padding_mask: torch.Tensor | None = None, # [B, N_q] True == ignore + v_padding_mask: torch.Tensor | None = None, # [B, N_v] True == ignore + ) -> torch.Tensor: + b = q_star.shape[0] + z = self.slots.unsqueeze(0).expand(b, -1, -1) # [B, S, d_w] + q = self.q_in(q_star) # [B, N_q, d_w] + z = self.condition(z, context=q, context_padding_mask=q_padding_mask) + + if self.pool_in is not None: + if v_padding_mask is None: + pooled = v_star.mean(1) # [B, d] + else: + keep = (~v_padding_mask).to(v_star.dtype).unsqueeze(-1) # [B, N_v, 1] + pooled = (v_star * keep).sum(1) / keep.sum(1).clamp_min(1.0) + pooled = nn.functional.layer_norm(pooled, (pooled.shape[-1],)) + z = z + self.pool_in(pooled).unsqueeze(1) # broadcast over slots + + # Dynamic resolution means N_v varies across a batch; V* arrives padded + # to the batch maximum and the mask hides the filler. + v = self.v_in(v_star) # [B, N_v, d_w] + for block in self.read: + z = block(z, context=v, context_padding_mask=v_padding_mask) + return self.out_norm(z) # [B, S, d_w] + + def visual_residual( + self, + query_state: torch.Tensor, # [B, S, d_w] -- query only; NOT added to + v_star: torch.Tensor, # [B, N_v, d] + v_padding_mask: torch.Tensor | None = None, + ) -> torch.Tensor: + """``W_o XAttn(LN(q), V*)`` -- the raw visual residual, base-free. + + Separated from :meth:`reread_pure` so held-out interventions can pair + an arbitrary query source with an arbitrary decoder base (advisor's + query-only substitution).""" + if self.pure_ln is None: + raise RuntimeError("visual_residual() requires config.read_residual_pure=True") + a, _ = self.pure_attn( + self.pure_ln(query_state), v_star, v_star, + key_padding_mask=v_padding_mask, need_weights=False, + ) + return a + + def reread_pure( + self, + u: torch.Tensor, # [B, S, d_w] provisional state + v_star: torch.Tensor, # [B, N_v, d] + v_padding_mask: torch.Tensor | None = None, + query_state: torch.Tensor | None = None, # persistent slot-ID query + ) -> torch.Tensor: + """v7: ``Z = U + W_o XAttn(LN(U), V*)``. Update lives in span(V(V*)). + + ``query_state``가 주어지면 query만 그것으로 대체한다 (residual 기저는 + 여전히 ``u``) — persistent slot identity가 ``LN(U+E_slot)``을 query로 + 쓰되 state에는 E를 누적하지 않기 위한 통로. ``None``이면 비트동일.""" + q = u if query_state is None else query_state + return u + self.visual_residual(q, v_star, v_padding_mask) + + def reread( + self, + z: torch.Tensor, # [B, S, d_w] current workspace state + q_star: torch.Tensor, # [B, N_q, d] + v_star: torch.Tensor, # [B, N_v, d] + q_padding_mask: torch.Tensor | None = None, + v_padding_mask: torch.Tensor | None = None, + gate_override: float | torch.Tensor | None = None, + ) -> tuple[torch.Tensor, torch.Tensor]: + """Gated consultation of ``V*`` by the EVOLVED workspace state. + + ``z' = z + g * (read(z, V*) - z)`` with scalar ``g`` per item, so + ``g = 0`` reproduces ``z`` exactly (bit-identical to the no-reread + path) and ``g = 1`` is a full re-read. The reader weights are shared + with the initial read; only the gate decides WHEN looking again is + worth it. Returns ``(z', g)`` with ``g`` of shape ``[B]``. + + ``gate_override`` substitutes the learned gate with a fixed value + (forced-open 1.0 / forced-closed 0.0 / anything between). Held-out + causal evaluation ONLY -- §3.2's gate interventions. Never set during + training; ``None`` (the default) is the learned-gate path, bitwise. + """ + if self.gate is None: + raise RuntimeError("reread() requires config.read_gating=True") + if q_padding_mask is None: + q_bar = self.q_in(q_star).mean(1) # [B, d_w] + else: + keep = (~q_padding_mask).to(q_star.dtype).unsqueeze(-1) + q_bar = self.q_in((q_star * keep).sum(1) / keep.sum(1).clamp_min(1.0)) + # LN on the pooled state: the gate should react to the state's + # direction, not to scale drift across steps/runs. + z_bar = nn.functional.layer_norm(z.mean(1), (z.shape[-1],)) # [B, d_w] + g = torch.sigmoid(self.gate(torch.cat([z_bar, q_bar], dim=-1))).squeeze(-1) # [B] + if gate_override is not None: + g = torch.as_tensor(gate_override, dtype=g.dtype, device=g.device).expand_as(g) + if float(g.max()) == 0.0: + return z, g # exact noop, mirrors the closed-gate residual form + + v = self.v_in(v_star) # [B, N_v, d_w] + upd = z + for block in self.read: + upd = block(upd, context=v, context_padding_mask=v_padding_mask) + upd = self.out_norm(upd) + return z + g.view(-1, 1, 1) * (upd - z), g + + +class WorkspaceTransition(nn.Module): + """``f_theta``: shared recurrent step. Sees ``Z^(k-1)`` and ``Q*``. Never ``V*``. + + Shared across all ``K`` steps, so the trainable parameter count is + independent of ``K`` (§3.2). + """ + + def __init__(self, config: CloseQwen3VLConfig): + super().__init__() + d, d_w = config.backbone_width, config.workspace_width + self.q_in = (nn.Identity() if d == d_w + else nn.Linear(d, d_w, bias=False)) + self.blocks = nn.ModuleList( + _make_block(config, cross=True) for _ in range(config.transition_num_blocks) + ) + self.out_norm = nn.LayerNorm(d_w) + + def forward( + self, + z: torch.Tensor, # [B, S, d_w] + q_star: torch.Tensor, # [B, N_q, d] + q_padding_mask: torch.Tensor | None = None, + cond: torch.Tensor | None = None, # persistent slot-ID (norm 입력 전용) + ) -> torch.Tensor: + q = self.q_in(q_star) # [B, N_q, d_w] + for block in self.blocks: + z = block(z, context=q, context_padding_mask=q_padding_mask, cond=cond) + return self.out_norm(z) # [B, S, d_w] + + +class RawEvidenceQuestionTransition(nn.Module): + """Shared recurrent update for the slot-free ``E=V*; R0=Q*`` path. + + The state has the same rows and width as the text-only question scaffold. + Each step reads the complete immutable visual memory with one pre-norm + cross-attention. The residual-compatible mode used by existing runs is:: + + R_next = R + CrossAttn(RMSNorm(R), RMSNorm(E), RMSNorm(E)) + + The state-replacement candidate removes that recurrent identity path:: + + H_next = CrossAttn(RMSNorm(H), RMSNorm(E), RMSNorm(E)) + + Its question-anchored form replaces the one-time decoder addition with a + fixed scaffold at every recurrent step:: + + Z_next = Q* + CrossAttn(RMSNorm(Z), RMSNorm(E), RMSNorm(E)) + + There is no self-attention or FFN in either current method: ``Q*`` is already + contextualized by the frozen lower backbone, and the upper decoder retains + its pretrained attention/MLP stack. Consequently every recurrent state + change is caused by a visual-memory read. The same module instance is + reused for every recurrent step. + """ + + def __init__(self, config: CloseQwen3VLConfig): + super().__init__() + text_cfg = config.text_config + d = config.backbone_width + self.num_heads = int(text_cfg.num_attention_heads) + self.num_key_value_heads = int(text_cfg.num_key_value_heads) + self.num_key_value_groups = self.num_heads // self.num_key_value_heads + self.head_dim = d // self.num_heads + self.dropout = float(config.workspace_dropout) + if self.num_heads % self.num_key_value_heads: + raise ValueError("query heads must be divisible by KV heads") + + # Native Qwen GQA shapes. Q/K/V/O and the input RMSNorm are copied + # from L(ell_star+1) after post_init, before upper-layer LoRA injection. + self.read_norm_q = Qwen3VLTextRMSNorm(d, eps=text_cfg.rms_norm_eps) + self.read_norm_e = Qwen3VLTextRMSNorm(d, eps=text_cfg.rms_norm_eps) + attention_bias = bool(getattr(text_cfg, "attention_bias", False)) + self.q_proj = nn.Linear( + d, self.num_heads * self.head_dim, bias=attention_bias + ) + self.k_proj = nn.Linear( + d, self.num_key_value_heads * self.head_dim, bias=attention_bias + ) + self.v_proj = nn.Linear( + d, self.num_key_value_heads * self.head_dim, bias=attention_bias + ) + self.o_proj = nn.Linear(self.num_heads * self.head_dim, d, bias=False) + self.state_replacement = bool( + getattr(config, "raw_state_replacement", False) + ) + self.question_anchored = bool( + getattr(config, "raw_question_anchor", False) + ) + # Compatibility only: old raw-E/Q checkpoints used a random d->d->d + # FFN after every read and did not serialize a feature flag. Their + # configs resolve raw_transition_ffn=None to True; all new launches + # explicitly set it False and allocate neither module nor parameters. + self.use_ffn = bool(getattr(config, "raw_transition_ffn", True)) + if self.use_ffn: + self.ffn_norm = Qwen3VLTextRMSNorm(d, eps=text_cfg.rms_norm_eps) + self.ffn = nn.Sequential( + nn.Linear(d, config.workspace_ffn_mult * d), + nn.GELU(), + nn.Dropout(config.workspace_dropout), + nn.Linear(config.workspace_ffn_mult * d, d), + ) + + def forward( + self, + r: torch.Tensor, # [B,N_q,d] + evidence: torch.Tensor | None = None, # [B,N_v,d] == raw V* + evidence_padding_mask: torch.Tensor | None = None, + question_padding_mask: torch.Tensor | None = None, + read_scale: float = 1.0, # held-out intervention only + projected_evidence: tuple[torch.Tensor, torch.Tensor] | None = None, + question_anchor: torch.Tensor | None = None, + ) -> torch.Tensor: + """Apply one recurrent update. + + ``projected_evidence`` contains head-split K/V tensors returned by + :meth:`project_evidence`. Since raw E is immutable across recurrent + steps, the normal rollout computes these once and reuses them. This is + algebraically identical to calling ``nn.MultiheadAttention`` four + times, but avoids repeating the dominant ``N_v x d x d`` K/V + projections for every step. + """ + if projected_evidence is None: + if evidence is None: + raise ValueError("evidence or projected_evidence is required") + projected_evidence = self.project_evidence(evidence) + k, v = projected_evidence # [B,H,N_v,D_h] + + q_norm = self.read_norm_q(r) + d = q_norm.shape[-1] + q = self._split_heads(self.q_proj(q_norm), self.num_heads) + + attn_mask = None + if evidence_padding_mask is not None: + # SDPA's additive mask uses -inf for keys that must be ignored. + attn_mask = torch.zeros( + evidence_padding_mask.shape[0], + 1, + 1, + evidence_padding_mask.shape[1], + dtype=q.dtype, + device=q.device, + ).masked_fill( + evidence_padding_mask[:, None, None, :], float("-inf") + ) + delta = nn.functional.scaled_dot_product_attention( + q, + k, + v, + attn_mask=attn_mask, + dropout_p=(self.dropout if self.training else 0.0), + is_causal=False, + enable_gqa=True, + ) + delta = delta.transpose(1, 2).contiguous().view(r.shape[0], r.shape[1], d) + delta = self.o_proj(delta) + if self.state_replacement: + r_next = float(read_scale) * delta + if self.question_anchored: + if question_anchor is None: + raise ValueError( + "question_anchor is required by raw_question_anchor" + ) + # Preserve Q* but replace the visual response. There is no + # R_{k-1} residual, so the old identity carrier stays removed. + r_next = question_anchor + r_next + else: + r_next = r + float(read_scale) * delta + if self.use_ffn: + r_next = r_next + self.ffn(self.ffn_norm(r_next)) + if question_padding_mask is not None: + # Padding rows are physical cache plumbing only. Keeping them + # unchanged makes batched and per-item recurrent states agree. + padding_value = ( + question_anchor + if self.question_anchored and question_anchor is not None + else r + ) + r_next = torch.where( + question_padding_mask.unsqueeze(-1), padding_value, r_next + ) + return r_next + + def _split_heads(self, x: torch.Tensor, num_heads: int) -> torch.Tensor: + b, n, d = x.shape + return x.view(b, n, num_heads, d // num_heads).transpose(1, 2) + + def project_evidence( + self, evidence: torch.Tensor + ) -> tuple[torch.Tensor, torch.Tensor]: + """Normalize and project immutable E once for a recurrent rollout.""" + e = self.read_norm_e(evidence) + k = self.k_proj(e) + v = self.v_proj(e) + return ( + self._split_heads(k, self.num_key_value_heads), + self._split_heads(v, self.num_key_value_heads), + ) + + +#: Backbone projections the post-``l*`` adapter attaches to. +ADAPTER_TARGET_MODULES = ( + "q_proj", + "k_proj", + "v_proj", + "o_proj", + "gate_proj", + "up_proj", + "down_proj", +) + + +def build_adapter_config(config: CloseQwen3VLConfig): + """LoRA config restricted to the decoder layers above ``l*``. + + peft rather than a hand-rolled adapter: a hook-attached delta holds a + closure over its target module, which is fragile under ``device_map`` + sharding and FSDP -- exactly the settings this will train in. peft's + ``layers_to_transform`` expresses the "above ``l*`` only" restriction + directly, and its save/load path is already Trainer-compatible. + """ + from peft import LoraConfig + + upper = config.upper_slice + excl = set(getattr(config, "adapter_exclude_layers", []) or []) + if getattr(config, "recurrence_only_adapter", False): + target_layers = [int(config.ell_star) + 1] + else: + target_layers = [ + li for li in range(upper.start, upper.stop) if li not in excl + ] + return LoraConfig( + r=config.adapter_rank, + lora_alpha=config.adapter_alpha, + lora_dropout=config.adapter_dropout, + bias="none", + target_modules=list(ADAPTER_TARGET_MODULES), + layers_to_transform=target_layers, + layers_pattern="layers", + task_type=None, + ) + + +# --------------------------------------------------------------------------- +# output type +# --------------------------------------------------------------------------- + + +@dataclass +class CloseCausalLMOutput(ModelOutput): + """Adds the workspace trajectory, which the Trainer needs for ``L_traj``.""" + + loss: torch.FloatTensor | None = None + logits: torch.FloatTensor | None = None + past_key_values: Cache | None = None + workspace_states: tuple[torch.FloatTensor, ...] | None = None # K+1 x [B, S, d_w] + #: LN'd masked-mean of V*, detached -- the target side of the Trainer's + #: read-grounding InfoNCE. Never fed back into the model. + v_star_pooled: torch.FloatTensor | None = None # [B, d] + #: bridge-only interface distillation 항 (advisor): 1-cos(LN h_R, LN h_base). + #: 학습 시에만 채워짐. gradient는 workspace_out·splice_gain·l*+1층 adapter 한정. + iface_loss: torch.FloatTensor | None = None + #: Training-only normalized state-restoration loss. Its target is the + #: image-conditioned question state from the frozen multimodal lower pass; + #: inference never receives that state. + functional_state_loss: torch.FloatTensor | None = None + #: Training-only score gap for the matched residual-swap intervention: + #: normalized log p(y_i | B_i + C_i) - log p(y_i | B_i + C_donor). + residual_swap_score_gap: torch.FloatTensor | None = None # [B] + residual_swap_valid: torch.BoolTensor | None = None # [B] + #: 1-indexed CRR transition selected by the transition-aware intervention. + residual_swap_transition: torch.LongTensor | None = None # scalar + #: Training-only gold-answer NLL at each decoded CRR prefix + #: Z_k = B + mean(C_1..C_k). Every column is scored by the same actual + #: upper decoder; the Trainer applies a one-sided no-regression objective. + step_answer_nll: torch.FloatTensor | None = None # [B, T] + step_answer_valid: torch.BoolTensor | None = None # [B, T] + #: RL-only greedy generations sampled from a stochastic CRR trajectory. + #: Ordinary SFT/inference calls leave every field below unset. + generated_ids: torch.LongTensor | None = None # [B, <=N_a] + #: Frozen-decoder, token-normalized gold-answer log likelihood for a + #: sampled CRR trajectory. This is an environment score only: it is + #: computed under no_grad and cannot form a pathwise decoder gradient. + latent_policy_gold_logprob: torch.FloatTensor | None = None # [B] + #: The same frozen gold-answer score at every decoded CRR prefix + #: Z_k = B + mean(C_1..C_k). This is populated only when latent RL asks + #: for transition-aware credit assignment; its last column is exactly + #: ``latent_policy_gold_logprob``. + latent_policy_step_gold_logprob: torch.FloatTensor | None = None # [B, T] + #: Dimension-normalized Gaussian score log-density for C2..CT. + latent_policy_log_probs: torch.FloatTensor | None = None # [B, T-1] + latent_policy_mean_update_rms: torch.FloatTensor | None = None # [B, T-1] + latent_policy_noise_rms: torch.FloatTensor | None = None # [B, T-1] + + +@dataclass +class LatentPolicyTrace: + """Training-only score-function trace for stochastic CRR transitions. + + The sampled recurrent states themselves are detached. Consequently the + only differentiable quantity returned to the RL Trainer is ``log_probs``; + answer generation and reward computation cannot create a pathwise gradient + through the frozen decoder. + """ + + log_probs: torch.FloatTensor # [B, T-1] + mean_update_rms: torch.FloatTensor # [B, T-1], relative to C1 RMS + noise_rms: torch.FloatTensor # [B, T-1], relative to C1 RMS + + +class CRRLatentPolicyAdapter(nn.Module): + """Zero-init low-rank correction used only by CRR states ``C2..CT``. + + This is intentionally narrower than adapting the shared native L21: L21 + also constructs B/C1 and processes generated answer tokens. A separate + latent-only adapter keeps those competence-critical paths frozen and makes + the score-function action policy the only behavior changed by RL. + """ + + def __init__(self, width: int, rank: int, alpha: float): + super().__init__() + if rank < 1: + raise ValueError("CRRLatentPolicyAdapter rank must be >= 1") + self.down = nn.Linear(width, rank, bias=False) + self.up = nn.Linear(rank, width, bias=False) + self.scaling = float(alpha) / float(rank) + + def forward(self, state: torch.Tensor) -> torch.Tensor: + return state + self.up(self.down(state)) * self.scaling + + +class CRRFeaturewiseAggregator(nn.Module): + """Native-basis-preserving linear aggregation over recurrent depth. + + This is the block-diagonal special case of ``Linear(T * d, d)``: block + ``k`` is a learned diagonal matrix instead of a dense ``d x d`` matrix. + It learns feature-wise depth selection while preserving the pretrained + hidden basis and keeping cross-node gradient synchronization negligible. + """ + + def __init__(self, steps: int, width: int): + super().__init__() + if steps < 1 or width < 1: + raise ValueError("steps and width must both be positive") + self.steps = int(steps) + self.width = int(width) + self.weight = nn.Parameter(torch.empty(self.steps, self.width)) + self.reset_parameters() + + def reset_parameters(self) -> None: + with torch.no_grad(): + self.weight.fill_(1.0 / self.steps) + + def forward(self, concatenated: torch.Tensor) -> torch.Tensor: + expected = self.steps * self.width + if concatenated.shape[-1] != expected: + raise ValueError( + f"expected concatenated width {expected}, got " + f"{concatenated.shape[-1]}" + ) + states = concatenated.reshape( + *concatenated.shape[:-1], self.steps, self.width + ) + return (states * self.weight).sum(dim=-2) + + +# --------------------------------------------------------------------------- +# the model +# --------------------------------------------------------------------------- + + +class CloseQwen3VLForConditionalGeneration(Qwen3VLPreTrainedModel): + def _init_weights(self, module): + super()._init_weights(module) + if isinstance(module, CRRFeaturewiseAggregator): + module.reset_parameters() + # ER v2.2 C: aggregation 파라미터 init. post_init 스윕(from_backbone)과 + # missing-key 초기화(from_pretrained) 모두 이 훅을 지나므로 두 로드 + # 경로에서 동일 값이 보장된다 (__init__ 직접 대입은 meta 경유에서 유실). + if module is self and getattr( + self.config, "concat_aggregation", False + ) and hasattr(self, "reasoning_aggregator"): + # 개정 init (advisor 2026-08-12): W = [I/T, ..., I/T] — step 0에서 + # R_agg = mean(R1..RT) 정확히. (구판 [0,..,0,I]는 폐기; random 금지.) + # I/4 = 0.25는 bf16 정확 표현 — 반올림 오차 없음. 네 블록은 이후 + # answer CE로 독립 분화. + _T = int(self.config.num_workspace_steps) + d_w = int(self.reasoning_aggregator.out_features) + with torch.no_grad(): + w = self.reasoning_aggregator.weight # [D, T*D] + w.zero_() + _eye = torch.eye(d_w, dtype=w.dtype, device=w.device) / _T + for _i in range(_T): + w[:, _i * d_w:(_i + 1) * d_w].copy_(_eye) + if module is self and getattr(self.config, "temporal_aggregation", False) \ + and hasattr(self, "temporal_logits"): + _T = int(self.config.num_workspace_steps) + _w0 = torch.tensor([0.4, 0.3, 0.2, 0.1][:_T]) if _T <= 4 \ + else torch.full((_T,), 1.0 / _T) + _w0 = _w0 / _w0.sum() + with torch.no_grad(): + self.temporal_logits.copy_(_w0.log().to(self.temporal_logits.dtype)) + if module is self and hasattr(self, "crr_policy_adapter"): + # Exact identity at the SFT -> latent-RL handoff. Down follows the + # backbone initializer; zero Up is the standard LoRA-style start. + with torch.no_grad(): + self.crr_policy_adapter.up.weight.zero_() + + """CLOSE: Qwen3-VL with its upper visual path replaced by a read-once workspace.""" + + config_class = CloseQwen3VLConfig + _no_split_modules = ["Qwen3VLTextDecoderLayer", "Qwen3VLVisionBlock"] + + + def __init__(self, config: CloseQwen3VLConfig, backbone=None): + super().__init__(config) + config.validate() + config._require_ell_star() + + # The host model. Pass an already-loaded one via :meth:`from_backbone`; + # `CloseQwen3VLForConditionalGeneration.from_pretrained()` + # does NOT work, because base checkpoint keys are "model.*" / "lm_head.*" + # while this tree needs "backbone.model.*". transformers only warns about + # that, leaving all 8.3B parameters random and every forward NaN. + self.backbone = ( + backbone + if backbone is not None + else Qwen3VLForConditionalGeneration._from_config(config) + ) + + raw_eq = bool(getattr(config, "raw_evidence_question", False)) + crr = bool( + getattr(config, "counterfactual_residual_recurrence", False) + ) + q_state_interface = raw_eq or crr + if raw_eq: + # Replacement, not an addition: the raw-evidence path allocates no + # slot reader, slot transition, evidence slots, reasoning slots, + # mid-read module, or learned trajectory aggregator. + self.raw_eq_transition = RawEvidenceQuestionTransition(config) + self.workspace_out = nn.Identity() + elif crr: + # CRR reuses the native L(ell_star+1) module itself. No copied + # transition, visual memory, slots, projection, or aggregation is + # allocated. RL checkpoints may add one shared low-rank correction + # that is called only for C2..CT, never for B/C1/token decoding. + self.workspace_out = nn.Identity() + if getattr(config, "crr_concat_aggregation", False): + width = int(config.backbone_width) + steps = int(config.num_workspace_steps) + self.reasoning_aggregator = CRRFeaturewiseAggregator( + steps, width + ) + if int(getattr(config, "crr_policy_rank", 0)) > 0: + self.crr_policy_adapter = CRRLatentPolicyAdapter( + config.backbone_width, + int(config.crr_policy_rank), + float(config.crr_policy_alpha), + ) + else: + self.workspace_read = WorkspaceRead(config) + self.workspace_transition = WorkspaceTransition(config) + self.workspace_out = nn.Linear( + config.workspace_width, config.backbone_width + ) + # -- ER v2 (explicit evidence read; advisor 2026-08-11) -------------- + # R이 E를 별도 memory로 읽는 pre-norm cross-attn 잔차 + 질문 조건화 R^0. + if getattr(config, "explicit_evidence_read", False): + # ER v2 canonical (§5): R이 persistent E를 읽는 명시적 cross-attention. + # query=LN(R), key/value=LN(E). 전 step 공유 (step별 복제 금지, §14). + assert config.workspace_width == config.backbone_width, ( + "explicit_evidence_read는 native width(d_w == backbone d) 전제 (§2)") + d_w = config.workspace_width + self.evidence_read_norm_q = nn.LayerNorm(d_w) + self.evidence_read_norm_kv = nn.LayerNorm(d_w) + self.evidence_read = nn.MultiheadAttention( + d_w, config.workspace_num_heads, dropout=0.0, batch_first=True) + # §8: 차원-변환 전용 P_Z는 native width에서 Identity. + # interface 보정 수신자 = splice_gain + post-l* LoRA (§9.5에서 검증). + self.workspace_out = nn.Identity() + if getattr(config, "r_init_question", False): + d_w = config.workspace_width + # Legacy config name, current semantics: R^0 is learnable slots + # only. Q* first enters through the shared transition. + self.reasoning_slots = nn.Parameter( + torch.randn(config.num_workspace_slots, d_w) * 0.02 + ) + if getattr(config, "concat_aggregation", False): + # ER v2.2 C 확정판 (§6-8): R_agg = Linear(Concat(R1..RT, dim=hidden)). + # W ∈ [D, T·D], bias 없음. 초기 W=[I/T,...,I/T], so + # R_agg=mean(R1..RT). Temporal softmax/gates are absent. + assert getattr(config, "er_single_chain", False), ( + "concat_aggregation은 er_single_chain=True 전제 (§12)") + _T = int(config.num_workspace_steps) + d_w = config.workspace_width + self.reasoning_aggregator = nn.Linear(_T * d_w, d_w, bias=False) + # 값(zero + 마지막 블록 Identity)은 _init_weights(self)에서 설정 — + # __init__ 직접 대입은 from_pretrained meta 경유에서 유실 (실증됨). + if getattr(config, "temporal_aggregation", False): + # (legacy — softmax 변형; C 확정판에서 미사용, §7 금지 목록) + assert getattr(config, "er_single_chain", False), ( + "temporal_aggregation은 er_single_chain=True 전제 (§12)") + assert not getattr(config, "concat_aggregation", False), "동시 사용 금지" + _T = int(config.num_workspace_steps) + self.temporal_logits = nn.Parameter(torch.zeros(_T)) + # Placeholder embedding for the S slot positions in the text-only prefill. + # Cache-shape plumbing below ell*: these rows are overwritten by R_agg + # before the upper decoder. They are a fixed text-only placeholder, not + # an extra learned prompt outside the declared workspace/LoRA method. + if not q_state_interface: + self.slot_embedding = nn.Parameter( + torch.randn(config.num_workspace_slots, config.backbone_width) * 0.02, + requires_grad=False, + ) + # RMS splice gain is retained for historical interface-loss checkpoints. + # In the current answer-only method it is a fixed 1, not an undeclared + # trainable decoder-interface scalar (and bf16 updates near 1 would be + # numerically ineffective at the configured learning rate anyway). + self.splice_gain = ( + nn.Parameter(torch.ones(1), requires_grad=bool(config.interface_loss)) + if ( + not q_state_interface + and getattr(config, "splice_scale_match", False) + ) + else None + ) + + self.post_init() + if raw_eq: + self._initialize_raw_attention_from_upper_layer() + + # peft wraps the targeted Linears in place, so this must run after + # post_init: the adapters start as an exact no-op (B initialised to 0) + # and must not be re-initialised by the backbone's weight init. + self.adapter_config = None + if config.adapter_rank > 0: + from peft import inject_adapter_in_model + + self.adapter_config = build_adapter_config(config) + # Inject at `backbone`, not `text_model`: peft matches layer indices + # with `.*\.layers\.(\d+)\.`, which needs a dot before "layers". + # From text_model the keys start "layers.0..." and nothing matches; + # from backbone they are "model.language_model.layers.0...". + inject_adapter_in_model(self.adapter_config, self.backbone) + if getattr(config, "recurrence_only_adapter", False): + from peft.tuners.tuners_utils import BaseTunerLayer + + for module in self.backbone.modules(): + if isinstance(module, BaseTunerLayer): + module._disable_adapters = True + + # Qwen3's dynamic number of patches makes cuDNN repeatedly compile a + # Conv3d plan. The kernel covers exactly one non-overlapping patch, so + # the equivalent linear evaluation is both state-free and faster. + self.vl.visual.patch_embed.forward = ( + _linear_qwen3_vision_patch_embed_forward.__get__( + self.vl.visual.patch_embed, + type(self.vl.visual.patch_embed), + ) + ) + + def _initialize_raw_attention_from_upper_layer(self) -> None: + """Copy the first upper decoder layer's visual-read-compatible basis. + + L(ell_star+1) is pretrained to consume layer-ell_star hidden states, + exactly the feature space occupied by both Q* and V*. Cross-attention + remains position-free, but its Q/K/V/O maps and adjacent RMSNorms start + from that decoder layer instead of an unrelated random basis. + + This runs after ``post_init`` (so HF initialization cannot overwrite + the copy) and before LoRA injection (so source projections are plain + Linears and the raw transition receives only the frozen base weights). + """ + source_idx = int(getattr(self.config, "raw_attention_source_layer", -1)) + if source_idx < 0: + source_idx = int(self.config.ell_star) + 1 + source_layer = self.text_model.layers[source_idx] + source_attn = source_layer.self_attn + target = self.raw_eq_transition + pairs = ( + (target.q_proj, source_attn.q_proj), + (target.k_proj, source_attn.k_proj), + (target.v_proj, source_attn.v_proj), + (target.o_proj, source_attn.o_proj), + ) + with torch.no_grad(): + for dst, src in pairs: + if dst.weight.shape != src.weight.shape: + raise ValueError( + f"raw GQA/source shape mismatch at L{source_idx}: " + f"{tuple(dst.weight.shape)} vs {tuple(src.weight.shape)}" + ) + dst.weight.copy_(src.weight) + if dst.bias is not None: + if src.bias is None: + raise ValueError("raw GQA/source bias mismatch") + dst.bias.copy_(src.bias) + elif src.bias is not None: + raise ValueError("raw GQA/source bias mismatch") + target.read_norm_q.weight.copy_(source_layer.input_layernorm.weight) + target.read_norm_e.weight.copy_(source_layer.input_layernorm.weight) + if target.use_ffn: + target.ffn_norm.weight.copy_( + source_layer.post_attention_layernorm.weight + ) + self.raw_attention_source_layer = source_idx + + @classmethod + def from_backbone(cls, pretrained: str, config: CloseQwen3VLConfig, **kwargs): + """Build CLOSE around a real Qwen3-VL checkpoint. + + The backbone is loaded first and handed to ``__init__`` so its weights + are never routed through this class's key namespace. Use this, not + ``from_pretrained``, when starting from the base model; + ``from_pretrained`` remains correct for reloading a *saved CLOSE* + checkpoint, whose keys already match. + """ + backbone = Qwen3VLForConditionalGeneration.from_pretrained(pretrained, **kwargs) + model = cls(config, backbone=backbone) + return model.to(backbone.dtype) + + # -- convenience views ------------------------------------------------- + + @property + def vl(self): + """``Qwen3VLModel`` (visual + language_model).""" + return self.backbone.model + + @property + def text_model(self): + """``Qwen3VLTextModel``.""" + return self.backbone.model.language_model + + def _uses_question_state_interface(self) -> bool: + """Whether the replacement prefix has exactly the question rows.""" + return bool( + getattr(self.config, "raw_evidence_question", False) + or getattr( + self.config, "counterfactual_residual_recurrence", False + ) + ) + + def _upper_decoder_start(self) -> int: + """First layer that consumes the image-dependent decoder latent. + + CRR turns L(ell_star+1) into its shared recurrent operator. That layer + still processes answer tokens, but its prefix KV is populated only by + the text-only scaffold; the image-dependent prefix starts at the next + layer. Historical methods continue to splice at ell_star+1. + """ + return int(self.config.ell_star) + ( + 2 + if getattr( + self.config, "counterfactual_residual_recurrence", False + ) + else 1 + ) + + # -- parameter freezing ------------------------------------------------ + + def freeze_backbone(self) -> dict[str, int]: + """Freeze everything, then open exactly the declared method parameters. + + §3.3: "Vision encoder와 기본 backbone은 동결하고 workspace module, + projection, post-l* low-rank adapter만 학습". + + LoRA parameters live *inside* ``backbone`` after peft injection, so they + are re-enabled by name after the blanket freeze. + """ + for p in self.parameters(): + p.requires_grad_(False) + for module in ( + getattr(self, "workspace_read", None), + getattr(self, "workspace_transition", None), + getattr(self, "raw_eq_transition", None), + self.workspace_out, + ): + if module is None: + continue + for p in module.parameters(): + p.requires_grad_(True) + for name in ( + "evidence_read_norm_q", + "evidence_read_norm_kv", + "evidence_read", + "reasoning_aggregator", + ): + module = getattr(self, name, None) + if module is not None: + for p in module.parameters(): + p.requires_grad_(True) + for name in ("reasoning_slots", "temporal_logits"): + p = getattr(self, name, None) + if p is not None: + p.requires_grad_(True) + if self.splice_gain is not None and getattr(self.config, "interface_loss", False): + self.splice_gain.requires_grad_(True) + + n_lora = 0 + for name, p in self.backbone.named_parameters(): + if "lora_" in name: + p.requires_grad_(True) + n_lora += p.numel() + + n_train = sum(p.numel() for p in self.parameters() if p.requires_grad) + n_total = sum(p.numel() for p in self.parameters()) + return {"trainable": n_train, "total": n_total, "lora": n_lora} + + def _aggregate_crr_residuals( + self, residual_steps: list[torch.Tensor] | tuple[torch.Tensor, ...] + ) -> torch.Tensor: + """Aggregate CRR states with a mean-compatible concat projection.""" + steps = list(residual_steps) + if not steps: + raise ValueError("CRR aggregation requires at least one residual") + horizon = int(self.config.num_workspace_steps) + if len(steps) > horizon: + raise ValueError( + f"received {len(steps)} CRR states for horizon {horizon}" + ) + if not getattr(self.config, "crr_concat_aggregation", False): + return torch.stack(steps, dim=0).mean(dim=0) + if not hasattr(self, "reasoning_aggregator"): + raise RuntimeError( + "crr_concat_aggregation=True but reasoning_aggregator is missing" + ) + if len(steps) < horizon: + steps.extend(torch.zeros_like(steps[0]) for _ in range(horizon - len(steps))) + aggregate = self.reasoning_aggregator(torch.cat(steps, dim=-1)) + prefix_steps = len(residual_steps) + if prefix_steps < horizon: + aggregate = aggregate * (horizon / prefix_steps) + return aggregate + + def decoder_latent_from_states( + self, states: list[torch.Tensor] | tuple[torch.Tensor, ...] + ) -> torch.Tensor: + """Return the exact pre-interface latent decoded by the current model. + + Keeping this operation on the model prevents evaluation scripts from + silently targeting a stored state that the selected interface does not + actually decode. Residual Raw-E/Q returns ``R_T``; state replacement + returns ``Q* + H_T``; question-anchored replacement returns ``Z_T``; + historical concat models reconstruct their learned ``R_agg``. + """ + if not states: + raise ValueError("states must not be empty") + if getattr( + self.config, "counterfactual_residual_recurrence", False + ): + # states = [B, C1, ..., CT], where B=F(Q_text) is strictly + # image-independent and every Ck is an image-conditioned residual. + if len(states) != self.config.num_workspace_steps + 1: + raise ValueError( + f"expected anchor plus {self.config.num_workspace_steps} " + f"counterfactual residuals, got {len(states)} tensors" + ) + residual_steps = list(states[1:]) + # Evaluation-only prefix sweep. Step k uses the same interface + # trained by L_step_keep: Z_k = B + mean(C_1..C_k), rather than + # decoding C_k alone. Normal training/inference has no override. + k_ov = int(getattr(self.config, "er_step_override", 0) or 0) + if k_ov: + if not 1 <= k_ov <= self.config.num_workspace_steps: + raise ValueError( + "er_step_override must be in " + f"[1,{self.config.num_workspace_steps}]" + ) + residual_steps = residual_steps[:k_ov] + if getattr( + self.config, "visual_cumulative_recurrence", False + ): + # Full-state visual CRR stores C_k = R_k - B and decodes the + # actual final recurrent state without temporal averaging. + return states[0] + residual_steps[-1] + return states[0] + self._aggregate_crr_residuals(residual_steps) + if getattr(self.config, "raw_evidence_question", False): + # states = [R0, R1, ..., RT] in residual mode and + # [Q*, H1, ..., HT] in state-replacement mode. + if len(states) != self.config.num_workspace_steps + 1: + raise ValueError( + f"expected R0 plus {self.config.num_workspace_steps} recurrent " + f"states, got {len(states)} tensors" + ) + if getattr(self.config, "raw_mean_aggregation", False): + return torch.stack(list(states[1:]), dim=0).mean(dim=0) + if getattr(self.config, "raw_state_replacement", False): + if getattr(self.config, "raw_question_anchor", False): + return states[-1] + return states[0] + states[-1] + return states[-1] + if not getattr(self.config, "evidence_reasoning", False): + return states[-1] + r_steps = list(states[1:]) # states[0] is persistent evidence E0 + if getattr(self.config, "concat_aggregation", False): + if len(r_steps) != self.config.num_workspace_steps: + raise ValueError( + f"expected {self.config.num_workspace_steps} reasoning states, " + f"got {len(r_steps)}" + ) + return self.reasoning_aggregator(torch.cat(r_steps, dim=-1)) + if getattr(self.config, "temporal_aggregation", False): + a = torch.softmax(self.temporal_logits.float(), dim=0).to(r_steps[0].dtype) + return torch.stack(r_steps, dim=0).mul( + a.view(-1, *([1] * r_steps[0].ndim)) + ).sum(dim=0) + return states[-1] + + # -- §3.2 pipeline ----------------------------------------------------- + + @contextmanager + def _recurrence_adapter_execution(self, enabled: bool): + """Toggle the single recurrent LoRA without changing trainability.""" + if not getattr(self.config, "recurrence_only_adapter", False): + yield + return + from peft.tuners.tuners_utils import BaseTunerLayer + + modules = [ + module + for module in self.backbone.modules() + if isinstance(module, BaseTunerLayer) + ] + previous = [module.disable_adapters for module in modules] + for module in modules: + module._disable_adapters = not enabled + try: + yield + finally: + for module, was_disabled in zip(modules, previous): + module._disable_adapters = was_disabled + + @torch.no_grad() + def _set_adapters_enabled(self, enabled: bool) -> None: + """post-l* LoRA on/off (iface teacher는 frozen-base 동작점 필요).""" + from peft.tuners.tuners_utils import BaseTunerLayer + for m in self.backbone.modules(): + if isinstance(m, BaseTunerLayer): + m.enable_adapters(enabled) + + def _encode_visual_memory( + self, + input_ids: torch.LongTensor, # [B, L_mm] multimodal sequence + pixel_values: torch.Tensor, + image_grid_thw: torch.LongTensor, + attention_mask: torch.Tensor | None, + ) -> tuple[torch.Tensor, torch.Tensor]: + """``V* = Pi_img(F^mm_{<=l*}(I, q))``, right-padded. ``[B, N_v, d]``. + + Runs under ``no_grad``: the vision encoder and lower backbone are frozen, + and nothing above needs gradients through them. + """ + embeds, pos, visual_mask, deepstack = embed_multimodal( + self.vl, + input_ids, + pixel_values, + image_grid_thw, + attention_mask, + return_deepstack=True, + ) + ctx = make_split_context( + self.text_model, + embeds, + pos, + attention_mask, + visual_pos_masks=visual_mask, + deepstack_visual_embeds=deepstack, + ) + lower = run_layer_range(self.text_model, ctx, 0, self.config.ell_star + 1) + crr = bool( + getattr( + self.config, "counterfactual_residual_recurrence", False + ) + ) + capture_functional = bool( + self.training + and getattr(self.config, "functional_state_loss", False) + ) + if crr or capture_functional: + # Free target from the same lower pass that produced V*: normal + # multimodal question states at the measured read boundary. The + # oracle shows that these rows alone retain base V* competence in a + # Q-only upper context. Store detached, padded rows for the + # training-only split-state restoration objective. + text_mask = ~vision_span_mask(input_ids, self.config) + if attention_mask is not None: + text_mask &= attention_mask > 0 + q_mm, q_mm_pad = select_tokens_padded(lower, text_mask) + q_mm_ids, _ = select_tokens_padded(input_ids.unsqueeze(-1), text_mask) + q_mm = q_mm.detach() + q_mm_ids = q_mm_ids.squeeze(-1) + if crr: + # Consume-once factual boundary state. It is the sole image + # input to CRR; raw visual rows are not materialized below. + self._counterfactual_q_state = q_mm + self._counterfactual_q_pad = q_mm_pad + self._counterfactual_q_ids = q_mm_ids + if getattr( + self.config, "visual_counterfactual_recurrence", False + ): + self._visual_counterfactual_bundle = ( + lower.detach(), + ctx, + text_mask, + ) + if capture_functional: + self._functional_q_target = q_mm + self._functional_q_target_pad = q_mm_pad + self._functional_q_target_ids = q_mm_ids + if self.training and getattr(self.config, "interface_loss", False): + # iface teacher: frozen-base 정상(mm) 경로의 q_end hidden @ l*+1. + # adapter를 꺼 base 동작점을 재현 — 학습 target일 뿐 student + # forward에 입력되지 않으므로 새 causal bypass 없음 (advisor). + self._set_adapters_enabled(False) + try: + h1 = run_layer_range( + self.text_model, ctx, self.config.ell_star + 1, + self.config.ell_star + 2, hidden_states=lower) + finally: + self._set_adapters_enabled(True) + if attention_mask is not None: + idx = attention_mask.sum(1).long() - 1 # [B] 마지막 non-pad + else: + idx = torch.full( + (lower.shape[0],), lower.shape[1] - 1, + dtype=torch.long, device=lower.device) + self._iface_h_base = h1[ + torch.arange(lower.shape[0], device=lower.device), idx + ].detach() # [B, d] + if crr: + # The multimodal lower pass is needed for Q_mm, but CRR has no V* + # memory. Returning an empty tensor prevents an accidental raw + # visual path while preserving the historical helper signature. + return ( + lower.new_empty(lower.shape[0], 0, lower.shape[-1]), + torch.empty( + lower.shape[0], 0, dtype=torch.bool, device=lower.device + ), + ) + mask = image_token_mask(input_ids, self.config.image_token_id) + return select_tokens_padded(lower, mask) # [B, N_v, d], [B, N_v] + + def _prefill_text_branch( + self, + question_ids: torch.LongTensor, # [B, N_q] no image tokens + question_mask: torch.Tensor | None, + ): + """Run the text-only lower branch through ``F_{<=l*}``, caching KV. + + Returns ``(q_star, ctx, cache)`` where ``q_star`` is ``[B, N_q, d]`` and + ``ctx.hidden_states`` holds the lower-branch state. The raw-evidence + path uses exactly the question rows; historical slot paths append their + fixed cache-shape placeholders. + """ + cfg = self.config + b, n_q = question_ids.shape + + embeds = self.vl.get_input_embeddings()(question_ids) # [B, N_q, d] + if question_mask is None: + question_mask = torch.ones(b, n_q, dtype=torch.long, device=embeds.device) + + if self._uses_question_state_interface(): + # No placeholders and no duplicated Q prefix. Logical text RoPE + # positions remain invariant to right-padding in a batch. + keep = question_mask > 0 + one_d = keep.long().cumsum(dim=-1) - 1 + one_d = one_d.masked_fill(~keep, 0) + position_ids = one_d.unsqueeze(0).expand(3, -1, -1) + cache = DynamicCache(config=self.text_model.config) + ctx = make_split_context( + self.text_model, + embeds, + position_ids, + question_mask, + past_key_values=cache, + ) + lower = run_layer_range( + self.text_model, ctx, 0, cfg.ell_star + 1, use_cache=True + ) + ctx.hidden_states = lower + return lower, ctx, cache + + s = cfg.num_workspace_slots + slots = self.slot_embedding.unsqueeze(0).expand(b, -1, -1).to(embeds.dtype) + embeds = torch.cat([embeds, slots], dim=1) # [B, N_q + S, d] + + # Physical rows are [right-padded q; slots], but each item's slots get + # logical RoPE positions immediately after its own real question. + full_mask, position_ids, _ = _replacement_prefix_layout(question_mask, s) + + cache = DynamicCache(config=self.text_model.config) + ctx = make_split_context( + self.text_model, embeds, position_ids, full_mask, past_key_values=cache + ) + lower = run_layer_range( + self.text_model, ctx, 0, cfg.ell_star + 1, use_cache=True + ) # [B, N_q + S, d] + ctx.hidden_states = lower + return lower[:, :n_q], ctx, cache + + def _decode_token_block( + self, + embeds: torch.Tensor, # [B,N_a,d] + position_ids: torch.LongTensor, # [3,B,N_a] + lower_attention_mask: torch.Tensor, # [B,N_q+S+N_a], slots masked + upper_attention_mask: torch.Tensor, # [B,N_q+S+N_a], slots visible + cache: Cache, + cache_position: torch.LongTensor, # [N_a], physical cache positions + return_lower: bool = False, + ) -> torch.Tensor | tuple[torch.Tensor, torch.Tensor]: + """Decode answer rows while preserving the strict split interface. + + Lower layers may attend only to the text question and preceding answer + tokens. The physical placeholder slot rows exist in their cache solely + so every layer has the same cache length, and are masked here. Upper + layers see those same positions as the overwritten ``R_agg`` rows. + """ + tm = self.text_model + lower_ctx = make_split_context( + tm, + embeds, + position_ids, + lower_attention_mask, + past_key_values=cache, + cache_position=cache_position, + ) + upper_start = self._upper_decoder_start() + hidden = run_layer_range( + tm, lower_ctx, 0, upper_start, use_cache=True + ) + upper_ctx = make_split_context( + tm, + embeds, + position_ids, + upper_attention_mask, + past_key_values=cache, + cache_position=cache_position, + ) + upper = run_layer_range( + tm, + upper_ctx, + upper_start, + None, + hidden_states=hidden, + use_cache=True, + ) + if return_lower: + return upper, hidden + return upper + + def _competitive_read(self, q_in: torch.Tensor, kv_in: torch.Tensor) -> torch.Tensor: + """ER v2.1 (advisor 2026-08-11): competitive normalization evidence read. + + 기존 ``evidence_read`` MHA의 W_qkv/W_o를 그대로 사용하되, 표준 + row-softmax(evidence 축) 대신 ① reasoning-slot 축 softmax — 각 E_j를 + 두고 R slot들이 경쟁 (Slot Attention식) — ② evidence 축 재정규화. + 새 파라미터/모듈/loss 없음. vanilla read에서는 전 slot이 같은 E_j를 + 100% 읽는 해가 무벌점이라 실제로 그 해로 붕괴했다 (smoke A/B §5). + + q_in: [B, S, d] = LN(R), kv_in: [B, N, d] = LN(E) — norm은 호출부에서. + """ + mha = self.evidence_read + d, h = mha.embed_dim, mha.num_heads + dh = d // h + w, b = mha.in_proj_weight, mha.in_proj_bias + q = nn.functional.linear(q_in, w[:d], None if b is None else b[:d]) + k = nn.functional.linear(kv_in, w[d:2 * d], None if b is None else b[d:2 * d]) + v = nn.functional.linear(kv_in, w[2 * d:], None if b is None else b[2 * d:]) + B, S, _ = q.shape + N = k.shape[1] + q = q.view(B, S, h, dh).transpose(1, 2) # [B, H, S, dh] + k = k.view(B, N, h, dh).transpose(1, 2) # [B, H, N, dh] + v = v.view(B, N, h, dh).transpose(1, 2) # [B, H, N, dh] + logits = q @ k.transpose(-1, -2) / math.sqrt(dh) # [B, H, S, N] + # 확정 구현 (advisor): log-domain competitive normalization. + # A = softmax_j(logsoftmax_s(L)) ≡ C/Σ_j C (수학적 동일; FP64 편차 6e-17) + # 이되, 확률 공간의 나눗셈이 없어 전패 slot에서도 forward/backward 유계 + # (극단 경쟁 unit test: exact=NaN, logdom max|grad| 0.68, 행합 1 유지). + # 안정화 이력: ①분모-ε → 1/ε=1e6 증폭, grad 1e10 폭발 (x1, step 2.4k) + # ②ε-분자 → ∂A/∂à = 1/Σ(Ã+ε) ≈ 1/(Nε) 증폭, 재폭발 (step 3.9k) + # ③분모 clamp → 유계지만 전패 slot 읽기 차단 (의미론 변경이라 기각) + # ④log-domain — ε/clamp/hyperparameter 없음. 경쟁 의미론 정확 보존. + log_comp = nn.functional.log_softmax(logits.float(), dim=-2) # slot 축 경쟁 + a = torch.softmax(log_comp, dim=-1) # evidence 축 재정규화 (log 공간) + a = a.to(v.dtype) + if getattr(self, "_er_attn_capture", None) is not None: + self._er_attn_capture.append(a.detach().float().cpu()) # [B, H, S, N] + out = (a @ v).transpose(1, 2).reshape(B, S, d) # [B, S, d] + return mha.out_proj(out) + + def _encode_workspace_er( + self, + e0: torch.Tensor, # [B, S, d_w] E^(0) = InitialRead(V*) + q_star: torch.Tensor, + v_star: torch.Tensor, + q_padding_mask: torch.Tensor | None, + v_padding_mask: torch.Tensor | None, + gate_overrides: list | None, + v_star_reread: torch.Tensor | None, + v_reread_padding_mask: torch.Tensor | None, + reread_zero_memory: bool, + reread_memory_overrides: list | None, + mid_step: int, + ) -> list[torch.Tensor]: + """Evidence–Reasoning 분리 — strict 버전 (advisor 2026-08-06 수정). + + decoder 입력은 **R^(T) 단독** — E 직행 우회(E→decoder)를 구조적으로 + 제거해 모든 시각 정보가 reasoning을 통과하게 강제한다: + I → V* → E → R^(1:T) → answer. + + recurrence 입력은 detach 없는 R + E (answer CE가 f_theta를 경유해 + E·reader까지 학습). gradient routing 표(advisor)는 쌍둥이-그래프로 + 구현한다: traj가 소비할 R-체인만 sg[E]로 병렬 전개 — + 값은 비트동일(dropout=0 필수), 그래프만 분리되어 + - L_ans → 전부 (reader/E/transition/adapter) [answer 체인] + - L_anchor(k=0) → initial reader·evidence proj만 [E^(0) 그래프] + - L_reason(k>=1) → transition·d_psi만 [traj 체인, sg[E]] + 이 되고 trainer/optimizer 변경이 필요 없다. + + 반환 states = [E^(0), R_t^(1..T)] (traj 체인). answer 체인의 R^(T)는 + self._er_answer_final(consume-once)로 prefill에 전달. E의 detached + 사본은 self._er_last_evidence에 남겨 probe가 쓴다. + """ + assert mid_step > 0, "ER 모드는 mid_read_step > 0 필요" + assert float(getattr(self.config, "workspace_dropout", 0.0)) == 0.0, ( + "ER 쌍둥이-체인은 dropout=0 필요 (두 체인의 값 동일성 전제)") + self._er_answer_final = None # stale-state 방지: forward 끝에서만 설정 + E = e0 + v2 = bool(getattr(self.config, "explicit_evidence_read", False)) + if getattr(self, "_debug_er_capture", False): + self._dbg_R_ans, self._dbg_R_traj = [], [] + if getattr(self.config, "r_init_question", False): + # ER v2 canonical (§4): R^0 = R_init (learnable slots, batch broadcast). + # Q* 조건화 블록/Pool 없음 — Q*는 매 transition에서 입력되므로 불필요. + # traj 체인은 stopgrad(R_init) 값 공유 (§9.3: traj -X-> reasoning_slots). + r0 = self.reasoning_slots.unsqueeze(0).expand(E.shape[0], -1, -1) + R, R_t = r0, r0.detach() + else: + R = torch.zeros_like(E) # answer 체인 (E gradient 통과) + R_t = torch.zeros_like(E) # traj 체인 (sg[E] — evidence/reader 차단) + states = [E] + + for k in range(self.config.num_workspace_steps): + if v2: + # ER v2 canonical (§5-6): R̄ = R + CrossAttn(LN(R), LN(E), LN(E)); + # R = f_theta(R̄, Q*). transition은 잔차 블록이므로 R̄를 외부에서 + # 재가산하지 않는다 (double-residual 금지, §6). + # traj 체인: evidence_read(R_t, sg(E)) — E 값은 읽되 E 생성부 차단 (§9.3). + def _read_E(r, mem): + q = self.evidence_read_norm_q(r) + kv = self.evidence_read_norm_kv(mem) + if getattr(self.config, "competitive_evidence_read", False): + return r + self._competitive_read(q, kv) + out, _ = self.evidence_read(q, kv, kv, need_weights=False) + return r + out + R = self.workspace_transition(_read_E(R, E), q_star, q_padding_mask) + if getattr(self.config, "er_single_chain", False): + # ER v2.2 C (§12): k>=1 traj 감독이 없으므로 twin 불필요 — + # states가 answer 체인을 직접 담아 L_ans가 R1..RT 전체에 + # (aggregation 경유) gradient를 보낸다 (§11). + R_t = R + else: + R_t = self.workspace_transition( + _read_E(R_t, E.detach()), q_star, q_padding_mask) + if getattr(self, "_debug_er_capture", False): + self._dbg_R_ans.append(R.detach().clone()) + self._dbg_R_traj.append(R_t.detach().clone()) + else: + x = R + E + total = self.workspace_transition(x, q_star, q_padding_mask) + R = total - E + x_t = R_t + E.detach() + total_t = self.workspace_transition(x_t, q_star, q_padding_mask) + R_t = total_t - E.detach() + if (k + 1) == mid_step: + ov = gate_overrides[k] if gate_overrides is not None else None + off_mid = ov == 0.0 + if not off_mid: + v_r, vp_r = v_star, v_padding_mask + if v_star_reread is not None: + v_r, vp_r = v_star_reread, v_reread_padding_mask + if reread_zero_memory: + v_r = torch.zeros_like(v_r) + elif (reread_memory_overrides is not None + and reread_memory_overrides[k] is not None): + v_r, vp_r = reread_memory_overrides[k] + # ER v2 (advisor): 재읽기 query는 R-only — "무엇을 다시 볼지"는 + # reasoning state가 정하고, E는 저장소 역할만. (v1 경로는 E+R 유지.) + u_q = R if v2 else (E + R) + woa = self.workspace_read.visual_residual(u_q, v_r, vp_r) + a_eff = float(getattr(self.config, "mid_read_alpha", 1.0)) + if ov is not None: + a_eff = float(ov) # eval sweep은 절대값으로 대체 + E_new = E + a_eff * woa + with torch.no_grad(): + uf = (E + R).float() + dv = (a_eff * woa).float() + den = uf.norm(dim=(-2, -1)).clamp_min(1e-6) + self.last_mid_stats_full = { + "u_mid": dv.norm(dim=(-2, -1)) / den, + "a_mid": nn.functional.cosine_similarity( + uf.flatten(1), dv.flatten(1), dim=1), + "r_mid": 1 - nn.functional.cosine_similarity( + uf.flatten(1), (E_new + R).float().flatten(1), dim=1), + "q_mid": (E_new + R).float().norm(dim=(-2, -1)) / den, + } + if E.shape[0] > 1: + def _div(xx): + pm = nn.functional.normalize( + xx.float().mean(1), dim=-1) + g = pm @ pm.T + b_ = g.shape[0] + off = g.masked_select(~torch.eye( + b_, dtype=torch.bool, device=g.device)) + return 1 - off.mean() + self.last_mid_stats_full["div_z0"] = _div( + states[0]).expand(E.shape[0]).clone() + self.last_mid_stats_full["div_z1"] = _div( + states[1] if len(states) > 1 else states[0] + ).expand(E.shape[0]).clone() + self.last_mid_stats_full["div_umid"] = _div( + E + R).expand(E.shape[0]).clone() + E = E_new + states.append(R_t) + # D_{k*->j}: read를 끈 shadow R-chain(E = E^(0) 유지) 대비 발산 — + # read 갱신이 최종 reasoning까지 남는지의 학습-중 감시. + with torch.no_grad(): + Rs = states[mid_step - 1] if mid_step > 1 else torch.zeros_like(E) + prop = [] + for j in range(mid_step, len(states)): + xs = Rs + e0.detach() + Rs = self.workspace_transition(xs, q_star, q_padding_mask) - e0.detach() + prop.append(1 - nn.functional.cosine_similarity( + states[j].float().flatten(1), Rs.float().flatten(1), dim=1)) + self.last_mid_prop_full = torch.stack(prop, dim=1) + self.last_reread_gates = [] + self.last_reread_update_mags = [] + if getattr(self.config, "concat_aggregation", False): + # ER v2.2 C 확정판 (§6/§10): R_agg = W_agg · Concat(R1..RT). + # R0/E/V* 미포함 (§10); states는 single-chain이라 answer 체인 = + # grad 경로 → L_ans가 aggregator를 거쳐 R1..RT 각각에 직접 도달 (§14). + r_steps = states[1:] # R1..RT, 각 [B, S, d_w] + r_cat = torch.cat(r_steps, dim=-1) # [B, S, T*d_w] + R_final = self.reasoning_aggregator(r_cat) # [B, S, d_w] + with torch.no_grad(): + # §18: step별 block norm ||W_k|| + cos(R_agg, R_k) + d_w = R.shape[-1] + w = self.reasoning_aggregator.weight.float() # [D, T*D] + self._er_agg_stats = { + **{f"aggW{_i + 1}": float(w[:, _i * d_w:(_i + 1) * d_w].norm()) + for _i in range(len(r_steps))}, + "agg_norm": float(R_final.float().norm(dim=(-2, -1)).mean()), + **{f"agg_cos_R{_i + 1}": float(nn.functional.cosine_similarity( + r_steps[_i].float().flatten(1), + R_final.float().flatten(1), dim=1).mean()) + for _i in range(len(r_steps))}, + } + self._er_answer_final = R_final + elif getattr(self.config, "temporal_aggregation", False): + # (legacy — softmax 변형) + a_w = torch.softmax(self.temporal_logits.float(), dim=0).to(R.dtype) # [T] + r_steps = states[1:] # R1..RT + R_final = r_steps[0] * a_w[0] + for _i in range(1, len(r_steps)): + R_final = R_final + a_w[_i] * r_steps[_i] + self._er_answer_final = R_final + else: + self._er_answer_final = R + self._er_last_evidence = E.detach() + return states + + def _encode_counterfactual_residual_recurrence( + self, + q_text: torch.Tensor, + q_mm: torch.Tensor, + q_padding_mask: torch.Tensor | None, + ctx, + visual_bundle=None, + ) -> list[torch.Tensor]: + """Run the native-layer counterfactual residual recurrence. + + ``F=L(ell_star+1)`` is called as the one shared recurrent operator. Its + text-only call populates F's prefix cache, so answer tokens may traverse + F without receiving an image-conditioned KV bypass. Every other F call + uses a cache-free context and therefore changes only the returned + question-shaped state. + + Returned states are ``[B, C1, ..., CT]``: + + ``B=F(Q_text)``, ``C1=F(Q_mm)-B``, + ``G_B(C)=F(B+C)-F(B)``, and + ``Ck=(1-beta)C{k-1}+beta*G_B(C{k-1})``. + """ + if getattr( + self.config, "visual_counterfactual_recurrence", False + ): + if visual_bundle is None: + raise RuntimeError( + "Qwen3 full-state recurrence is missing its multimodal " + "boundary scaffold" + ) + return self._encode_visual_full_state_recurrence( + q_text, + q_padding_mask, + ctx, + visual_bundle, + ) + + steps = int(self.config.num_workspace_steps) + beta = float(self.config.counterfactual_beta) + cell_index = int(self.config.ell_star) + 1 + tm = self.text_model + + # This is the only call that writes L21 prefix KV. Its input is + # strictly text-only, preserving the no-image lower-cache invariant. + base_anchor = run_layer_range( + tm, + ctx, + cell_index, + cell_index + 1, + hidden_states=q_text, + use_cache=True, + ) + + # Recurrent evaluations must neither read nor mutate the replacement + # cache. The causal mask and RoPE tables remain the same Q-only ones. + recurrent_ctx = replace(ctx, past_key_values=None) + + def cell(hidden: torch.Tensor) -> torch.Tensor: + return run_layer_range( + tm, + recurrent_ctx, + cell_index, + cell_index + 1, + hidden_states=hidden, + use_cache=False, + ) + + factual_t1 = cell(q_mm) + residual = factual_t1 - base_anchor + if q_padding_mask is not None: + residual = residual.masked_fill(q_padding_mask.unsqueeze(-1), 0) + states = [base_anchor, residual] + + if steps > 1: + fixed_anchor_next = cell(base_anchor) + for _ in range(1, steps): + proposed = cell(base_anchor + residual) - fixed_anchor_next + if beta == 1.0: + residual = proposed + elif beta == 0.0: + residual = residual + else: + residual = torch.lerp(residual, proposed, beta) + if hasattr(self, "crr_policy_adapter"): + residual = self.crr_policy_adapter(residual) + if q_padding_mask is not None: + residual = residual.masked_fill( + q_padding_mask.unsqueeze(-1), 0 + ) + states.append(residual) + + return states + + def _encode_visual_full_state_recurrence( + self, + q_text: torch.Tensor, + q_padding_mask: torch.Tensor | None, + text_ctx, + visual_bundle, + ) -> list[torch.Tensor]: + """Run the promoted full-state CRR with one native Qwen3 layer. + + ``R1`` is the frozen pretrained multimodal output. For ``k>=2``, the + same physical layer consumes persistent visual rows together with the + previous question state, and only its recurrence-only LoRA is active. + The original multimodal cache is never exposed to answer decoding. + """ + steps = int(self.config.num_workspace_steps) + beta = float(self.config.counterfactual_beta) + cell_index = int(self.config.ell_star) + 1 + tm = self.text_model + mm_lower, mm_ctx, text_rows = visual_bundle + + # Populate answer-time KV for the recurrent layer from text only. + with self._recurrence_adapter_execution(False), torch.no_grad(): + base_anchor = run_layer_range( + tm, + text_ctx, + cell_index, + cell_index + 1, + hidden_states=q_text, + use_cache=True, + ) + + normal_ctx = replace(mm_ctx, past_key_values=None) + + def full_cell(hidden: torch.Tensor) -> torch.Tensor: + return run_layer_range( + tm, + normal_ctx, + cell_index, + cell_index + 1, + hidden_states=hidden, + use_cache=False, + ) + + # Exact pretrained multimodal anchor; LoRA begins at R1 -> R2. + with self._recurrence_adapter_execution(False), torch.no_grad(): + first_full = full_cell(mm_lower) + r1, r_padding = select_tokens_padded(first_full, text_rows) + if q_padding_mask is not None and not torch.equal( + r_padding, q_padding_mask + ): + raise ValueError( + "multimodal and text-only padding disagree in Qwen3 recurrence" + ) + + residual = r1 - base_anchor + if q_padding_mask is not None: + residual = residual.masked_fill(q_padding_mask.unsqueeze(-1), 0) + states = [base_anchor, residual] + update_rms = [] + update_relative = [] + r_current = r1 + + for _ in range(1, steps): + state_before = r_current + recurrent_input = _replace_selected_rows_from_padded( + first_full, + text_rows, + r_current, + r_padding, + ) + + def recurrent_text_rows(hidden: torch.Tensor): + # The adapter context must live inside the checkpointed + # function: non-reentrant checkpoint invokes this callable + # again during backward after the outer forward scope ended. + with self._recurrence_adapter_execution(True): + full = full_cell(hidden) + return select_tokens_padded(full, text_rows) + + if self.training and torch.is_grad_enabled(): + proposed, proposed_padding = checkpoint( + recurrent_text_rows, + recurrent_input, + use_reentrant=False, + preserve_rng_state=True, + ) + else: + proposed, proposed_padding = recurrent_text_rows( + recurrent_input + ) + if not torch.equal(proposed_padding, r_padding): + raise RuntimeError("Qwen3 recurrent text-row layout changed") + transition_delta = proposed - state_before + if q_padding_mask is not None: + transition_delta = transition_delta.masked_fill( + q_padding_mask.unsqueeze(-1), 0 + ) + r_current = state_before + beta * transition_delta + residual = r_current - base_anchor + if q_padding_mask is not None: + residual = residual.masked_fill( + q_padding_mask.unsqueeze(-1), 0 + ) + states.append(residual) + + with torch.no_grad(): + valid = ( + torch.ones_like(r_padding, dtype=torch.bool) + if q_padding_mask is None + else ~q_padding_mask + ) + valid_f = valid.unsqueeze(-1).float() + denom = valid_f.sum(dim=(1, 2)).clamp_min(1.0) + applied = beta * transition_delta + delta_rms = ( + (applied.float().square() * valid_f).sum(dim=(1, 2)) + / (denom * applied.shape[-1]) + ).sqrt() + state_rms = ( + (state_before.float().square() * valid_f).sum(dim=(1, 2)) + / (denom * state_before.shape[-1]) + ).sqrt() + update_rms.append(delta_rms) + update_relative.append( + delta_rms / state_rms.clamp_min(1e-8) + ) + + self.last_recurrent_update_rms = ( + torch.stack(update_rms, dim=1) if update_rms else None + ) + self.last_recurrent_update_relative = ( + torch.stack(update_relative, dim=1) + if update_relative + else None + ) + return states + + def _counterfactual_transition_rollout_prefix( + self, + states: list[torch.Tensor] | tuple[torch.Tensor, ...], + donors: torch.LongTensor, + transition_index: int, + question_attention_mask: torch.Tensor | None, + ) -> torch.Tensor: + """Swap one CRR transition and roll the remaining causal tail forward.""" + horizon = int(self.config.num_workspace_steps) + if len(states) != horizon + 1: + raise ValueError( + f"expected B plus {horizon} CRR states, got {len(states)}" + ) + if not 1 <= int(transition_index) <= horizon: + raise ValueError( + f"reliance_transition_index must be in [1,{horizon}], " + f"got {transition_index}" + ) + batch_size, question_length = states[0].shape[:2] + if donors.shape != (batch_size,): + raise ValueError( + f"donors must have shape {(batch_size,)}, got {tuple(donors.shape)}" + ) + if question_attention_mask is None: + q_mask = torch.ones( + batch_size, + question_length, + dtype=torch.long, + device=states[0].device, + ) + else: + q_mask = question_attention_mask + q_keep = q_mask.gt(0) + aligned = q_keep & q_keep.index_select(0, donors) + + k = int(transition_index) + base_anchor = states[0].detach() + residual = states[k].index_select(0, donors).detach() + residual = residual.masked_fill(~aligned.unsqueeze(-1), 0) + hybrid_residuals = [state.detach() for state in states[1:k]] + hybrid_residuals.append(residual) + + if k < horizon: + q_pos_1d = q_keep.long().cumsum(dim=-1) - 1 + q_pos_1d = q_pos_1d.masked_fill(~q_keep, 0) + q_positions = q_pos_1d.unsqueeze(0).expand(3, -1, -1) + recurrent_ctx = make_split_context( + self.text_model, + base_anchor, + q_positions, + q_mask, + ) + cell_index = int(self.config.ell_star) + 1 + + def cell(hidden: torch.Tensor) -> torch.Tensor: + return run_layer_range( + self.text_model, + recurrent_ctx, + cell_index, + cell_index + 1, + hidden_states=hidden, + use_cache=False, + ) + + fixed_anchor_next = cell(base_anchor) + beta = float(self.config.counterfactual_beta) + q_padding_mask = ~q_keep + for _ in range(k, horizon): + proposed = cell(base_anchor + residual) - fixed_anchor_next + if beta == 1.0: + residual = proposed + elif beta == 0.0: + residual = residual + else: + residual = torch.lerp(residual, proposed, beta) + if hasattr(self, "crr_policy_adapter"): + residual = self.crr_policy_adapter(residual) + residual = residual.masked_fill( + q_padding_mask.unsqueeze(-1), 0 + ) + hybrid_residuals.append(residual) + + if len(hybrid_residuals) != horizon: + raise RuntimeError( + "transition rollout did not reconstruct the full CRR horizon" + ) + return base_anchor + self._aggregate_crr_residuals(hybrid_residuals) + + def _encode_counterfactual_residual_policy( + self, + q_text: torch.Tensor, + q_mm: torch.Tensor, + q_padding_mask: torch.Tensor | None, + ctx, + *, + policy_std: float, + sample: bool, + ) -> tuple[list[torch.Tensor], LatentPolicyTrace]: + """Sample ``C2..CT`` as an amortized Gaussian latent policy. + + ``B`` and ``C1`` remain the deterministic CRR visual anchor. For each + later step, the shared native cell predicts the Gaussian mean + + ``mu_k = (1-beta) C{k-1} + beta G_B(C{k-1})``. + + The action is the next complete residual state + ``Ck ~ N(mu_k, (policy_std * RMS(C1))^2 I)``. Sampled states and the + state supplied to the following transition are detached, as required by + score-function policy gradients. This blocks accidental pathwise + gradients while retaining ``d log pi(Ck|C{k-1}) / d theta`` through the + policy mean. Gaussian constants are omitted and the elementwise score + is averaged over valid latent dimensions to keep its scale independent + of question length. + + The normal deterministic CRR path is implemented separately above and + remains untouched. ``sample=False`` is a structural test/inference + control: it executes the exact policy mean while still returning a + trace. + """ + if not math.isfinite(policy_std) or policy_std <= 0.0: + raise ValueError("policy_std must be finite and > 0") + steps = int(self.config.num_workspace_steps) + if steps < 2: + raise ValueError("latent policy requires at least two CRR steps") + beta = float(self.config.counterfactual_beta) + cell_index = int(self.config.ell_star) + 1 + tm = self.text_model + + recurrent_ctx = replace(ctx, past_key_values=None) + + def cell(hidden: torch.Tensor) -> torch.Tensor: + return run_layer_range( + tm, + recurrent_ctx, + cell_index, + cell_index + 1, + hidden_states=hidden, + use_cache=False, + ) + + # C1 defines the policy's local visual region and is deliberately not an + # RL action. It also fills L21's prefix cache from the text-only branch, + # exactly as deterministic CRR does. No graph is retained for B/C1. + with torch.no_grad(): + base_anchor = run_layer_range( + tm, + ctx, + cell_index, + cell_index + 1, + hidden_states=q_text, + use_cache=True, + ) + residual = cell(q_mm) - base_anchor + if q_padding_mask is not None: + residual = residual.masked_fill( + q_padding_mask.unsqueeze(-1), 0 + ) + + base_observation = base_anchor.detach() + residual = residual.detach() + states = [base_observation, residual] + + if q_padding_mask is None: + valid = torch.ones( + residual.shape[:2], + dtype=torch.bool, + device=residual.device, + ) + else: + valid = ~q_padding_mask + valid_3d = valid.unsqueeze(-1) + n_valid_dims = ( + valid.sum(dim=1).to(torch.float32) * residual.shape[-1] + ).clamp_min(1.0) # [B] + c1_square_sum = ( + residual.float().square() * valid_3d + ).sum(dim=(1, 2)) + c1_rms = (c1_square_sum / n_valid_dims).sqrt().clamp_min(1e-6) + sigma = (float(policy_std) * c1_rms).view(-1, 1, 1).detach() + + # F(B) is shared by every conditional policy mean. B is an observation, + # but the shared cell weights remain differentiable. + fixed_anchor_next = cell(base_observation) + log_probs: list[torch.Tensor] = [] + mean_update_rms: list[torch.Tensor] = [] + noise_rms: list[torch.Tensor] = [] + + for _ in range(1, steps): + observation = residual.detach() + proposed = cell(base_observation + observation) - fixed_anchor_next + if beta == 1.0: + policy_mean = proposed + elif beta == 0.0: + policy_mean = observation + else: + policy_mean = torch.lerp(observation, proposed, beta) + if hasattr(self, "crr_policy_adapter"): + policy_mean = self.crr_policy_adapter(policy_mean) + if q_padding_mask is not None: + policy_mean = policy_mean.masked_fill( + q_padding_mask.unsqueeze(-1), 0 + ) + + if sample: + noise = torch.randn_like(policy_mean) + sampled_state = policy_mean.detach() + sigma.to( + policy_mean.dtype + ) * noise + else: + sampled_state = policy_mean.detach() + if q_padding_mask is not None: + sampled_state = sampled_state.masked_fill( + q_padding_mask.unsqueeze(-1), 0 + ) + + # Evaluate the sampled (detached) action under the differentiable + # policy mean. Omitting constants/log sigma is exact for gradients + # because sigma is detached and not a learned parameter. + standardized = ( + sampled_state.float() - policy_mean.float() + ) / sigma + score = -0.5 * standardized.square() + log_prob = (score * valid_3d).sum(dim=(1, 2)) / n_valid_dims + log_probs.append(log_prob) + + with torch.no_grad(): + update = (policy_mean.detach() - observation).float() + perturbation = ( + sampled_state - policy_mean.detach() + ).float() + mean_update_rms.append( + ((update.square() * valid_3d).sum(dim=(1, 2)) / n_valid_dims) + .sqrt() + .div(c1_rms) + ) + noise_rms.append( + ( + (perturbation.square() * valid_3d).sum(dim=(1, 2)) + / n_valid_dims + ) + .sqrt() + .div(c1_rms) + ) + + residual = sampled_state.detach() + states.append(residual) + + return states, LatentPolicyTrace( + log_probs=torch.stack(log_probs, dim=1), + mean_update_rms=torch.stack(mean_update_rms, dim=1), + noise_rms=torch.stack(noise_rms, dim=1), + ) + + def _encode_raw_evidence_question( + self, + q_star: torch.Tensor, + v_star: torch.Tensor, + q_padding_mask: torch.Tensor | None, + v_padding_mask: torch.Tensor | None, + gate_overrides: list | None, + v_star_reread: torch.Tensor | None, + v_reread_padding_mask: torch.Tensor | None, + reread_memory_overrides: list | None, + reread_zero_memory: bool, + ) -> list[torch.Tensor]: + """Slot-free recurrent read with immutable raw visual evidence. + + Residual mode stores ``[R0=Q*, R1, ..., RT]``. State-replacement mode + stores ``[Q*, H1, ..., HT]`` and the decoder later receives ``Q*+HT``. + Question-anchored replacement stores ``[Z0=Q*, Z1, ..., ZT]`` where + ``Zk=Q*+CrossAttn(Z{k-1},V*)`` and directly decodes ``ZT``. + Every step uses the same CrossAttn module and directly reads raw ``V*``. + The visual memory itself is never updated or exposed to the decoder. + """ + r = q_star + states = [r] + base_e, base_pad = v_star, v_padding_mask + if v_star_reread is not None: + base_e, base_pad = v_star_reread, v_reread_padding_mask + if reread_zero_memory: + base_e = torch.zeros_like(base_e) + base_projected = self.raw_eq_transition.project_evidence(base_e) + + for k in range(self.config.num_workspace_steps): + e_k, pad_k = base_e, base_pad + projected_k = base_projected + if reread_memory_overrides is not None and reread_memory_overrides[k] is not None: + e_k, pad_k = reread_memory_overrides[k] + projected_k = self.raw_eq_transition.project_evidence(e_k) + scale = 1.0 + if gate_overrides is not None and gate_overrides[k] is not None: + # Evaluation-only read ablation/scale; there is no learned gate. + scale = float(gate_overrides[k]) + r = self.raw_eq_transition( + r, + e_k, + evidence_padding_mask=pad_k, + question_padding_mask=q_padding_mask, + read_scale=scale, + projected_evidence=projected_k, + question_anchor=( + q_star + if getattr(self.config, "raw_question_anchor", False) + else None + ), + ) + states.append(r) + + self.last_reread_gates = [] + self.last_reread_update_mags = [] + return states + + def encode_workspace( + self, + q_star: torch.Tensor, # [B, N_q, d] + v_star: torch.Tensor, # [B, N_v, d] + q_padding_mask: torch.Tensor | None = None, + v_padding_mask: torch.Tensor | None = None, + gate_overrides: list | None = None, # eval-only: per-step None/float + v_star_reread: torch.Tensor | None = None, # eval-only: swap rereads' V* + v_reread_padding_mask: torch.Tensor | None = None, + reread_memory_overrides: list | None = None, # eval-only: per-step (v, pad) | None + reread_zero_memory: bool = False, # eval-only: V* -> 0 at EVERY reread (spec 10.3) + final_read_spec: dict | None = None, # eval-only: latent-necessity battery + ) -> list[torch.Tensor]: + """``Z^(0..K)``. ``V*`` is consumed here and nowhere else. + + With ``config.read_gating`` the workspace may re-consult ``V*`` between + transitions through the shared reader (gated, see + :meth:`WorkspaceRead.reread`); the decoder-facing contract is unchanged + either way. Returns ``K + 1`` tensors of shape ``[B, S, d_w]``. + + The three eval-only knobs implement §3.2's held-out gate battery: + ``gate_overrides[k]`` forces step ``k``'s gate (forced-open/closed/ + step-specific), ``v_star_reread`` feeds the REREADS a different visual + memory while the initial read keeps the true one (mid-recurrence swap). + Defaults leave the learned path bitwise intact. They are reachable only + through :meth:`prefill` / :meth:`generate_answer`, never ``forward``, + so the training loop cannot consume them even by accident. + """ + if getattr(self.config, "raw_evidence_question", False): + if final_read_spec is not None: + raise ValueError("raw_evidence_question does not use final_read_spec") + return self._encode_raw_evidence_question( + q_star, + v_star, + q_padding_mask, + v_padding_mask, + gate_overrides, + v_star_reread, + v_reread_padding_mask, + reread_memory_overrides, + reread_zero_memory, + ) + + z = self.workspace_read(q_star, v_star, q_padding_mask, v_padding_mask) + gating = getattr(self.config, "read_gating", False) + pure = getattr(self.config, "read_residual_pure", False) + verify = getattr(self.config, "final_verify", False) + mid_step = int(getattr(self.config, "mid_read_step", 0) or 0) + sid = None + if getattr(self.config, "persistent_slot_id", False): + # E_slot을 매 step 입력에 일시 제공 (누적 금지). 저장 state는 + # f_theta 출력 그대로다. + sid = self.workspace_read.slots.unsqueeze(0).to(z.dtype) # [1, S, d_w] + if getattr(self.config, "evidence_reasoning", False): + # advisor 최종안: Evidence–Reasoning 분리. 전용 경로로 위임 — + # flag off면 이 아래 기존 코드가 비트동일하게 돈다. + assert final_read_spec is None, "ER 모드는 final_read_spec 미지원" + return self._encode_workspace_er( + z, q_star, v_star, q_padding_mask, v_padding_mask, + gate_overrides, v_star_reread, v_reread_padding_mask, + reread_zero_memory, reread_memory_overrides, mid_step, + ) + if final_read_spec is not None: + # Latent-necessity battery (advisor): run the ZERO-residual chain + # U^(0..T) (U^(0) := Z^(0)), then compose one final visual read + # with independently chosen query source / decoder base: + # Z_out = U^(base_t) + alpha * W_o XAttn(LN(query_src), V*). + # query_override (a tensor) replaces the query source entirely + # (matched query swap). final_read=False skips the read + # (latent-only / initial-read-only). + if not pure and not verify: + raise RuntimeError("final_read_spec requires a pure verifier branch") + spec = final_read_spec + chain = [z] + for _ in range(self.config.num_workspace_steps): + chain.append(self.workspace_transition( + chain[-1], q_star, q_padding_mask, cond=sid)) + base = chain[int(spec.get("base_t", self.config.num_workspace_steps))] + if spec.get("final_read", True): + q_src = spec.get("query_override") + if q_src is None: + q_src = chain[int(spec.get("query_t", self.config.num_workspace_steps))] + a = self.workspace_read.visual_residual(q_src, v_star, v_padding_mask) + z_out = base + float(spec.get("alpha", 1.0)) * a + else: + z_out = base + self.last_reread_gates = [] + self.last_reread_update_mags = [] + self.last_reread_gates_full = None + self.last_reread_update_mags_full = None + self.last_chain_states = chain # U^(0..T), query-swap donor 추출용 + return chain + [z_out] + if not gating and not pure and not verify and not mid_step: + del v_star # make the read-once contract visible at the call site + states = [z] + gates: list[torch.Tensor] = [] + upd_mags: list[float] = [] + upd_full: list[torch.Tensor] = [] + for k in range(self.config.num_workspace_steps): + u = self.workspace_transition(z, q_star, q_padding_mask, cond=sid) + z = u + if mid_step and (k + 1) == mid_step: + # MAIN: single intermediate pure read at LOGICAL step k* + # (python index k = k*-1; the +1 here is the off-by-one guard + # the directive calls out). Eval hooks share the step-wise + # keying: gate_overrides[k]==0.0 -> mid-off, + # reread_memory_overrides[k] -> blank/swap, zero flag -> zero. + off_mid = gate_overrides is not None and gate_overrides[k] == 0.0 + if not off_mid: + v_r, vp_r = v_star, v_padding_mask + if v_star_reread is not None: + v_r, vp_r = v_star_reread, v_reread_padding_mask + if reread_zero_memory: + v_r = torch.zeros_like(v_r) + elif (reread_memory_overrides is not None + and reread_memory_overrides[k] is not None): + v_r, vp_r = reread_memory_overrides[k] + z = self.workspace_read.reread_pure( + u, v_r, vp_r, + query_state=None if sid is None else u + sid) + # read 스케일: 학습은 config.mid_read_alpha, eval은 + # gate_overrides가 절대값으로 대체 (sweep 의미 보존). + # reread_pure가 z = u + WoA이므로 (z-u) = WoA 정확. + a_eff = float(getattr(self.config, "mid_read_alpha", 1.0)) + if gate_overrides is not None and gate_overrides[k] is not None: + a_eff = float(gate_overrides[k]) + if a_eff != 1.0: + z = u + a_eff * (z - u) + with torch.no_grad(): + dv = (z - u).float() + uf = u.float() + den = uf.norm(dim=(-2, -1)).clamp_min(1e-6) + self.last_mid_stats_full = { + "u_mid": dv.norm(dim=(-2, -1)) / den, + "a_mid": nn.functional.cosine_similarity( + uf.flatten(1), dv.flatten(1), dim=1), + "r_mid": 1 - nn.functional.cosine_similarity( + uf.flatten(1), z.float().flatten(1), dim=1), + "q_mid": z.float().norm(dim=(-2, -1)) / den, + } + # 시간축 diversity 감시 (advisor): 배치 내 항목쌍은 무관 + # 입력이므로, pooled state의 평균 pairwise cos-dist가 + # d_unrelated의 학습 중 근사가 된다. pre-read 수축이 + # 스케줄 교정으로 완화되는지 이 곡선으로 판정. + if u.shape[0] > 1: + def _div(x): # [B, S, d_w] -> 평균 pairwise 1-cos + pm = nn.functional.normalize( + x.float().mean(1), dim=-1) # [B, d_w] + g = pm @ pm.T + b = g.shape[0] + off = g.masked_select( + ~torch.eye(b, dtype=torch.bool, device=g.device)) + return 1 - off.mean() + self.last_mid_stats_full["div_z0"] = _div( + states[0]).expand(u.shape[0]).clone() + self.last_mid_stats_full["div_z1"] = _div( + states[1] if len(states) > 1 else states[0] + ).expand(u.shape[0]).clone() + self.last_mid_stats_full["div_umid"] = _div(u).expand( + u.shape[0]).clone() + if pure: + # Eval-only residual scale (advisor alpha-sweep): a non-None + # gate_overrides[k] acts as alpha in Z = U + alpha*W_o A. + # alpha = 0.0 skips the branch entirely (== read_once), so the + # sweep's zero point and reread-off are one code path. + alpha_k = None + if gate_overrides is not None and gate_overrides[k] is not None: + alpha_k = float(gate_overrides[k]) + if alpha_k == 0.0: + states.append(z) + continue + # all-steps substitute (global null/swap) -- the v7-ep1 run + # exposed that this was wired only into the gated branch: + # null_mem came back bit-identical to native (0.0% changes), + # which is impossible with a real substitution. + v_r, vp_r = v_star, v_padding_mask + if v_star_reread is not None: + v_r, vp_r = v_star_reread, v_reread_padding_mask + if reread_zero_memory: + v_r, vp_r = torch.zeros_like(v_star), v_padding_mask + elif reread_memory_overrides is not None and reread_memory_overrides[k] is not None: + v_r, vp_r = reread_memory_overrides[k] + z = self.workspace_read.reread_pure(u, v_r, vp_r) + rho = float(getattr(self.config, "residual_norm_cap", 0.0) or 0.0) + if rho > 0.0: + dv = z - u + s_cap = ( + rho * u.float().pow(2).mean((-2, -1)).sqrt() + / dv.float().pow(2).mean((-2, -1)).sqrt().clamp_min(1e-6) + ).clamp(max=1.0).to(dv.dtype) # [B] + z = u + s_cap.view(-1, 1, 1) * dv + if alpha_k is not None and alpha_k != 1.0: + z = u + alpha_k * (z - u) + with torch.no_grad(): + num = (z - u).float().norm(dim=(-2, -1)) + den = u.float().norm(dim=(-2, -1)).clamp_min(1e-6) + u_item = num / den + upd_mags.append(float(u_item.mean())) + upd_full.append(u_item) + if gating: + # Consultation happens through the same reader and lands in Z; + # f_theta itself still never receives V* (read-mediation holds, + # the "once" becomes "as often as the gate earns"). + v_r = v_star if v_star_reread is None else v_star_reread + vp_r = ( + v_padding_mask if v_star_reread is None else v_reread_padding_mask + ) + if reread_memory_overrides is not None and reread_memory_overrides[k] is not None: + # step-specific visual ablation: THIS reread consults a + # substitute memory (blank / donor), all other steps keep + # the true V*. The decisive §3.2 battery test. + v_r, vp_r = reread_memory_overrides[k] + z, g = self.workspace_read.reread( + u, q_star, v_r, q_padding_mask, vp_r, + gate_override=None if gate_overrides is None else gate_overrides[k], + ) + gates.append(g) + # u_k: realized update magnitude ||Z-U||_F / ||U||_F -- the + # gate says how much the model WANTED to look, u_k what the + # look actually changed (draft §3.2). + with torch.no_grad(): + num = (z - u).float().norm(dim=(-2, -1)) + den = u.float().norm(dim=(-2, -1)).clamp_min(1e-6) + u_item = num / den # [B] + upd_mags.append(float(u_item.mean())) + upd_full.append(u_item) + states.append(z) + if verify: + # MAIN: single final visual verification. Mid-loop above ran pure + # f_theta only (no reader calls -- tests/test_final_verify.py + # counts them). Eval hooks: gate_overrides[-1]==0.0 -> branch off; + # reread_memory_overrides[-1] -> substitute verifier memory; + # reread_zero_memory -> zero tensor. + off = gate_overrides is not None and gate_overrides[-1] == 0.0 + if not off: + v_r, vp_r = v_star, v_padding_mask + if reread_zero_memory: + v_r = torch.zeros_like(v_star) + elif (reread_memory_overrides is not None + and reread_memory_overrides[-1] is not None): + v_r, vp_r = reread_memory_overrides[-1] + states.append(self.workspace_read.reread_pure(states[-1], v_r, vp_r)) + else: + states.append(states[-1]) + if mid_step and len(states) == self.config.num_workspace_steps + 1: + # D_{k*->j} propagation telemetry (advisor: "step 4 update가 빠르게 + # 소거되면 구조는 아름답지만 답에 기여하지 않는다"). Shadow chain = + # mid-null (read skipped); identical to clean bitwise before k*, so + # start it at Z^(k*-1). workspace_dropout=0.0 -> deterministic. + with torch.no_grad(): + zs = states[mid_step - 1] + prop = [] + for j in range(mid_step, len(states)): + zs = self.workspace_transition( + zs, q_star, q_padding_mask, cond=sid) + prop.append(1 - nn.functional.cosine_similarity( + states[j].float().flatten(1), zs.float().flatten(1), dim=1)) + # [B, T-k*+1]; columns j = k*, ..., T + self.last_mid_prop_full = torch.stack(prop, dim=1) + # Telemetry for the trainer/analysis: per-step means, [K] each or []. + self.last_reread_gates = [float(g.detach().mean()) for g in gates] + self.last_reread_update_mags = upd_mags + # Full per-item resolution for offline probes (compact logs only carry + # the means). [B, K] or None when gating is off. + self.last_reread_gates_full = ( + torch.stack([g.detach().float() for g in gates], dim=1) if gates else None + ) + self.last_reread_update_mags_full = ( + torch.stack(upd_full, dim=1) if upd_full else None + ) # [B, K] + return states + + # -- prefill ----------------------------------------------------------- + + def prefill( + self, + input_ids: torch.LongTensor, + pixel_values: torch.Tensor, + image_grid_thw: torch.LongTensor, + question_ids: torch.LongTensor, + attention_mask: torch.Tensor | None = None, + question_attention_mask: torch.Tensor | None = None, + workspace_override: torch.Tensor | None = None, + gate_overrides: list | None = None, + reread_inputs: dict | None = None, + reread_step_inputs: dict | None = None, + reread_zero_memory: bool = False, + final_read_spec: dict | None = None, + return_decoder_latent: bool = False, + latent_policy_std: float | None = None, + latent_policy_sample: bool = True, + return_latent_policy_trace: bool = False, + ): + """Run §3.2 end to end and leave a complete replacement cache behind. + + Shared by training and generation, so the two cannot drift apart. + + Args: + workspace_override: substitute ``Z^(K)`` (``[B, S, d_w]``). Held-out + causal evaluation only -- content replacement, matched swap, + path-leakage. Never set during training. + gate_overrides: per-step forced gate values (``None`` entries keep + the learned gate). Held-out gate battery only. + reread_inputs: multimodal inputs (``input_ids``/``pixel_values``/ + ``image_grid_thw``/``attention_mask``) of a DIFFERENT item; its + ``V*`` is served to the re-reads while the initial read keeps + the true one (mid-recurrence swap). Held-out battery only. + reread_step_inputs: ``{step_k: inputs-dict}`` -- like + ``reread_inputs`` but applied ONLY at the given steps' rereads + (step-specific blank/swap ablation). Held-out battery only. + + Returns: + Normally ``(upper, cache, states, v_pooled)``. With + ``return_decoder_latent=True``, appends the exact latent supplied to + the decoder (``R_T`` in the raw-E/Q method). This opt-in fifth + return value keeps old callers compatible while giving causal + evaluations a non-ambiguous intervention target. + """ + cfg = self.config + policy_enabled = latent_policy_std is not None + if return_latent_policy_trace and not policy_enabled: + raise ValueError( + "return_latent_policy_trace requires latent_policy_std" + ) + if policy_enabled and not getattr( + cfg, "counterfactual_residual_recurrence", False + ): + raise ValueError("latent policy is implemented only for CRR") + tm = self.text_model + n_q = question_ids.shape[1] + # Consume-once training buffers must never survive into a later batch. + self._functional_q_target = None + self._functional_q_target_pad = None + self._functional_q_target_ids = None + self._functional_state_loss = None + self._counterfactual_q_state = None + self._counterfactual_q_pad = None + self._counterfactual_q_ids = None + self._visual_counterfactual_bundle = None + q_pad = ( + None if question_attention_mask is None else (question_attention_mask == 0) + ) # [B, N_q] True == ignore + + # 1. V* -- the one and only visual read, under no_grad (frozen branch). + v_star, v_pad = self._encode_visual_memory( + input_ids, pixel_values, image_grid_thw, attention_mask + ) # [B, N_v, d], [B, N_v] + + # 2. Text-only lower prefill. Raw-E/Q uses Q rows only; historical slot + # methods append fixed cache-shape placeholders. + q_star, ctx, cache = self._prefill_text_branch( + question_ids, question_attention_mask + ) # q_star [B, N_q, d] + + if getattr(cfg, "counterfactual_residual_recurrence", False): + unsupported = { + "gate_overrides": gate_overrides is not None, + "reread_inputs": reread_inputs is not None, + "reread_step_inputs": reread_step_inputs is not None, + "reread_zero_memory": bool(reread_zero_memory), + "final_read_spec": final_read_spec is not None, + } + active = [name for name, enabled in unsupported.items() if enabled] + if active: + raise ValueError( + "CRR has no raw-evidence reread controls; unsupported: " + + ", ".join(active) + ) + + q_mm = self._counterfactual_q_state + q_mm_pad = self._counterfactual_q_pad + q_mm_ids = self._counterfactual_q_ids + visual_bundle = self._visual_counterfactual_bundle + self._counterfactual_q_state = None + self._counterfactual_q_pad = None + self._counterfactual_q_ids = None + self._visual_counterfactual_bundle = None + if q_mm is None or q_mm_pad is None or q_mm_ids is None: + raise RuntimeError("CRR factual question state was not captured") + if q_mm.shape != q_star.shape: + raise ValueError( + "multimodal/text-only question rows disagree after removing " + f"the vision span: {tuple(q_mm.shape)} vs {tuple(q_star.shape)}" + ) + valid = ~q_mm_pad + if q_pad is not None: + if q_pad.shape != q_mm_pad.shape or not torch.equal( + q_pad, q_mm_pad + ): + raise ValueError( + "multimodal and text-only question padding masks disagree" + ) + valid &= ~q_pad + if not torch.equal(q_mm_ids[valid], question_ids[valid]): + raise ValueError( + "text-only token ids do not equal multimodal prompt text rows" + ) + + policy_trace = None + if policy_enabled: + z_states, policy_trace = ( + self._encode_counterfactual_residual_policy( + q_star, + q_mm, + q_pad, + ctx, + policy_std=float(latent_policy_std), + sample=bool(latent_policy_sample), + ) + ) + else: + z_states = self._encode_counterfactual_residual_recurrence( + q_star, + q_mm, + q_pad, + ctx, + visual_bundle=visual_bundle, + ) + z_final = ( + self.decoder_latent_from_states(z_states) + if workspace_override is None + else workspace_override + ) + del v_star + lower = ctx.hidden_states + if z_final.shape != lower.shape: + raise ValueError( + "CRR decoder latent must match Q* shape, got " + f"{tuple(z_final.shape)} vs {tuple(lower.shape)}" + ) + # In latent-policy mode the generated answer is a non-differentiable + # environment outcome. Detaching here makes the score trace above + # the sole gradient path even if a caller accidentally leaves an + # upper-decoder parameter trainable. + decoder_input = ( + z_final.detach() if policy_enabled else z_final + ).to(lower.dtype) + if policy_enabled: + with torch.no_grad(): + upper = run_layer_range( + tm, + ctx, + self._upper_decoder_start(), + None, + hidden_states=decoder_input, + use_cache=True, + ) + else: + upper = run_layer_range( + tm, + ctx, + self._upper_decoder_start(), + None, + hidden_states=decoder_input, + use_cache=True, + ) + if return_latent_policy_trace: + if return_decoder_latent: + return upper, cache, z_states, None, z_final, policy_trace + return upper, cache, z_states, None, policy_trace + if return_decoder_latent: + return upper, cache, z_states, None, z_final + return upper, cache, z_states, None + + # The removed visual-anchor loss must have zero runtime footprint in + # the current answer-only method. Historical anchor runs opt in through + # config.read_anchor. + v_pooled = None + if getattr(cfg, "read_anchor", False): + if v_pad is None: + v_pooled = v_star.float().mean(1) + else: + keep = (~v_pad).to(v_star.dtype).unsqueeze(-1) + v_pooled = ( + (v_star * keep).sum(1).float() + / keep.sum(1).float().clamp_min(1.0) + ) + v_pooled = torch.nn.functional.layer_norm( + v_pooled, (v_pooled.shape[-1],) + ).detach() + + # 3. Z^(0..K). V* dies here. + v_swap, v_swap_pad = None, None + if reread_inputs is not None and "__v_star__" in reread_inputs: + # eval-only: precomputed substitute memory tensor (e.g. corpus-mean + # dictionary), bypassing the image encoder. + v_swap = reread_inputs["__v_star__"] + v_swap_pad = reread_inputs.get("__v_pad__") + elif reread_inputs is not None: + v_swap, v_swap_pad = self._encode_visual_memory( + reread_inputs["input_ids"], + reread_inputs["pixel_values"], + reread_inputs["image_grid_thw"], + reread_inputs.get("attention_mask"), + ) + step_overrides = None + if reread_step_inputs is not None: + step_overrides = [None] * self.config.num_workspace_steps + for k_, inp in reread_step_inputs.items(): + if "__v_star__" in inp: + # eval-only: direct tensor (e.g. true zero memory) -- the + # blank IMAGE still yields nonzero V*, so "zero" must be + # injected as a tensor, not an image (advisor §4). + step_overrides[int(k_)] = (inp["__v_star__"], inp.get("__v_pad__")) + else: + step_overrides[int(k_)] = self._encode_visual_memory( + inp["input_ids"], inp["pixel_values"], + inp["image_grid_thw"], inp.get("attention_mask"), + ) + z_states = self.encode_workspace( + q_star, v_star, q_pad, v_pad, + gate_overrides=gate_overrides, + v_star_reread=v_swap, + v_reread_padding_mask=v_swap_pad, + reread_memory_overrides=step_overrides, + reread_zero_memory=reread_zero_memory, + final_read_spec=final_read_spec, + ) + del v_star + + if getattr(cfg, "raw_evidence_question", False): + # Residual: states=[R0=Q*, R1..RT], decode RT or mean(R1..RT). + # Replacement: states=[Q*, H1..HT], decode Q*+HT. + # Anchored replacement: states=[Q*, Z1..ZT], decode ZT. + # The selected mean aggregation is parameter-free; no learned + # temporal interface is allocated. + z_final = ( + self.decoder_latent_from_states(z_states) + if workspace_override is None + else workspace_override + ) + if ( + self.training + and getattr(cfg, "functional_state_loss", False) + and workspace_override is None + ): + target = self._functional_q_target + target_pad = self._functional_q_target_pad + target_ids = self._functional_q_target_ids + self._functional_q_target = None + self._functional_q_target_pad = None + self._functional_q_target_ids = None + if target is None or target_pad is None or target_ids is None: + raise RuntimeError("functional-state target was not captured") + if target.shape != q_star.shape: + raise ValueError( + "multimodal/text-only question rows disagree after the " + f"vision span is removed: {tuple(target.shape)} vs " + f"{tuple(q_star.shape)}" + ) + valid = ~target_pad + if q_pad is not None: + if q_pad.shape != target_pad.shape or not torch.equal(q_pad, target_pad): + raise ValueError( + "multimodal and text-only question padding masks disagree" + ) + valid &= ~q_pad + if not torch.equal(target_ids[valid], question_ids[valid]): + raise ValueError( + "text-only token ids do not equal the multimodal prompt " + "with its vision span removed" + ) + # Scale-free visual-state restoration: + # ||R_agg-Q*_mm||^2 / sg(||Q*_mm-Q*_text||^2). + # The denominator removes the large shared text scaffold and + # defines 1.0 as the image-independent Q* floor. Per-example + # reduction prevents long prompts from dominating. + mask = valid.unsqueeze(-1).to(torch.float32) + err = (z_final.float() - target.float()).square() * mask + visual = (target.float() - q_star.float()).square() * mask + err_sum = err.sum(dim=(1, 2)) + visual_sum = visual.sum(dim=(1, 2)).detach() + # The normal multimodal branch always has a nonzero image + # residual, but retain a finite floor for degenerate fixtures. + floor = mask.sum(dim=(1, 2)).clamp_min(1.0) * 1e-8 + self._functional_state_loss = ( + err_sum / visual_sum.clamp_min(floor) + ).mean() + k_ov = int(getattr(cfg, "er_step_override", 0) or 0) + if k_ov and workspace_override is None: + if not 1 <= k_ov <= cfg.num_workspace_steps: + raise ValueError( + f"er_step_override must be in [1,{cfg.num_workspace_steps}]" + ) + z_final = ( + z_states[0] + z_states[k_ov] + if ( + getattr(cfg, "raw_state_replacement", False) + and not getattr(cfg, "raw_question_anchor", False) + ) + else z_states[k_ov] + ) + lower = ctx.hidden_states # [B,N_q,d], lower-cache values stay text-only + if z_final.shape != lower.shape: + raise ValueError( + "raw-evidence decoder latent must match Q* shape, got " + f"{tuple(z_final.shape)} vs {tuple(lower.shape)}" + ) + upper = run_layer_range( + tm, + ctx, + cfg.ell_star + 1, + None, + hidden_states=z_final.to(lower.dtype), + use_cache=True, + ) + if return_decoder_latent: + return upper, cache, z_states, v_pooled, z_final + return upper, cache, z_states, v_pooled + + # 4. overwrite the slot positions with P_Z(Z^(K)). Everything below l* + # was computed on a sequence with no image tokens, so this is the + # first and only point at which visual information enters. + lower = ctx.hidden_states # [B, N_q + S, d] + z_final = z_states[-1] if workspace_override is None else workspace_override + if (getattr(self.config, "evidence_reasoning", False) + and workspace_override is None): + # strict ER: decoder 입력 = answer 체인의 R^(T) 단독 — + # E 직행 우회 없음. consume-once로 stale-state 차단. + r_fin = self._er_answer_final + assert r_fin is not None, "ER: encode 없이 prefill 시도 (stale/미설정)" + self._er_answer_final = None + z_final = r_fin + # 진단 전용 (checkpoint eval): R^(k)로 디코드하는 step-sweep. + # ER states = [E^0, R_t^1..R_t^T]이고 dropout=0에서 R_t == R 비트동일. + # 학습은 이 config 속성이 없으므로 경로 불변 (advisor: 훈련 무접촉). + k_ov = int(getattr(self.config, "er_step_override", 0) or 0) + if k_ov: + T = self.config.num_workspace_steps + assert 1 <= k_ov <= T, f"er_step_override must be in [1,{T}]" + z_final = z_states[k_ov] + z_proj = self.workspace_out(z_final).to(lower.dtype) # [B, S, d] + if self.splice_gain is not None: + # RMS-match the slot rows to the (non-pad) question rows. Fresh + # linear output is O(1) while layer-20 hiddens run into the + # hundreds; unmatched, the decoder's attention treats the slots as + # near-zero vectors and ignoring them is free (probe 20143). + if question_attention_mask is not None: + qm = (question_attention_mask > 0).to(lower.dtype) # [B, N_q] + else: + qm = torch.ones( + lower.shape[0], n_q, dtype=lower.dtype, device=lower.device + ) + q_rms = ( + (lower[:, :n_q].float().pow(2).mean(-1).sqrt() * qm).sum(1) + / qm.sum(1).clamp_min(1.0) + ) # [B] + z_rms = z_proj.float().pow(2).mean(-1).sqrt().mean(1) # [B] + scale = (q_rms / z_rms.clamp_min(1e-6)).view(-1, 1, 1).to(z_proj.dtype) + z_proj = z_proj * scale * self.splice_gain.to(z_proj.dtype) + spliced = torch.cat([lower[:, :n_q], z_proj], dim=1) # [B, N_q + S, d] + + if (self.training and getattr(cfg, "interface_loss", False) + and workspace_override is None + and getattr(self, "_iface_h_base", None) is not None): + # iface student: R^(T).detach() → 동일 splice 변환(bridge 재적용) + # → layer l*+1 한 층만 재계산 → 마지막 slot 위치 hidden. + # gradient는 workspace_out·splice_gain·해당 층 adapter에만 흐른다 + # (R/f_theta/reader/E 차단 — advisor bridge-only 규정). + zd = self.workspace_out(z_final.detach()).to(lower.dtype) + if self.splice_gain is not None: + zd_rms = zd.float().pow(2).mean(-1).sqrt().mean(1) # [B] + sc = (q_rms / zd_rms.clamp_min(1e-6)).view(-1, 1, 1).to(zd.dtype) + zd = zd * sc * self.splice_gain.to(zd.dtype) + spliced_d = torch.cat([lower[:, :n_q].detach(), zd], dim=1) + # cache 없는 ctx 사본 필수: 원본 ctx로 돌리면 이 패스가 l*+1층 + # cache에 slot 키를 저장해 본 upper 실행의 키가 2배로 중복된다 + # (게이트에서 [*, 111, 222] shape 오류로 검출). + import dataclasses as _dc + ctx_if = _dc.replace(ctx, past_key_values=None) + h_if = run_layer_range( + tm, ctx_if, cfg.ell_star + 1, cfg.ell_star + 2, + hidden_states=spliced_d) # [B, N_q+S, d] + h_r = h_if[:, -1].float() # 마지막 prefix 위치 (= 마지막 slot) + hb = self._iface_h_base.float() + self._iface_h_base = None + d_ = h_r.shape[-1] + self._iface_loss = (1 - nn.functional.cosine_similarity( + nn.functional.layer_norm(h_r, (d_,)), + nn.functional.layer_norm(hb, (d_,)), dim=-1)).mean() + + # 5. F_{>l*} over M* = [Q*; P_Z(Z^(K))], completing the cache. + upper = run_layer_range( + tm, ctx, cfg.ell_star + 1, None, hidden_states=spliced, use_cache=True + ) # [B, N_q + S, d] + if return_decoder_latent: + return upper, cache, z_states, v_pooled, z_final + return upper, cache, z_states, v_pooled + + @torch.no_grad() + def _greedy_decode_from_prefill( + self, + upper: torch.Tensor, + cache: Cache, + question_ids: torch.LongTensor, + question_attention_mask: torch.Tensor | None, + *, + max_new_tokens: int, + eos_token_id: int | None, + ) -> torch.LongTensor: + """Greedily decode from an already-built replacement prefix cache.""" + if max_new_tokens < 1: + raise ValueError("max_new_tokens must be >= 1") + tm = self.text_model + b = question_ids.shape[0] + q_state_interface = self._uses_question_state_interface() + n_ctx = question_ids.shape[1] + ( + 0 if q_state_interface else self.config.num_workspace_slots + ) + device = question_ids.device + q_mask = question_attention_mask + if q_mask is None: + q_mask = torch.ones_like(question_ids, dtype=torch.long) + if q_state_interface: + prefix_mask = q_mask + logical_prefix_lengths = (q_mask > 0).sum(dim=-1).long() + last_q = (logical_prefix_lengths - 1).clamp_min(0) + seed_hidden = upper[ + torch.arange(b, device=device), last_q + ].unsqueeze(1) + else: + prefix_mask, _, logical_prefix_lengths = _replacement_prefix_layout( + q_mask, self.config.num_workspace_slots + ) + seed_hidden = upper[:, -1:] + + nxt = self.backbone.lm_head(final_norm(tm, seed_hidden)).argmax(-1) # [B, 1] + out = [nxt] + done = torch.zeros(b, dtype=torch.bool, device=device) + if eos_token_id is not None: + done |= nxt.squeeze(1) == eos_token_id + + for step in range(1, max_new_tokens): + if bool(done.all()): + break + pos = n_ctx + step - 1 + embeds = self.vl.get_input_embeddings()(nxt) # [B, 1, d] + cache_position = torch.tensor([pos], device=device) + logical_pos = logical_prefix_lengths + step - 1 # [B] + position_ids = logical_pos.view(1, b, 1).expand(3, -1, -1) + generated_mask = torch.ones( + b, step, dtype=prefix_mask.dtype, device=device + ) + upper_mask = torch.cat([prefix_mask, generated_mask], dim=-1) + if q_state_interface: + lower_mask = upper_mask + else: + lower_prefix_mask = prefix_mask.clone() + lower_prefix_mask[:, -self.config.num_workspace_slots:] = 0 + lower_mask = torch.cat([lower_prefix_mask, generated_mask], dim=-1) + h = self._decode_token_block( + embeds, + position_ids, + lower_mask, + upper_mask, + cache, + cache_position, + ) + nxt = self.backbone.lm_head(final_norm(tm, h)).argmax(-1) # [B, 1] + if eos_token_id is not None: + nxt = torch.where(done.unsqueeze(1), nxt.new_full(nxt.shape, eos_token_id), nxt) + done |= nxt.squeeze(1) == eos_token_id + out.append(nxt) + + return torch.cat(out, dim=1) + + @torch.no_grad() + def _gold_answer_logprob_from_prefill( + self, + upper: torch.Tensor, + cache: Cache, + question_ids: torch.LongTensor, + question_attention_mask: torch.Tensor | None, + answer_ids: torch.LongTensor, + labels: torch.LongTensor, + ) -> torch.FloatTensor: + """Score a gold answer against an already sampled latent prefix. + + The score is the mean teacher-forced log probability over valid answer + tokens. Running this helper under ``no_grad`` is intentional: the + decoder acts only as a frozen reward environment, while policy gradient + reaches the latent adapter solely through ``LatentPolicyTrace``. + """ + if answer_ids.ndim != 2 or labels.shape != answer_ids.shape: + raise ValueError( + "answer_ids and labels must have the same [B, N_a] shape" + ) + if answer_ids.shape[0] != question_ids.shape[0]: + raise ValueError("answer and question batch sizes disagree") + if answer_ids.shape[1] < 1: + raise ValueError("gold-answer scoring requires at least one token") + + tm = self.text_model + batch_size, answer_length = answer_ids.shape + q_state_interface = self._uses_question_state_interface() + q_mask = question_attention_mask + if q_mask is None: + q_mask = torch.ones_like(question_ids, dtype=torch.long) + + if q_state_interface: + prefix_mask = q_mask + logical_prefix_lengths = (q_mask > 0).sum(dim=-1).long() + last_q = (logical_prefix_lengths - 1).clamp_min(0) + hidden_for_logits = upper[ + torch.arange(batch_size, device=upper.device), last_q + ].unsqueeze(1) + else: + prefix_mask, _, logical_prefix_lengths = _replacement_prefix_layout( + q_mask, self.config.num_workspace_slots + ) + hidden_for_logits = upper[:, -1:] + + # The final prefix state predicts y_0. Consuming gold y_0..y_{N-2} + # supplies the remaining N-1 teacher-forced prediction states; there is + # no reason to decode y_{N-1}, whose output would predict beyond labels. + if answer_length > 1: + teacher_ids = answer_ids[:, :-1] + teacher_mask = labels[:, :-1].ne(-100).to(prefix_mask.dtype) + n_ctx = question_ids.shape[1] + ( + 0 if q_state_interface else self.config.num_workspace_slots + ) + teacher_embeds = self.vl.get_input_embeddings()(teacher_ids) + cache_position = torch.arange( + n_ctx, + n_ctx + teacher_ids.shape[1], + device=teacher_embeds.device, + ) + logical_answer_pos = ( + logical_prefix_lengths[:, None] + + torch.arange( + teacher_ids.shape[1], device=teacher_embeds.device + )[None, :] + ) + answer_positions = logical_answer_pos.unsqueeze(0).expand( + 3, -1, -1 + ) + upper_mask = torch.cat([prefix_mask, teacher_mask], dim=-1) + if q_state_interface: + lower_mask = upper_mask + else: + lower_prefix_mask = prefix_mask.clone() + lower_prefix_mask[:, -self.config.num_workspace_slots:] = 0 + lower_mask = torch.cat( + [lower_prefix_mask, teacher_mask], dim=-1 + ) + answer_hidden = self._decode_token_block( + teacher_embeds, + answer_positions, + lower_mask, + upper_mask, + cache, + cache_position, + ) + hidden_for_logits = torch.cat( + [hidden_for_logits, answer_hidden], dim=1 + ) + + logits = self.backbone.lm_head(final_norm(tm, hidden_for_logits)) + nll, valid = _answer_nll_per_example(logits, labels) + if not bool(valid.all()): + raise ValueError("every latent-policy reward row needs a gold token") + return -nll + + @torch.no_grad() + def _gold_answer_logprobs_by_crr_prefix( + self, + upper: torch.Tensor, + cache: Cache, + z_states: list[torch.Tensor] | tuple[torch.Tensor, ...], + question_ids: torch.LongTensor, + question_attention_mask: torch.Tensor | None, + answer_ids: torch.LongTensor, + labels: torch.LongTensor, + ) -> torch.FloatTensor: + """Score ``Z_1..Z_T`` for transition-aware latent RL. + + The final prefix reuses the factual cached decode. Earlier prefixes + are stacked into one frozen L22--L27 replay and share the same + text-only lower answer states, exactly preserving the strict CRR + interface. No reward tensor returned here carries a decoder gradient. + """ + if not getattr( + self.config, "counterfactual_residual_recurrence", False + ): + raise ValueError("prefix reward scoring requires CRR") + expected_states = int(self.config.num_workspace_steps) + 1 + if len(z_states) != expected_states: + raise ValueError( + f"expected B plus {expected_states - 1} CRR states, " + f"got {len(z_states)}" + ) + if answer_ids.ndim != 2 or labels.shape != answer_ids.shape: + raise ValueError( + "answer_ids and labels must have the same [B, N_a] shape" + ) + batch_size, answer_length = answer_ids.shape + if batch_size != question_ids.shape[0] or answer_length < 1: + raise ValueError("prefix rewards require a non-empty aligned batch") + + tm = self.text_model + num_steps = expected_states - 1 + n_q = question_ids.shape[1] + q_mask = question_attention_mask + if q_mask is None: + q_mask = torch.ones_like(question_ids, dtype=torch.long) + q_keep = q_mask.gt(0) + logical_prefix_lengths = q_keep.sum(dim=-1).long() + last_q = (logical_prefix_lengths - 1).clamp_min(0) + + prefixes: list[torch.Tensor] = [] + residual_prefix: list[torch.Tensor] = [] + for residual in z_states[1:]: + residual_prefix.append(residual) + prefixes.append( + z_states[0] + self._aggregate_crr_residuals(residual_prefix) + ) + + final_hidden_for_logits = upper[ + torch.arange(batch_size, device=upper.device), last_q + ].unsqueeze(1) + answer_lower_hidden = None + answer_positions = None + combined_mask = None + if answer_length > 1: + teacher_ids = answer_ids[:, :-1] + teacher_mask = labels[:, :-1].ne(-100).to(q_mask.dtype) + teacher_embeds = self.vl.get_input_embeddings()(teacher_ids) + cache_position = torch.arange( + n_q, + n_q + teacher_ids.shape[1], + device=teacher_embeds.device, + ) + logical_answer_pos = ( + logical_prefix_lengths[:, None] + + torch.arange( + teacher_ids.shape[1], device=teacher_embeds.device + )[None, :] + ) + answer_positions = logical_answer_pos.unsqueeze(0).expand( + 3, -1, -1 + ) + combined_mask = torch.cat([q_mask, teacher_mask], dim=-1) + final_answer_hidden, answer_lower_hidden = self._decode_token_block( + teacher_embeds, + answer_positions, + combined_mask, + combined_mask, + cache, + cache_position, + return_lower=True, + ) + final_hidden_for_logits = torch.cat( + [final_hidden_for_logits, final_answer_hidden], dim=1 + ) + + final_logits = self.backbone.lm_head( + final_norm(tm, final_hidden_for_logits) + ) + final_nll, final_valid = _answer_nll_per_example(final_logits, labels) + if not bool(final_valid.all()): + raise ValueError("every latent-policy reward row needs a gold token") + if num_steps == 1: + return -final_nll.unsqueeze(1) + + num_partial = num_steps - 1 + partial_prefix = torch.cat(prefixes[:-1], dim=0) + q_pos_1d = q_keep.long().cumsum(dim=-1) - 1 + q_pos_1d = q_pos_1d.masked_fill(~q_keep, 0) + q_positions = q_pos_1d.unsqueeze(0).expand(3, -1, -1) + partial_q_positions = q_positions.repeat(1, num_partial, 1) + partial_q_mask = q_mask.repeat(num_partial, 1) + + if answer_lower_hidden is not None: + partial_input = torch.cat( + [ + partial_prefix.to(answer_lower_hidden.dtype), + answer_lower_hidden.repeat(num_partial, 1, 1), + ], + dim=1, + ) + partial_positions = torch.cat( + [ + partial_q_positions, + answer_positions.repeat(1, num_partial, 1), + ], + dim=-1, + ) + partial_attention_mask = combined_mask.repeat(num_partial, 1) + else: + partial_input = partial_prefix + partial_positions = partial_q_positions + partial_attention_mask = partial_q_mask + + partial_ctx = make_split_context( + tm, + partial_input, + partial_positions, + partial_attention_mask, + ) + partial_upper = run_layer_range( + tm, + partial_ctx, + self._upper_decoder_start(), + None, + hidden_states=partial_input, + use_cache=False, + ) + partial_last_q = last_q.repeat(num_partial) + partial_hidden_for_logits = partial_upper[ + torch.arange(partial_upper.shape[0], device=partial_upper.device), + partial_last_q, + ].unsqueeze(1) + if answer_lower_hidden is not None: + partial_hidden_for_logits = torch.cat( + [ + partial_hidden_for_logits, + partial_upper[ + :, n_q : n_q + answer_lower_hidden.shape[1] + ], + ], + dim=1, + ) + partial_logits = self.backbone.lm_head( + final_norm(tm, partial_hidden_for_logits) + ) + partial_nll, partial_valid = _answer_nll_per_example( + partial_logits, + labels.repeat(num_partial, 1), + ) + if not bool(partial_valid.all()): + raise ValueError("every CRR prefix needs a valid gold-answer score") + partial_scores = -partial_nll.view(num_partial, batch_size).transpose( + 0, 1 + ) + return torch.cat([partial_scores, -final_nll.unsqueeze(1)], dim=1) + + @torch.no_grad() + def generate_answer( + self, + input_ids: torch.LongTensor, + pixel_values: torch.Tensor, + image_grid_thw: torch.LongTensor, + question_ids: torch.LongTensor, + attention_mask: torch.Tensor | None = None, + question_attention_mask: torch.Tensor | None = None, + max_new_tokens: int = 8, + eos_token_id: int | None = None, + workspace_override: torch.Tensor | None = None, + gate_overrides: list | None = None, + reread_inputs: dict | None = None, + reread_zero_memory: bool = False, + ) -> torch.LongTensor: + """Greedy decode against the replacement cache. ``[B, <=N_a]``. + + A purpose-built loop rather than ``generate()``: the prefill is not a + token sequence the generation utilities can reconstruct, and the targets + here are short VQA answers. Correctness of the read-once contract is + easier to guarantee with the loop visible. + """ + upper, cache, _, _ = self.prefill( + input_ids, + pixel_values, + image_grid_thw, + question_ids, + attention_mask, + question_attention_mask, + workspace_override=workspace_override, + gate_overrides=gate_overrides, + reread_inputs=reread_inputs, + reread_zero_memory=reread_zero_memory, + ) + return self._greedy_decode_from_prefill( + upper, + cache, + question_ids, + question_attention_mask, + max_new_tokens=max_new_tokens, + eos_token_id=eos_token_id, + ) + + def generate_answer_with_latent_policy( + self, + input_ids: torch.LongTensor, + pixel_values: torch.Tensor, + image_grid_thw: torch.LongTensor, + question_ids: torch.LongTensor, + attention_mask: torch.Tensor | None = None, + question_attention_mask: torch.Tensor | None = None, + *, + policy_std: float, + max_new_tokens: int = 8, + eos_token_id: int | None = None, + sample: bool = True, + ) -> tuple[torch.LongTensor, LatentPolicyTrace]: + """Sample CRR latent actions, then greedily decode a terminal answer. + + The returned token ids are an environment outcome and carry no graph. + Only ``trace.log_probs`` is differentiable. Deterministic inference + continues to use :meth:`generate_answer` and therefore the policy mean. + """ + if not getattr( + self.config, "counterfactual_residual_recurrence", False + ): + raise ValueError("latent-policy generation requires a CRR checkpoint") + upper, cache, _, _, trace = self.prefill( + input_ids, + pixel_values, + image_grid_thw, + question_ids, + attention_mask, + question_attention_mask, + latent_policy_std=float(policy_std), + latent_policy_sample=bool(sample), + return_latent_policy_trace=True, + ) + generated = self._greedy_decode_from_prefill( + upper, + cache, + question_ids, + question_attention_mask, + max_new_tokens=max_new_tokens, + eos_token_id=eos_token_id, + ) + return generated, trace + + def score_gold_answer_with_latent_policy( + self, + input_ids: torch.LongTensor, + pixel_values: torch.Tensor, + image_grid_thw: torch.LongTensor, + question_ids: torch.LongTensor, + answer_ids: torch.LongTensor, + labels: torch.LongTensor, + attention_mask: torch.Tensor | None = None, + question_attention_mask: torch.Tensor | None = None, + *, + policy_std: float, + sample: bool = True, + score_prefixes: bool = False, + ) -> tuple[ + torch.FloatTensor, + torch.FloatTensor | None, + LatentPolicyTrace, + ]: + """Sample latent actions and return frozen gold-answer scores. + + ``score_prefixes=False`` preserves the original terminal-only reward + path. The opt-in prefix path scores ``Z_1..Z_T`` with the same frozen + decoder and gold tokens, while keeping the Gaussian score trace as the + only differentiable path. + """ + if not getattr( + self.config, "counterfactual_residual_recurrence", False + ): + raise ValueError("latent-policy scoring requires a CRR checkpoint") + upper, cache, z_states, _, trace = self.prefill( + input_ids, + pixel_values, + image_grid_thw, + question_ids, + attention_mask, + question_attention_mask, + latent_policy_std=float(policy_std), + latent_policy_sample=bool(sample), + return_latent_policy_trace=True, + ) + if score_prefixes: + step_scores = self._gold_answer_logprobs_by_crr_prefix( + upper, + cache, + z_states, + question_ids, + question_attention_mask, + answer_ids, + labels, + ) + score = step_scores[:, -1] + else: + score = self._gold_answer_logprob_from_prefill( + upper, + cache, + question_ids, + question_attention_mask, + answer_ids, + labels, + ) + step_scores = None + return score, step_scores, trace + + # -- forward ----------------------------------------------------------- + + def forward( + self, + input_ids: torch.LongTensor | None = None, + attention_mask: torch.Tensor | None = None, + pixel_values: torch.Tensor | None = None, + image_grid_thw: torch.LongTensor | None = None, + question_ids: torch.LongTensor | None = None, + question_attention_mask: torch.Tensor | None = None, + labels: torch.LongTensor | None = None, + answer_ids: torch.LongTensor | None = None, + past_key_values: Cache | None = None, + use_cache: bool | None = None, + return_dict: bool | None = None, + workspace_override: torch.Tensor | None = None, + latent_policy_std: float | None = None, + latent_policy_sample: bool = True, + latent_policy_max_new_tokens: int = 8, + latent_policy_eos_token_id: int | None = None, + latent_policy_score_gold: bool = False, + latent_policy_score_prefixes: bool = False, + reliance_transition_index: int | torch.Tensor | None = None, + **kwargs, + ) -> CloseCausalLMOutput: + """Prefill through the workspace, then decode the answer. + + Args: + input_ids: the *multimodal* sequence (image placeholders included), + used only to build ``V*``. + question_ids: the *text-only* question, used for ``Q*`` and for the + replacement cache. Required -- there is deliberately no fallback + that strips the vision span implicitly, because getting that + wrong would silently reintroduce image tokens below ``l*``. + answer_ids: teacher-forced answer tokens ``[B, N_a]``. + labels: ``[B, N_a]`` targets **already aligned** to ``answer_ids`` -- + ``labels[:, i]`` is the target at answer position ``i``, and no + internal shift is applied. The shift lives on the hidden-state + side: position ``i``'s prediction comes from + ``[last replacement-cache position] + answer[:i]``. ``-100`` ignores. + past_key_values: not accepted yet -- see below. + return_dict: must be ``True``/``None``; tuple returns are not supported. + """ + cfg = self.config + if kwargs: + raise TypeError(f"unexpected forward kwargs: {sorted(kwargs)}") + if latent_policy_std is not None: + if workspace_override is not None: + raise ValueError( + "latent policy and workspace_override are mutually exclusive" + ) + if past_key_values is not None: + raise ValueError("latent policy always builds a fresh prefix cache") + if return_dict is False: + raise ValueError("latent policy requires return_dict=True") + if input_ids is None or pixel_values is None or image_grid_thw is None: + raise ValueError( + "latent policy requires multimodal input_ids, pixel_values, " + "and image_grid_thw" + ) + if question_ids is None: + raise ValueError("latent policy requires text-only question_ids") + generated = None + gold_logprob = None + step_gold_logprob = None + if latent_policy_score_gold: + if answer_ids is None or labels is None: + raise ValueError( + "latent_policy_score_gold requires answer_ids and labels" + ) + gold_logprob, step_gold_logprob, trace = ( + self.score_gold_answer_with_latent_policy( + input_ids, + pixel_values, + image_grid_thw, + question_ids, + answer_ids, + labels, + attention_mask, + question_attention_mask, + policy_std=float(latent_policy_std), + sample=bool(latent_policy_sample), + score_prefixes=bool(latent_policy_score_prefixes), + ) + ) + else: + if latent_policy_score_prefixes: + raise ValueError( + "latent_policy_score_prefixes requires " + "latent_policy_score_gold" + ) + generated, trace = self.generate_answer_with_latent_policy( + input_ids, + pixel_values, + image_grid_thw, + question_ids, + attention_mask, + question_attention_mask, + policy_std=float(latent_policy_std), + max_new_tokens=int(latent_policy_max_new_tokens), + eos_token_id=latent_policy_eos_token_id, + sample=bool(latent_policy_sample), + ) + return CloseCausalLMOutput( + generated_ids=generated, + latent_policy_gold_logprob=gold_logprob, + latent_policy_step_gold_logprob=step_gold_logprob, + latent_policy_log_probs=trace.log_probs, + latent_policy_mean_update_rms=trace.mean_update_rms, + latent_policy_noise_rms=trace.noise_rms, + ) + compute_residual_swap = bool( + self.training + and getattr(cfg, "counterfactual_reliance_loss", False) + and labels is not None + and workspace_override is None + ) + compute_step_retention = bool( + self.training + and getattr(cfg, "counterfactual_step_retention_loss", False) + and labels is not None + and workspace_override is None + ) + need_answer_lower = compute_residual_swap or compute_step_retention + # Every prefill rebuilds the replacement cache from scratch. Accepting a + # caller-supplied cache would silently discard it and redo the visual + # read, so refuse instead of pretending to support incremental decode. + if past_key_values is not None: + raise NotImplementedError( + "past_key_values is not supported: forward() always rebuilds the " + "replacement cache. Incremental decoding needs prepare_inputs_for_" + "generation, which is not implemented yet." + ) + if return_dict is False: + raise ValueError( + "return_dict=False is not supported; CLOSE always returns a " + "CloseCausalLMOutput (the workspace trajectory has no tuple slot)." + ) + if question_ids is None: + raise ValueError( + "question_ids (text-only, no vision span) is required. Strip the " + "vision span explicitly with utils.splitting.vision_span_mask; an " + "implicit fallback risks leaking image tokens below l*." + ) + if pixel_values is None or input_ids is None: + raise ValueError("input_ids and pixel_values are required to build V*.") + + tm = self.text_model + n_q = question_ids.shape[1] + upper, cache, z_states, v_pooled = self.prefill( + input_ids, + pixel_values, + image_grid_thw, + question_ids, + attention_mask, + question_attention_mask, + workspace_override=workspace_override, + ) + + q_state_interface = self._uses_question_state_interface() + # The last real prefix position predicts answer token 0. Historical + # slot paths always end in a valid slot; raw Q* batches may end in + # right-padding and therefore require a per-item gather. + if q_state_interface: + q_mask_for_seed = question_attention_mask + if q_mask_for_seed is None: + q_mask_for_seed = torch.ones_like(question_ids, dtype=torch.long) + last_q = ((q_mask_for_seed > 0).sum(dim=-1).long() - 1).clamp_min(0) + hidden_for_logits = upper[ + torch.arange(upper.shape[0], device=upper.device), last_q + ].unsqueeze(1) + else: + hidden_for_logits = upper[:, -1:] # [B, 1, d] + answer_lower_hidden = None + ans_positions = None + upper_mask = None + if answer_ids is not None and answer_ids.shape[1] > 1: + n_ctx = n_q + ( + 0 if q_state_interface else cfg.num_workspace_slots + ) + n_a = answer_ids.shape[1] + ans_embeds = self.vl.get_input_embeddings()(answer_ids) # [B, N_a, d] + cache_position = torch.arange(n_ctx, n_ctx + n_a, device=ans_embeds.device) + q_mask = question_attention_mask + if q_mask is None: + q_mask = torch.ones_like(question_ids, dtype=torch.long) + if q_state_interface: + prefix_mask = q_mask + logical_prefix_lengths = (q_mask > 0).sum(dim=-1).long() + else: + prefix_mask, _, logical_prefix_lengths = _replacement_prefix_layout( + q_mask, cfg.num_workspace_slots + ) + logical_answer_pos = ( + logical_prefix_lengths[:, None] + + torch.arange(n_a, device=ans_embeds.device)[None, :] + ) # [B,N_a] + ans_positions = logical_answer_pos.unsqueeze(0).expand(3, -1, -1) + if labels is not None and labels.shape == answer_ids.shape: + answer_mask = labels.ne(-100).to(prefix_mask.dtype) + else: + answer_mask = torch.ones( + answer_ids.shape, dtype=prefix_mask.dtype, device=answer_ids.device + ) + upper_mask = torch.cat([prefix_mask, answer_mask], dim=-1) + if q_state_interface: + lower_mask = upper_mask + else: + lower_prefix_mask = prefix_mask.clone() + lower_prefix_mask[:, -cfg.num_workspace_slots:] = 0 + lower_mask = torch.cat([lower_prefix_mask, answer_mask], dim=-1) + decoded_answer = self._decode_token_block( + ans_embeds, + ans_positions, + lower_mask, + upper_mask, + cache, + cache_position, + return_lower=need_answer_lower, + ) # [B,N_a,d] + if need_answer_lower: + h_ans, answer_lower_hidden = decoded_answer + else: + h_ans = decoded_answer + hidden_for_logits = torch.cat([hidden_for_logits, h_ans[:, :-1]], dim=1) + + logits = self.backbone.lm_head(final_norm(tm, hidden_for_logits)) # [B, N_a, V] + + # Both training-only counterfactual objectives replay only L22-L27. + # Question/answer states below that boundary are shared with the + # factual pass, so neither branch performs another visual read. + q_mask = None + q_keep = None + q_positions = None + if compute_residual_swap or compute_step_retention: + q_mask = question_attention_mask + if q_mask is None: + q_mask = torch.ones_like(question_ids, dtype=torch.long) + q_keep = q_mask.gt(0) + q_pos_1d = q_keep.long().cumsum(dim=-1) - 1 + q_pos_1d = q_pos_1d.masked_fill(~q_keep, 0) + q_positions = q_pos_1d.unsqueeze(0).expand(3, -1, -1) + + step_answer_nll = None + step_answer_valid = None + if compute_step_retention: + if not getattr(cfg, "counterfactual_residual_recurrence", False): + raise RuntimeError( + "step retention is implemented only for CRR" + ) + if len(z_states) < 3: + raise RuntimeError( + "step retention requires at least two CRR residual steps" + ) + if q_mask is None or q_keep is None or q_positions is None: + raise RuntimeError("step retention is missing question layout") + + # The factual path already scores Z_T. Score Z_1..Z_{T-1} in one + # stacked upper-decoder replay to keep the extra launches small. + base_anchor = z_states[0] + partial_prefixes = [] + residual_prefix: list[torch.Tensor] = [] + for residual in z_states[1:-1]: + residual_prefix.append(residual) + partial_prefixes.append( + base_anchor + + self._aggregate_crr_residuals(residual_prefix) + ) + num_partial = len(partial_prefixes) + partial_prefix = torch.cat(partial_prefixes, dim=0) + partial_q_positions = q_positions.repeat(1, num_partial, 1) + partial_q_mask = q_mask.repeat(num_partial, 1) + + if answer_ids is not None and answer_ids.shape[1] > 1: + if ( + answer_lower_hidden is None + or ans_positions is None + or upper_mask is None + ): + raise RuntimeError( + "step retention is missing teacher-forced lower states" + ) + partial_input = torch.cat( + [ + partial_prefix.to(answer_lower_hidden.dtype), + answer_lower_hidden.repeat(num_partial, 1, 1), + ], + dim=1, + ) + partial_positions = torch.cat( + [ + partial_q_positions, + ans_positions.repeat(1, num_partial, 1), + ], + dim=-1, + ) + partial_attention_mask = upper_mask.repeat(num_partial, 1) + else: + partial_input = partial_prefix + partial_positions = partial_q_positions + partial_attention_mask = partial_q_mask + + partial_ctx = make_split_context( + tm, + partial_input, + partial_positions, + partial_attention_mask, + ) + partial_upper = run_layer_range( + tm, + partial_ctx, + self._upper_decoder_start(), + None, + hidden_states=partial_input, + use_cache=False, + ) + last_q = (q_keep.sum(dim=-1).long() - 1).clamp_min(0) + partial_last_q = last_q.repeat(num_partial) + partial_hidden_for_logits = partial_upper[ + torch.arange( + partial_upper.shape[0], device=partial_upper.device + ), + partial_last_q, + ].unsqueeze(1) + if answer_ids is not None and answer_ids.shape[1] > 1: + n_a = answer_ids.shape[1] + partial_hidden_for_logits = torch.cat( + [ + partial_hidden_for_logits, + partial_upper[:, n_q : n_q + n_a - 1], + ], + dim=1, + ) + partial_logits = self.backbone.lm_head( + final_norm(tm, partial_hidden_for_logits) + ) + partial_nll, partial_valid = _answer_nll_per_example( + partial_logits, labels.repeat(num_partial, 1) + ) + factual_nll, factual_valid = _answer_nll_per_example(logits, labels) + batch_size = labels.shape[0] + step_answer_nll = torch.cat( + [ + partial_nll.view(num_partial, batch_size), + factual_nll.unsqueeze(0), + ], + dim=0, + ).transpose(0, 1) + step_answer_valid = torch.cat( + [ + partial_valid.view(num_partial, batch_size), + factual_valid.unsqueeze(0), + ], + dim=0, + ).transpose(0, 1) + + residual_swap_score_gap = None + residual_swap_valid = None + residual_swap_transition = None + if compute_residual_swap: + if not getattr(cfg, "counterfactual_residual_recurrence", False): + raise RuntimeError( + "counterfactual reliance is implemented only for CRR" + ) + donors, residual_swap_valid = _different_answer_residual_donors( + labels, question_attention_mask + ) + base_anchor = z_states[0] + + if q_mask is None or q_keep is None or q_positions is None: + raise RuntimeError("CRR residual swap is missing question layout") + if getattr(cfg, "transition_aware_reliance_loss", False): + if reliance_transition_index is None: + raise ValueError( + "transition-aware reliance requires " + "reliance_transition_index" + ) + if isinstance(reliance_transition_index, torch.Tensor): + if reliance_transition_index.numel() != 1: + raise ValueError( + "reliance_transition_index must be a scalar" + ) + transition_index = int(reliance_transition_index.item()) + else: + transition_index = int(reliance_transition_index) + swapped_prefix = self._counterfactual_transition_rollout_prefix( + z_states, + donors, + transition_index, + q_mask, + ) + residual_swap_transition = torch.tensor( + transition_index, + dtype=torch.long, + device=labels.device, + ) + else: + aggregated_residual = self._aggregate_crr_residuals(z_states[1:]) + donor_residual = aggregated_residual.index_select(0, donors) + aligned = q_mask.gt(0) & q_mask.index_select(0, donors).gt(0) + donor_residual = donor_residual.masked_fill( + ~aligned.unsqueeze(-1), 0 + ) + swapped_prefix = base_anchor + donor_residual + + if answer_ids is not None and answer_ids.shape[1] > 1: + if answer_lower_hidden is None or ans_positions is None or upper_mask is None: + raise RuntimeError( + "CRR residual swap is missing teacher-forced lower states" + ) + swapped_input = torch.cat( + [swapped_prefix.to(answer_lower_hidden.dtype), answer_lower_hidden], + dim=1, + ) + swapped_positions = torch.cat( + [q_positions, ans_positions], dim=-1 + ) + swapped_attention_mask = upper_mask + else: + swapped_input = swapped_prefix + swapped_positions = q_positions + swapped_attention_mask = q_mask + + # Only L22-L27 are replayed. Vision, the lower backbone, L21, and + # teacher-forced answer states below L22 are shared with the factual + # pass; no second multimodal read or cache is constructed. + swapped_ctx = make_split_context( + tm, + swapped_input, + swapped_positions, + swapped_attention_mask, + ) + swapped_upper = run_layer_range( + tm, + swapped_ctx, + self._upper_decoder_start(), + None, + hidden_states=swapped_input, + use_cache=False, + ) + last_q = (q_keep.sum(dim=-1).long() - 1).clamp_min(0) + swapped_hidden_for_logits = swapped_upper[ + torch.arange(swapped_upper.shape[0], device=swapped_upper.device), + last_q, + ].unsqueeze(1) + if answer_ids is not None and answer_ids.shape[1] > 1: + n_a = answer_ids.shape[1] + swapped_hidden_for_logits = torch.cat( + [ + swapped_hidden_for_logits, + swapped_upper[:, n_q : n_q + n_a - 1], + ], + dim=1, + ) + swapped_logits = self.backbone.lm_head( + final_norm(tm, swapped_hidden_for_logits) + ) + factual_nll, factual_valid = _answer_nll_per_example(logits, labels) + swapped_nll, swapped_valid = _answer_nll_per_example( + swapped_logits, labels + ) + # score = -NLL, hence score_pos - score_swap = NLL_swap - NLL_pos. + residual_swap_score_gap = swapped_nll - factual_nll + residual_swap_valid &= factual_valid & swapped_valid + + loss = None + if labels is not None: + loss = answer_cross_entropy( + logits, + labels, + per_example=bool( + getattr(cfg, "per_example_answer_loss", False) + ), + ) + + iface = getattr(self, "_iface_loss", None) + self._iface_loss = None # consume-once + functional = getattr(self, "_functional_state_loss", None) + self._functional_state_loss = None # consume-once + return CloseCausalLMOutput( + loss=loss, + logits=logits, + past_key_values=cache if use_cache else None, + workspace_states=tuple(z_states), + v_star_pooled=v_pooled, + iface_loss=iface, + functional_state_loss=functional, + residual_swap_score_gap=residual_swap_score_gap, + residual_swap_valid=residual_swap_valid, + residual_swap_transition=residual_swap_transition, + step_answer_nll=step_answer_nll, + step_answer_valid=step_answer_valid, + ) + +__all__ = [ + "CloseQwen3VLForConditionalGeneration", + "WorkspaceRead", + "WorkspaceTransition", + "RawEvidenceQuestionTransition", + "answer_cross_entropy", + "build_adapter_config", + "CloseCausalLMOutput", + "LatentPolicyTrace", + "CRRLatentPolicyAdapter", +]