"""Strict persistent-visual CVRR wrapper for Gemma 3 and Gemma 4. The implementation intentionally wraps released Transformers models instead of copying their decoder source. Native layer-call arguments are captured during frozen prefix passes and replayed for the shared recurrent cell and upper text-only continuation. This preserves each release's masks, rotary geometry, and attention type while enforcing a visibly auditable interface: the upper decoder receives only non-visual recurrent rows. """ from __future__ import annotations import contextlib import io import json import math import pathlib from dataclasses import dataclass from typing import Any import torch import torch.nn as nn import torch.nn.functional as F from .source_helpers import _layer_hidden class LoRALinear(nn.Module): def __init__( self, base: nn.Linear, *, rank: int, alpha: float, dropout: float, ): super().__init__() if rank <= 0: raise ValueError("LoRA rank must be positive") self.base = base for parameter in self.base.parameters(): parameter.requires_grad_(False) self.rank = int(rank) self.scale = float(alpha) / float(rank) self.dropout = nn.Dropout(float(dropout)) self.lora_A = nn.Parameter(torch.empty(rank, base.in_features)) self.lora_B = nn.Parameter(torch.zeros(base.out_features, rank)) nn.init.kaiming_uniform_(self.lora_A, a=math.sqrt(5)) self.enabled = True def forward(self, inputs): result = self.base(inputs) if not self.enabled: return result update = F.linear(self.dropout(inputs).float(), self.lora_A.float()) update = F.linear(update, self.lora_B.float()) return result + (update * self.scale).to(result.dtype) def _module_parent(root: nn.Module, path: str): parts = path.split(".") parent = root for part in parts[:-1]: parent = getattr(parent, part) return parent, parts[-1] def inject_cell_lora( cell: nn.Module, *, rank: int, alpha: float, dropout: float, suffixes: set[str] | None = None, ) -> dict[str, LoRALinear]: if suffixes is None: suffixes = { "q_proj", "k_proj", "v_proj", "o_proj", "gate_proj", "up_proj", "down_proj", } selected = { name: module for name, module in cell.named_modules() if isinstance(module, nn.Linear) and name.rsplit(".", 1)[-1] in suffixes } if not selected: raise RuntimeError("no attention/MLP projections found in recurrent cell") wrappers = {} # Materialize the list before replacing children during traversal. for name, module in selected.items(): parent, child = _module_parent(cell, name) wrapper = LoRALinear( module, rank=rank, alpha=alpha, dropout=dropout ) setattr(parent, child, wrapper) wrappers[name] = wrapper return wrappers @dataclass class LayerCall: positional_tail: tuple[Any, ...] keywords: dict[str, Any] @dataclass class GemmaCVRRTrace: """Frozen native states needed for one strict recurrent rollout. ``scaffold`` is multimodal and is consumed only by the shared recurrent cell. ``text_calls`` comes from an image-free sequence and is the sole context replayed by the upper decoder. Keeping those two objects separate makes the no-bypass contract directly inspectable. """ scaffold: torch.Tensor text_rows: torch.Tensor visual_rows: torch.Tensor question_valid: torch.Tensor base_anchor: torch.Tensor r1: torch.Tensor mm_cell_call: LayerCall text_calls: dict[int, LayerCall] @property def question_lengths(self) -> torch.Tensor: return self.question_valid.sum(dim=1) @property def visual_lengths(self) -> torch.Tensor: return self.visual_rows.sum(dim=1) class _StopAfterCell(RuntimeError): pass def _capture_call(storage: dict[int, LayerCall], index: int): def hook(_module, args, kwargs): storage[index] = LayerCall(tuple(args[1:]), dict(kwargs)) return hook def _gather_rows(full, mask): batch, _, width = full.shape lengths = mask.sum(dim=1).tolist() result = full.new_zeros((batch, max(lengths), width)) for row, length in enumerate(lengths): result[row, :length] = full[row, mask[row]] return result def _gather_ids(full, mask, *, pad_value: int): lengths = mask.sum(dim=1).tolist() result = full.new_full((full.shape[0], max(lengths)), pad_value) valid = torch.zeros_like(result, dtype=torch.bool) for row, length in enumerate(lengths): result[row, :length] = full[row, mask[row]] valid[row, :length] = True return result, valid def _replace_rows(full, mask, rows): result = full.clone() for batch_index in range(full.shape[0]): count = int(mask[batch_index].sum()) result[batch_index, mask[batch_index]] = rows[batch_index, :count] return result class GemmaCVRR(nn.Module): """One localized Gemma backbone with one shared recurrent-cell LoRA.""" def __init__( self, model_path: str, *, ell_star: int, steps: int = 4, beta: float = 0.33, rank: int = 32, alpha: float = 12.0, dropout: float = 0.01, device: str | torch.device = "cuda:0", offline: bool = True, ): super().__init__() from transformers import AutoConfig self.model_path = str(model_path) self.device_ref = torch.device(device) config = AutoConfig.from_pretrained( model_path, local_files_only=offline ) if config.model_type == "gemma3": from transformers import Gemma3ForConditionalGeneration as ModelClass elif config.model_type == "gemma4_unified": from transformers import ( Gemma4UnifiedForConditionalGeneration as ModelClass, ) if int(getattr(config.text_config, "num_kv_shared_layers", 0)): raise NotImplementedError( "Gemma4 cross-layer shared KV would be an upper visual bypass" ) else: raise ValueError(f"unsupported Gemma model_type={config.model_type!r}") self.base_model = ModelClass.from_pretrained( model_path, dtype=torch.bfloat16, device_map=str(self.device_ref), local_files_only=offline, attn_implementation="sdpa", ) self.model_type = config.model_type self.layers = self.base_model.model.language_model.layers self.ell_star = int(ell_star) self.cell_index = self.ell_star + 1 self.upper_start = self.cell_index + 1 if not 0 <= self.ell_star <= len(self.layers) - 2: raise ValueError( f"ell_star={ell_star} must leave a recurrent cell and upper decoder" ) if steps < 2: raise ValueError("CVRR training requires at least two recurrent states") if not 0.0 <= beta <= 1.0: raise ValueError("beta must lie in [0,1]") self.steps = int(steps) self.beta = float(beta) for parameter in self.base_model.parameters(): parameter.requires_grad_(False) self.lora = inject_cell_lora( self.layers[self.cell_index], rank=rank, alpha=alpha, dropout=dropout, ) # The dense checkpoint was placed before adapters were constructed; # newly allocated A/B tensors otherwise remain on CPU until an outer # training entrypoint happens to call ``model.to(device)``. for module in self.lora.values(): module.to(self.device_ref) self.rank = int(rank) self.alpha = float(alpha) self.adapter_dropout = float(dropout) self.base_model.eval() @contextlib.contextmanager def adapters(self, enabled: bool): previous = [module.enabled for module in self.lora.values()] for module in self.lora.values(): module.enabled = bool(enabled) try: yield finally: for module, value in zip(self.lora.values(), previous): module.enabled = value def train(self, mode: bool = True): super().train(mode) # Frozen dense modules stay deterministic; only LoRA dropout follows # training mode. self.base_model.eval() for module in self.lora.values(): module.dropout.train(mode) return self def _modality(self, mm_inputs): if "token_type_ids" in mm_inputs: return mm_inputs["token_type_ids"] if "mm_token_type_ids" in mm_inputs: return mm_inputs["mm_token_type_ids"] raise ValueError("Gemma multimodal inputs have no modality IDs") def _pad_token_id(self) -> int: return int(self.base_model.config.text_config.pad_token_id) def _initial_multimodal(self, mm_inputs): calls: dict[int, LayerCall] = {} captured = {} cell = self.layers[self.cell_index] def stop(_module, _args, output): captured["hidden"] = _layer_hidden(output).detach() raise _StopAfterCell pre = cell.register_forward_pre_hook( _capture_call(calls, self.cell_index), with_kwargs=True ) post = cell.register_forward_hook(stop) try: with torch.no_grad(), self.adapters(False): try: self.base_model.model( **mm_inputs, use_cache=False, return_dict=True ) except _StopAfterCell: pass finally: pre.remove() post.remove() if "hidden" not in captured or self.cell_index not in calls: raise RuntimeError("failed to capture native multimodal recurrent cell") return captured["hidden"], calls[self.cell_index] def _text_context(self, question_ids, question_mask): calls: dict[int, LayerCall] = {} captured = {} handles = [] for index in range(self.cell_index, len(self.layers)): handles.append( self.layers[index].register_forward_pre_hook( _capture_call(calls, index), with_kwargs=True ) ) def capture_cell(_module, _args, output): captured["anchor"] = _layer_hidden(output).detach() handles.append(self.layers[self.cell_index].register_forward_hook(capture_cell)) try: with torch.no_grad(), self.adapters(False): self.base_model.model( input_ids=question_ids, attention_mask=question_mask, use_cache=False, return_dict=True, ) finally: for handle in handles: handle.remove() missing = [ index for index in range(self.cell_index, len(self.layers)) if index not in calls ] if missing or "anchor" not in captured: raise RuntimeError(f"failed to capture text context; missing={missing}") return captured["anchor"], calls @staticmethod def _call_layer(layer, hidden, call: LayerCall): return _layer_hidden( layer(hidden, *call.positional_tail, **call.keywords) ) def _upper(self, state, text_calls): hidden = state with self.adapters(False): for index in range(self.upper_start, len(self.layers)): hidden = self._call_layer( self.layers[index], hidden, text_calls[index] ) hidden = self.base_model.model.language_model.norm(hidden) logits = self.base_model.lm_head(hidden) if self.model_type == "gemma4_unified": cap = self.base_model.config.text_config.final_logit_softcapping if cap is not None: logits = torch.tanh(logits / cap) * cap return logits def extract(self, mm_inputs: dict[str, torch.Tensor]) -> GemmaCVRRTrace: """Extract the frozen native first read and text-only upper context.""" attention = mm_inputs["attention_mask"].bool() visual = self._modality(mm_inputs).eq(1) & attention text_rows = (~visual) & attention question_ids, question_valid = _gather_ids( mm_inputs["input_ids"], text_rows, pad_value=self._pad_token_id(), ) question_mask = question_valid.long() first_full, mm_cell_call = self._initial_multimodal(mm_inputs) base_anchor, text_calls = self._text_context(question_ids, question_mask) r1 = _gather_rows(first_full, text_rows) if r1.shape != base_anchor.shape: raise RuntimeError( "native multimodal and image-free question states are misaligned: " f"R1={tuple(r1.shape)}, B={tuple(base_anchor.shape)}" ) return GemmaCVRRTrace( scaffold=first_full.detach(), text_rows=text_rows, visual_rows=visual, question_valid=question_valid, base_anchor=base_anchor.detach(), r1=r1.detach(), mm_cell_call=mm_cell_call, text_calls=text_calls, ) def rollout( self, trace: GemmaCVRRTrace, *, steps: int | None = None, initial_state: torch.Tensor | None = None, ) -> list[torch.Tensor]: """Run the shared native cell and return ``[R1, ..., R_T]``.""" horizon = self.steps if steps is None else int(steps) if horizon < 1: raise ValueError("rollout steps must be positive") state = trace.r1 if initial_state is None else initial_state if state.shape != trace.r1.shape: raise ValueError( f"initial state shape {tuple(state.shape)} != {tuple(trace.r1.shape)}" ) states = [state] for _ in range(1, horizon): recurrent_input = _replace_rows( trace.scaffold, trace.text_rows, state ) with self.adapters(True): proposal_full = self._call_layer( self.layers[self.cell_index], recurrent_input, trace.mm_cell_call, ) proposal = _gather_rows(proposal_full, trace.text_rows) state = state + self.beta * (proposal - state) states.append(state) return states def decode_logits( self, trace: GemmaCVRRTrace, state: torch.Tensor, ) -> torch.Tensor: """Decode one question-shaped state through the strict text-only path.""" if state.shape != trace.base_anchor.shape: raise ValueError( f"decoder state shape {tuple(state.shape)} != " f"text anchor {tuple(trace.base_anchor.shape)}" ) # Written explicitly as B + C_T to mirror the method definition. No # multimodal row or multimodal cache is passed to `_upper`. decoder_state = trace.base_anchor + (state - trace.base_anchor) return self._upper(decoder_state, trace.text_calls).float() def next_token_logits( self, trace: GemmaCVRRTrace, state: torch.Tensor, ) -> torch.Tensor: """Return the distribution after each sample's final valid prompt row.""" logits = self.decode_logits(trace, state) row = trace.question_lengths.to(logits.device) - 1 if bool((row < 0).any()): raise ValueError("empty question sequence") batch = torch.arange(logits.shape[0], device=logits.device) return logits[batch, row] def residual(self, trace: GemmaCVRRTrace, state: torch.Tensor) -> torch.Tensor: return state - trace.base_anchor def state_from_residual( self, trace: GemmaCVRRTrace, residual: torch.Tensor, ) -> torch.Tensor: if residual.shape != trace.base_anchor.shape: raise ValueError( f"residual shape {tuple(residual.shape)} != " f"text anchor {tuple(trace.base_anchor.shape)}" ) return trace.base_anchor + residual def forward(self, mm_inputs: dict[str, torch.Tensor], mm_labels): question_labels, _ = _gather_ids( mm_labels, ((~self._modality(mm_inputs).eq(1)) & mm_inputs["attention_mask"].bool()), pad_value=-100, ) trace = self.extract(mm_inputs) state = self.rollout(trace)[-1] logits = self.decode_logits(trace, state) shift_logits = logits[:, :-1] shift_labels = question_labels[:, 1:] token_loss = F.cross_entropy( shift_logits.reshape(-1, shift_logits.shape[-1]), shift_labels.reshape(-1), ignore_index=-100, reduction="none", ).reshape(shift_labels.shape) valid = shift_labels.ne(-100) counts = valid.sum(dim=1).clamp_min(1) per_example = (token_loss * valid).sum(dim=1) / counts return { "loss": per_example.mean(), "logits": logits, "labels": question_labels, "r1": trace.r1.detach(), "rT": state.detach(), "base_anchor": trace.base_anchor.detach(), "visual_rows": ( self._modality(mm_inputs).eq(1) & mm_inputs["attention_mask"].bool() ).sum(dim=1).detach(), } def adapter_state_dict(self): return { name: tensor.detach().cpu() for name, tensor in self.state_dict().items() if ".lora_A" in name or ".lora_B" in name } def save_adapter(self, output_dir: str | pathlib.Path, *, step: int): output = pathlib.Path(output_dir) output.mkdir(parents=True, exist_ok=True) torch.save(self.adapter_state_dict(), output / "adapter_model.pt") metadata = { "format": "gemma_cvrr_lora_v1", "base_model": self.model_path, "model_type": self.model_type, "ell_star": self.ell_star, "cell_layer": self.cell_index, "num_workspace_steps": self.steps, "counterfactual_beta": self.beta, "adapter_rank": self.rank, "adapter_alpha": self.alpha, "adapter_dropout": self.adapter_dropout, "step": int(step), "strict_path": True, } (output / "cvrr_config.json").write_text(json.dumps(metadata, indent=2)) def load_adapter(self, adapter_dir: str | pathlib.Path): """Load an adapter exactly; missing or surplus LoRA tensors are fatal.""" adapter_dir = pathlib.Path(adapter_dir).expanduser().resolve() metadata = json.loads((adapter_dir / "cvrr_config.json").read_text()) checks = { "model_type": self.model_type, "ell_star": self.ell_star, "cell_layer": self.cell_index, "num_workspace_steps": self.steps, "adapter_rank": self.rank, } mismatches = { name: (metadata.get(name), expected) for name, expected in checks.items() if metadata.get(name) != expected } if mismatches: raise ValueError(f"adapter metadata mismatch: {mismatches}") expected_keys = set(self.adapter_state_dict()) try: payload = torch.load( adapter_dir / "adapter_model.pt", map_location="cpu", weights_only=True, ) except TypeError: payload = torch.load(adapter_dir / "adapter_model.pt", map_location="cpu") actual_keys = set(payload) if actual_keys != expected_keys: raise RuntimeError( "adapter tensor mismatch: " f"missing={sorted(expected_keys - actual_keys)[:8]}, " f"unexpected={sorted(actual_keys - expected_keys)[:8]}" ) incompatible = self.load_state_dict(payload, strict=False) unexpected = list(incompatible.unexpected_keys) missing_lora = [key for key in incompatible.missing_keys if key in expected_keys] if unexpected or missing_lora: raise RuntimeError( f"adapter load failed: missing={missing_lora}, unexpected={unexpected}" ) return metadata def _prompt(processor, question: str, hint: str): content = [ {"type": "image"}, {"type": "text", "text": question.strip() + str(hint)}, ] return processor.apply_chat_template( [{"role": "user", "content": content}], tokenize=False, add_generation_prompt=True, ) class GemmaArrowCollator: def __init__(self, processor): self.processor = processor self.tokenizer = processor.tokenizer @staticmethod def _pad_1d(values, pad): width = max(item.shape[0] for item in values) result = values[0].new_full((len(values), width), pad) for index, item in enumerate(values): result[index, : item.shape[0]] = item return result def __call__(self, features): from PIL import Image examples = [] for feature in features: raw = feature["image_bytes"] if isinstance(raw, memoryview): raw = raw.tobytes() with Image.open(io.BytesIO(raw)) as opened: image = opened.convert("RGB") prompt = _prompt( self.processor, str(feature["fixed_question"]), str(feature["fixed_hint"]), ) prompt_item = self.processor( text=prompt, images=[image], return_tensors="pt" ) full_item = self.processor( text=prompt + str(feature["fixed_answer"]).strip(), images=[image], return_tensors="pt", ) prompt_ids = prompt_item["input_ids"][0] full_ids = full_item["input_ids"][0] if not torch.equal(full_ids[: prompt_ids.numel()], prompt_ids): raise RuntimeError("Gemma answer serialization changed the prompt prefix") eos = full_ids.new_tensor([self.tokenizer.eos_token_id]) item = {name: value for name, value in full_item.items()} item["input_ids"] = torch.cat((full_ids, eos)) item["attention_mask"] = torch.cat( (item["attention_mask"][0], torch.ones_like(eos)) ) modality_name = ( "token_type_ids" if "token_type_ids" in item else "mm_token_type_ids" ) item[modality_name] = torch.cat( (item[modality_name][0], torch.zeros_like(eos)) ) answer_ids = item["input_ids"][prompt_ids.numel() :] item["labels"] = torch.cat( (torch.full_like(prompt_ids, -100), answer_ids) ) examples.append(item) sequence_names = { "input_ids": int(self.tokenizer.pad_token_id), "attention_mask": 0, "labels": -100, } modality_name = ( "token_type_ids" if "token_type_ids" in examples[0] else "mm_token_type_ids" ) sequence_names[modality_name] = 0 batch = { name: self._pad_1d([item[name] for item in examples], pad) for name, pad in sequence_names.items() } for name in examples[0]: if name in sequence_names or name == "labels": continue values = [item[name] for item in examples] batch[name] = torch.cat(values, dim=0) labels = batch.pop("labels") return {"mm_inputs": batch, "mm_labels": labels} def move_batch(batch, device): return { "mm_inputs": { name: value.to(device, non_blocking=True) for name, value in batch["mm_inputs"].items() }, "mm_labels": batch["mm_labels"].to(device, non_blocking=True), }