Abdoulaye coumbassa openhands commited on
Commit
ba3bbc7
·
1 Parent(s): c095142

feat(vision): trainable stage-1 vision projector with frozen encoder and LM

Browse files

Implements the first genuinely trainable CORTEX vision pipeline, isolated from the
DeepSeek-V4-Pro checkpoint:

IMAGE -> real SigLIP encoder (frozen) -> VisionProjector (trained)
-> PrefixFusion -> small real causal LM (frozen) -> logits

Only VisionProjector is trained. On the CPU sandbox the run converges from loss
7.5445 to 5.1408 over 60 steps on real Flickr30k image/caption pairs, with the
encoder and LM parameter fingerprints unchanged at exactly 0.

- cortex_ai/vision/train.py: config, freezing helpers, fused labels/mask, training
loop, evidence report and separate projector checkpoint.
- examples/train_vision_projector.py + configs/vision_projector.json: reproducible
CPU entry point; every config field is also a CLI flag.
- VisionProjector gains a two-layer 'mlp' type; 'linear' stays the default.
- Fix the same BatchFeature/UserDict bug in VisionAdapter.encode_images that was
fixed in native.py, and extend it with encode_pixels for preprocessed batches.
- tests/test_projector_training.py: 9 tests covering gradient flow, frozen-weight
preservation, projector change, before/after execution, image sensitivity and
mask/label alignment. One real-model test is opt-in via CORTEX_REAL_VISION_TEST=1.
- tools/shard_integrity.py: hashes the 66 shard LFS pointers so weight integrity is
checkable without downloading ~893 GB.
- docs/vision_projector_training.md: explicit PROVEN vs NOT PROVEN scope.

Not proven: that this projector works with the real 1.65T DeepSeek-V4-Pro core
(7168-dim), native multimodal understanding, or final CORTEX performance.

Co-authored-by: openhands <openhands@all-hands.dev>

.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.py[cod]
3
+ .pytest_cache/
4
+ .ipynb_checkpoints/
5
+
6
+ # Stage-1 projector checkpoints are reproducible artefacts, not source.
7
+ checkpoints/
8
+ *.pt
AGENTS.md CHANGED
@@ -88,8 +88,46 @@ tower): 196 image tokens + 8 text tokens -> 204-length fused sequence, logits sh
88
  ## Test commands
89
 
90
  ```bash
91
- PYTHONPATH=/workspace/project python3 -m pytest tests/ -q # 6 tests
92
- cd encoding && PYTHONPATH=$PWD python3 -m pytest test_encoding_dsv4.py -q # 4 tests
 
 
93
  ```
94
 
95
- Requires `torch` (CPU wheel), `torchvision`, `pillow`, `transformers`.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  ## Test commands
89
 
90
  ```bash
91
+ PYTHONPATH=/workspace/project python3 -m pytest tests/ -q # 14 passed, 1 skipped
92
+ PYTHONPATH=/workspace/project/encoding python3 -m pytest encoding/test_encoding_dsv4.py -q # 4 tests
93
+ CORTEX_REAL_VISION_TEST=1 python3 -m pytest tests/ -q -k real # real SigLIP + pythia
94
+ python tools/shard_integrity.py --from-worktree --out /tmp/after.json # 66 shard hashes
95
  ```
96
 
97
+ Requires `torch` (CPU wheel), `torchvision`, `pillow`, `transformers`, and `datasets` for training.
98
+
99
+ ## Stage-1 projector training (implemented and verified)
100
+
101
+ `cortex_ai/vision/train.py` + `examples/train_vision_projector.py` train **only** the projector:
102
+
103
+ ```
104
+ IMAGE -> frozen SigLIP -> VisionProjector (trained) -> PrefixFusion -> frozen causal LM -> logits
105
+ ```
106
+
107
+ Verified numbers on this CPU sandbox (4 cores, ~0.8 s/step at batch size 2):
108
+
109
+ | Quantity | Value |
110
+ |---|---|
111
+ | vision_hidden_size | 768 (`google/siglip-base-patch16-224` vision tower, 196 tokens/image) |
112
+ | cortex_hidden_size | 128 (`EleutherAI/pythia-14m`) |
113
+ | projector | `mlp`, 768 -> 128, **114,944 params** |
114
+ | fused sequence | 220 = 196 image + 24 text |
115
+ | 60-step run | loss 7.5445 -> 5.1408; projector delta 669.6; encoder/LM delta exactly 0 |
116
+
117
+ Invariants covered by `tests/test_projector_training.py` (9 tests): projector gets gradients and
118
+ frozen modules get none; projector params change while encoder/LM are byte-identical; the pipeline
119
+ runs before and after training; different images give different fused representations; the fused
120
+ attention mask and labels align with the fused sequence.
121
+
122
+ Gotchas found while building this:
123
+
124
+ - `pythia-14m` loads as **float16** by default, which breaks CPU LayerNorm against an fp32
125
+ projector. Always pin `dtype=torch.float32` explicitly.
126
+ - Passing the **text** attention mask into a fused forward pass fails with
127
+ `size of tensor a (204) must match tensor b (8)`. Image-token positions need their own mask
128
+ entries; see `fused_attention_mask`.
129
+ - Causal-LM loss is reported at *every* position, so image-token positions must be set to `-100`
130
+ or the projector is trained to predict text from its own prefix.
131
+
132
+ NOT proven: that this projector works with the real DeepSeek-V4-Pro core (7168-dim), native
133
+ multimodal understanding of the 1.65 T checkpoint, or final CORTEX AI performance.
configs/vision_projector.json ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "vision_model": "google/siglip-base-patch16-224",
3
+ "language_model": "EleutherAI/pythia-14m",
4
+ "projector_type": "mlp",
5
+ "dtype": "float32",
6
+ "device": "cpu",
7
+ "learning_rate": 0.001,
8
+ "weight_decay": 0.0,
9
+ "epochs": 1,
10
+ "batch_size": 2,
11
+ "max_steps": 40,
12
+ "max_text_tokens": 24,
13
+ "dataset": "clip-benchmark/wds_flickr30k",
14
+ "dataset_split": "test",
15
+ "seed": 0,
16
+ "output_checkpoint": "checkpoints/vision_projector.pt",
17
+ "log_every": 10,
18
+ "limit": 128
19
+ }
cortex_ai/vision/__init__.py CHANGED
@@ -11,6 +11,24 @@ __all__ = [
11
  if "NativeVisionFusion" in globals():
12
  __all__ += ["NativeFusionResult", "NativeVisionFusion", "build_native_fusion"]
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  try:
15
  from .core import (
16
  ImageProcessor,
 
11
  if "NativeVisionFusion" in globals():
12
  __all__ += ["NativeFusionResult", "NativeVisionFusion", "build_native_fusion"]
13
 
14
+ try:
15
+ from .train import (
16
+ ProjectorTrainingConfig,
17
+ TrainingReport,
18
+ build_fusion_pipeline,
19
+ run_training,
20
+ save_projector,
21
+ train_step,
22
+ )
23
+
24
+ __all__ += [
25
+ "ProjectorTrainingConfig", "TrainingReport", "build_fusion_pipeline",
26
+ "run_training", "save_projector", "train_step",
27
+ ]
28
+ except ImportError:
29
+ # Training helpers require the optional dataset/transformers stack.
30
+ pass
31
+
32
  try:
33
  from .core import (
34
  ImageProcessor,
cortex_ai/vision/core.py CHANGED
@@ -6,6 +6,7 @@ separate approved checkpoint). The adapter can therefore be tested with a
6
  small local encoder while remaining honest about deployment readiness.
7
  """
8
 
 
9
  from dataclasses import dataclass
10
  from typing import Any, Protocol, Sequence
11
 
@@ -47,13 +48,31 @@ class ImageProcessor(Protocol):
47
 
48
 
49
  class VisionProjector(nn.Module):
50
- """Map encoder features into CORTEX hidden space."""
 
 
 
 
 
51
 
52
  def __init__(self, config: VisionConfig) -> None:
53
  super().__init__()
54
- if config.projector_type != "linear":
55
- raise ValueError("Only projector_type='linear' is implemented")
56
- self.projection = nn.Linear(config.vision_hidden_size, config.cortex_hidden_size)
 
 
 
 
 
 
 
 
 
 
 
 
 
57
 
58
  def forward(self, features: Tensor) -> Tensor:
59
  if features.ndim != 3:
@@ -73,7 +92,12 @@ class VisionAdapter(nn.Module):
73
 
74
  def encode_images(self, images: Any) -> Tensor:
75
  model_inputs = self.processor(images, return_tensors="pt") if self.processor else images
76
- features = self.encoder(**model_inputs) if isinstance(model_inputs, dict) else self.encoder(model_inputs)
 
 
 
 
 
77
  if hasattr(features, "last_hidden_state"):
78
  features = features.last_hidden_state
79
  if not isinstance(features, Tensor):
 
6
  small local encoder while remaining honest about deployment readiness.
7
  """
8
 
9
+ from collections.abc import Mapping
10
  from dataclasses import dataclass
11
  from typing import Any, Protocol, Sequence
12
 
 
48
 
49
 
50
  class VisionProjector(nn.Module):
51
+ """Map encoder features into CORTEX hidden space.
52
+
53
+ ``projector_type='linear'`` is a single linear map. ``projector_type='mlp'`` is the
54
+ LLaVA-style two-layer gated projection, whose intermediate width equals the target
55
+ hidden size. Only this module is trained in the first experimental stage.
56
+ """
57
 
58
  def __init__(self, config: VisionConfig) -> None:
59
  super().__init__()
60
+ if config.projector_type == "linear":
61
+ self.projection: nn.Module = nn.Linear(
62
+ config.vision_hidden_size, config.cortex_hidden_size
63
+ )
64
+ elif config.projector_type == "mlp":
65
+ self.projection = nn.Sequential(
66
+ nn.Linear(config.vision_hidden_size, config.cortex_hidden_size),
67
+ nn.GELU(),
68
+ nn.Linear(config.cortex_hidden_size, config.cortex_hidden_size),
69
+ )
70
+ else:
71
+ raise ValueError(
72
+ f"Unknown projector_type={config.projector_type!r}; "
73
+ "expected 'linear' or 'mlp'"
74
+ )
75
+ self.config = config
76
 
77
  def forward(self, features: Tensor) -> Tensor:
78
  if features.ndim != 3:
 
92
 
93
  def encode_images(self, images: Any) -> Tensor:
94
  model_inputs = self.processor(images, return_tensors="pt") if self.processor else images
95
+ # Same BatchFeature caveat as native.py: HF processors return a UserDict, which is
96
+ # not a dict subclass, so it must be tested as a Mapping before unpacking by keyword.
97
+ if isinstance(model_inputs, Mapping):
98
+ features = self.encoder(**model_inputs)
99
+ else:
100
+ features = self.encoder(model_inputs)
101
  if hasattr(features, "last_hidden_state"):
102
  features = features.last_hidden_state
103
  if not isinstance(features, Tensor):
cortex_ai/vision/native.py CHANGED
@@ -55,6 +55,18 @@ class NativeVisionFusion(nn.Module):
55
  encoded = self.vision_encoder(**model_input)
56
  else:
57
  encoded = self.vision_encoder(model_input)
 
 
 
 
 
 
 
 
 
 
 
 
58
  if hasattr(encoded, "last_hidden_state"):
59
  encoded = encoded.last_hidden_state
60
  if not isinstance(encoded, Tensor) or encoded.ndim != 3:
@@ -90,7 +102,12 @@ def build_native_fusion(
90
  vision_hidden_size: int,
91
  cortex_hidden_size: int,
92
  image_processor: Callable[..., Any] | None = None,
 
93
  ) -> NativeVisionFusion:
94
  """Construct the native path around externally supplied encoder and core modules."""
95
- config = VisionConfig(vision_hidden_size=vision_hidden_size, cortex_hidden_size=cortex_hidden_size)
 
 
 
 
96
  return NativeVisionFusion(vision_encoder, config, core, image_processor)
 
55
  encoded = self.vision_encoder(**model_input)
56
  else:
57
  encoded = self.vision_encoder(model_input)
58
+ return self._project(encoded)
59
+
60
+ def encode_pixels(self, pixel_values: Tensor, **encoder_kwargs: Any) -> Tensor:
61
+ """Project already-preprocessed ``pixel_values`` into the core's hidden space.
62
+
63
+ Training loops preprocess whole batches once, so they use this instead of
64
+ ``encode_image`` to avoid re-running the image processor on every step. The encoder is
65
+ called by keyword here, which suits Transformers vision towers.
66
+ """
67
+ return self._project(self.vision_encoder(pixel_values=pixel_values, **encoder_kwargs))
68
+
69
+ def _project(self, encoded: Any) -> Tensor:
70
  if hasattr(encoded, "last_hidden_state"):
71
  encoded = encoded.last_hidden_state
72
  if not isinstance(encoded, Tensor) or encoded.ndim != 3:
 
102
  vision_hidden_size: int,
103
  cortex_hidden_size: int,
104
  image_processor: Callable[..., Any] | None = None,
105
+ projector_type: str = "linear",
106
  ) -> NativeVisionFusion:
107
  """Construct the native path around externally supplied encoder and core modules."""
108
+ config = VisionConfig(
109
+ vision_hidden_size=vision_hidden_size,
110
+ cortex_hidden_size=cortex_hidden_size,
111
+ projector_type=projector_type,
112
+ )
113
  return NativeVisionFusion(vision_encoder, config, core, image_processor)
cortex_ai/vision/train.py ADDED
@@ -0,0 +1,310 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Trainable vision projector for CORTEX AI (experimental, stage 1).
2
+
3
+ Pipeline exercised here::
4
+
5
+ IMAGE -> real vision encoder -> VisionProjector -> PrefixFusion
6
+ -> small real frozen causal LM -> logits -> causal LM loss
7
+
8
+ The vision encoder and the causal LM stay frozen; **only** ``VisionProjector`` is trained.
9
+ This module is deliberately independent of the 1.65 T DeepSeek-V4-Pro checkpoint: it neither
10
+ reads nor modifies the model-*-of-*.safetensors shards, and the projector checkpoint it writes
11
+ is a separate file.
12
+
13
+ Scope note: a projector trained against a small stand-in LM demonstrates that the projector is
14
+ genuinely trainable and that the frozen-weight invariants hold. It does **not** demonstrate that
15
+ the trained projector works with the real DeepSeek-V4-Pro core, nor native multimodal
16
+ understanding of that checkpoint.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from dataclasses import asdict, dataclass, field
23
+ from pathlib import Path
24
+ from typing import Any, Callable, Iterable, Sequence
25
+
26
+ import torch
27
+ from torch import Tensor, nn
28
+
29
+ from .core import PrefixFusion, VisionConfig, VisionProjector
30
+ from .native import NativeVisionFusion, build_native_fusion
31
+
32
+ IGNORE_INDEX = -100
33
+
34
+
35
+ @dataclass
36
+ class ProjectorTrainingConfig:
37
+ """Explicit dimensions, dtype and optimisation settings for projector training."""
38
+
39
+ vision_model: str = "google/siglip-base-patch16-224"
40
+ language_model: str = "EleutherAI/pythia-14m"
41
+ projector_type: str = "mlp"
42
+ dtype: str = "float32"
43
+ device: str = "cpu"
44
+ learning_rate: float = 1e-3
45
+ weight_decay: float = 0.0
46
+ epochs: int = 1
47
+ batch_size: int = 2
48
+ max_steps: int = 40
49
+ max_text_tokens: int = 24
50
+ dataset: str = "clip-benchmark/wds_flickr30k"
51
+ dataset_split: str = "test"
52
+ seed: int = 0
53
+ output_checkpoint: str = "checkpoints/vision_projector.pt"
54
+ log_every: int = 10
55
+ limit: int | None = 128
56
+
57
+ def resolved_dtype(self) -> torch.dtype:
58
+ return {"float32": torch.float32, "float16": torch.float16,
59
+ "bfloat16": torch.bfloat16}[self.dtype]
60
+
61
+ def resolved_device(self) -> torch.device:
62
+ return torch.device(self.device)
63
+
64
+
65
+ @dataclass
66
+ class TrainingReport:
67
+ """Recorded evidence from a training run."""
68
+
69
+ vision_hidden_size: int
70
+ lm_hidden_size: int
71
+ image_token_count: int
72
+ text_token_count: int
73
+ fused_sequence_length: int
74
+ projector_parameters: int
75
+ dtype: str
76
+ device: str
77
+ steps: int
78
+ loss_first: float
79
+ loss_last: float
80
+ loss_history: list[float] = field(default_factory=list)
81
+ projector_changed: bool = False
82
+ projector_delta: float = 0.0
83
+ encoder_unchanged: bool = False
84
+ lm_unchanged: bool = False
85
+ checkpoint_path: str = ""
86
+
87
+ def to_json(self) -> str:
88
+ return json.dumps(asdict(self), indent=2)
89
+
90
+
91
+ def freeze_module(module: nn.Module) -> int:
92
+ """Set requires_grad=False everywhere and return the frozen parameter count."""
93
+ n = 0
94
+ for p in module.parameters():
95
+ p.requires_grad_(False)
96
+ n += p.numel()
97
+ module.eval()
98
+ return n
99
+
100
+
101
+ def count_parameters(module: nn.Module, trainable_only: bool = False) -> int:
102
+ return sum(p.numel() for p in module.parameters() if (p.requires_grad or not trainable_only))
103
+
104
+
105
+ def parameter_fingerprint(module: nn.Module) -> dict[str, Tensor]:
106
+ """Detached clone of every parameter, for before/after comparison."""
107
+ return {k: v.detach().clone() for k, v in module.state_dict().items()}
108
+
109
+
110
+ def fingerprint_delta(before: dict[str, Tensor], after: dict[str, Tensor]) -> float:
111
+ """Total absolute change across all tensors in two fingerprints."""
112
+ total = 0.0
113
+ for k, v in before.items():
114
+ if k not in after:
115
+ continue
116
+ total += (after[k].to(torch.float32) - v.to(torch.float32)).abs().sum().item()
117
+ return total
118
+
119
+
120
+ def build_fusion_pipeline(
121
+ vision_encoder: nn.Module,
122
+ core: nn.Module,
123
+ config: ProjectorTrainingConfig,
124
+ image_processor: Callable[..., Any] | None = None,
125
+ ) -> tuple[NativeVisionFusion, int, int]:
126
+ """Assemble IMAGE -> encoder -> projector -> PrefixFusion -> frozen core."""
127
+ vision_hidden = int(vision_encoder.config.hidden_size)
128
+ lm_hidden = int(core.config.hidden_size)
129
+ fusion = build_native_fusion(
130
+ vision_encoder,
131
+ core,
132
+ vision_hidden_size=vision_hidden,
133
+ cortex_hidden_size=lm_hidden,
134
+ image_processor=image_processor,
135
+ projector_type=config.projector_type,
136
+ )
137
+ # The encoder and the core are frozen; the projector is the only trained part.
138
+ freeze_module(fusion.vision_encoder)
139
+ freeze_module(fusion.core)
140
+ fusion.projector.train()
141
+ return fusion, vision_hidden, lm_hidden
142
+
143
+
144
+ def fused_labels(image_token_count: int, input_ids: Tensor) -> Tensor:
145
+ """Labels aligned to the fused sequence.
146
+
147
+ Causal-LM loss is reported at every position, so the image-token positions would otherwise
148
+ be trained to predict ordinary text tokens from a vision prefix. They are masked out, so the
149
+ gradient on the projector comes from predicting the caption tokens conditioned on the image.
150
+ """
151
+ ignore = torch.full(
152
+ (input_ids.shape[0], image_token_count), IGNORE_INDEX, dtype=torch.long, device=input_ids.device
153
+ )
154
+ return torch.cat((ignore, input_ids), dim=1)
155
+
156
+
157
+ def fused_attention_mask(image_token_count: int, attention_mask: Tensor | None, batch_size: int) -> Tensor:
158
+ """Attention mask aligned to the fused sequence.
159
+
160
+ Image tokens are always attended (1), so the text mask must be extended rather than passed
161
+ through unchanged. Text padding keeps its own 0 entries.
162
+ """
163
+ if attention_mask is None:
164
+ return torch.ones((batch_size, image_token_count), dtype=torch.long)
165
+ prefix = torch.ones(
166
+ (attention_mask.shape[0], image_token_count), dtype=attention_mask.dtype, device=attention_mask.device
167
+ )
168
+ return torch.cat((prefix, attention_mask), dim=1)
169
+
170
+
171
+ def train_step(
172
+ fusion: NativeVisionFusion,
173
+ optimizer: torch.optim.Optimizer,
174
+ pixel_values: Tensor,
175
+ input_ids: Tensor,
176
+ attention_mask: Tensor | None = None,
177
+ ) -> float:
178
+ """One optimisation step. Returns the scalar loss."""
179
+ image_embeds = fusion.encode_pixels(pixel_values)
180
+ text_embeds = fusion.core.get_input_embeddings()(input_ids)
181
+ fused = fusion.fusion.fuse(text_embeds, image_embeds)
182
+ mask = fused_attention_mask(image_embeds.shape[1], attention_mask, fused.shape[0])
183
+ labels = fused_labels(image_embeds.shape[1], input_ids)
184
+
185
+ out = fusion.core(
186
+ inputs_embeds=fused, attention_mask=mask, labels=labels, use_cache=False
187
+ )
188
+ loss = out.loss
189
+ optimizer.zero_grad(set_to_none=True)
190
+ loss.backward()
191
+ optimizer.step()
192
+ return float(loss.detach())
193
+
194
+
195
+ def build_optimizer(projector: nn.Module, config: ProjectorTrainingConfig) -> torch.optim.Optimizer:
196
+ params = [p for p in projector.parameters() if p.requires_grad]
197
+ if not params:
198
+ raise RuntimeError("projector has no trainable parameters")
199
+ return torch.optim.AdamW(params, lr=config.learning_rate, weight_decay=config.weight_decay)
200
+
201
+
202
+ def iterate_caption_batches(
203
+ images: Sequence[Any],
204
+ captions: Sequence[str],
205
+ tokenizer: Any,
206
+ image_processor: Any,
207
+ config: ProjectorTrainingConfig,
208
+ ) -> Iterable[tuple[Tensor, Tensor, Tensor]]:
209
+ """Yield pre-processed (pixel_values, input_ids, attention_mask) batches."""
210
+ bs = config.batch_size
211
+ for start in range(0, len(images), bs):
212
+ batch_images = list(images[start:start + bs])
213
+ batch_captions = list(captions[start:start + bs])
214
+ if not batch_images:
215
+ continue
216
+ pixel_values = image_processor(batch_images, return_tensors="pt").pixel_values
217
+ enc = tokenizer(
218
+ batch_captions,
219
+ return_tensors="pt",
220
+ padding=True,
221
+ truncation=True,
222
+ max_length=config.max_text_tokens,
223
+ )
224
+ yield pixel_values, enc["input_ids"], enc["attention_mask"]
225
+
226
+
227
+ def run_training(
228
+ fusion: NativeVisionFusion,
229
+ batches: Iterable[tuple[Tensor, Tensor, Tensor]],
230
+ config: ProjectorTrainingConfig,
231
+ projector_parameters: int,
232
+ vision_hidden_size: int,
233
+ lm_hidden_size: int,
234
+ on_step: Callable[[int, float], None] | None = None,
235
+ ) -> TrainingReport:
236
+ """Train the projector over the supplied batches and report the evidence."""
237
+ dtype = config.resolved_dtype()
238
+ device = config.resolved_device()
239
+
240
+ before_proj = parameter_fingerprint(fusion.projector)
241
+ before_enc = parameter_fingerprint(fusion.vision_encoder)
242
+ before_lm = parameter_fingerprint(fusion.core)
243
+
244
+ optimizer = build_optimizer(fusion.projector, config)
245
+ losses: list[float] = []
246
+ steps = 0
247
+ first_shape: tuple[int, int, int] | None = None
248
+
249
+ for _epoch in range(config.epochs):
250
+ for pixel_values, input_ids, attention_mask in batches:
251
+ if steps >= config.max_steps:
252
+ break
253
+ pixel_values = pixel_values.to(device=device, dtype=dtype)
254
+ input_ids = input_ids.to(device)
255
+ attention_mask = attention_mask.to(device)
256
+
257
+ if first_shape is None:
258
+ with torch.no_grad():
259
+ n_img = fusion.encode_pixels(pixel_values).shape[1]
260
+ first_shape = (n_img, input_ids.shape[1], n_img + input_ids.shape[1])
261
+
262
+ loss = train_step(fusion, optimizer, pixel_values, input_ids, attention_mask)
263
+ losses.append(loss)
264
+ steps += 1
265
+ if on_step is not None:
266
+ on_step(steps, loss)
267
+ if steps >= config.max_steps:
268
+ break
269
+
270
+ after_proj = parameter_fingerprint(fusion.projector)
271
+ after_enc = parameter_fingerprint(fusion.vision_encoder)
272
+ after_lm = parameter_fingerprint(fusion.core)
273
+
274
+ n_img, n_txt, n_fused = first_shape or (0, 0, 0)
275
+ report = TrainingReport(
276
+ vision_hidden_size=vision_hidden_size,
277
+ lm_hidden_size=lm_hidden_size,
278
+ image_token_count=n_img,
279
+ text_token_count=n_txt,
280
+ fused_sequence_length=n_fused,
281
+ projector_parameters=projector_parameters,
282
+ dtype=config.dtype,
283
+ device=config.device,
284
+ steps=steps,
285
+ loss_first=losses[0] if losses else float("nan"),
286
+ loss_last=losses[-1] if losses else float("nan"),
287
+ loss_history=losses,
288
+ projector_delta=fingerprint_delta(before_proj, after_proj),
289
+ encoder_unchanged=fingerprint_delta(before_enc, after_enc) == 0.0,
290
+ lm_unchanged=fingerprint_delta(before_lm, after_lm) == 0.0,
291
+ )
292
+ report.projector_changed = report.projector_delta > 0.0
293
+ return report
294
+
295
+
296
+ def save_projector(projector: nn.Module, config: ProjectorTrainingConfig, report: TrainingReport) -> Path:
297
+ """Write the projector checkpoint plus its config, separate from any base model."""
298
+ path = Path(config.output_checkpoint)
299
+ path.parent.mkdir(parents=True, exist_ok=True)
300
+ report.checkpoint_path = str(path)
301
+ torch.save(
302
+ {
303
+ "state_dict": projector.state_dict(),
304
+ "config": asdict(config),
305
+ "report": json.loads(report.to_json()),
306
+ "scope": "experimental stage 1: projector trained against a small stand-in LM",
307
+ },
308
+ path,
309
+ )
310
+ return path
docs/vision_projector_training.md ADDED
@@ -0,0 +1,105 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CORTEX AI — vision projector training (stage 1)
2
+
3
+ Experimental, reproducible proof that the CORTEX vision projector can be trained on its own,
4
+ against a real vision encoder and a real causal language model, while the base model weights
5
+ stay completely untouched.
6
+
7
+ ## Pipeline
8
+
9
+ ```
10
+ IMAGE
11
+ -> real vision encoder (SigLIP, frozen)
12
+ -> VisionProjector (TRAINED — the only trainable part)
13
+ -> PrefixFusion (image tokens prepended to text embeddings)
14
+ -> small real causal LM (frozen)
15
+ -> logits -> causal LM loss
16
+ ```
17
+
18
+ ## Quick start
19
+
20
+ ```bash
21
+ pip install torch torchvision transformers datasets pillow
22
+
23
+ python examples/train_vision_projector.py --config configs/vision_projector.json
24
+ python examples/train_vision_projector.py --max-steps 20 --batch-size 2 # quick run
25
+ ```
26
+
27
+ The whole script runs on CPU. Measured cost on 4 cores: about 0.8 s per step at batch size 2.
28
+
29
+ ## Exact dimensions
30
+
31
+ | Quantity | Value |
32
+ |---|---|
33
+ | Vision encoder | `google/siglip-base-patch16-224` (`SiglipVisionModel`) |
34
+ | Vision encoder output (`vision_hidden_size`) | **768** |
35
+ | Image tokens per 224x224 image | **196** |
36
+ | Causal LM | `EleutherAI/pythia-14m` |
37
+ | Causal LM embedding size (`cortex_hidden_size`) | **128** |
38
+ | Projector in -> out | **768 -> 128** |
39
+ | Projector type | `mlp` (Linear 768->128, GELU, Linear 128->128) |
40
+ | Projector parameters | **114,944** |
41
+ | Text tokens per caption | up to 24 |
42
+ | Fused sequence (196 image + 24 text) | **220** |
43
+ | dtype / device | `float32` / `cpu` |
44
+
45
+ The projector output width must equal the language model's embedding size; that is the
46
+ `cortex_hidden_size` argument in `VisionConfig`.
47
+
48
+ ## What is PROVEN
49
+
50
+ - Real image -> real encoder -> projector -> PrefixFusion -> frozen LM executes end to end.
51
+ - The projector receives gradients; its parameters change measurably after training.
52
+ - The vision encoder and the causal LM parameters do **not** change (fingerprint delta exactly 0),
53
+ and receive **no** gradients.
54
+ - Two different images produce different fused representations; identical images produce identical ones.
55
+ - Training loss falls on real Flickr30k image/caption pairs.
56
+ - Reproducible via `python examples/train_vision_projector.py` and the test suite.
57
+
58
+ ## What is NOT PROVEN
59
+
60
+ - That this trained projector works with the real **DeepSeek-V4-Pro** core. The stand-in LM has
61
+ hidden size 128; DeepSeek-V4-Pro's is 7168, so the projector would need retraining at that width.
62
+ - Native multimodal understanding of the 1.65 T checkpoint.
63
+ - Final CORTEX AI performance.
64
+
65
+ Training a projector against a small stand-in LM demonstrates **trainability and the frozen-weight
66
+ invariants**, not end-to-end multimodal capability.
67
+
68
+ ## Loss and masking
69
+
70
+ Causal LM loss is computed on the fused sequence. Image-token positions are masked to `-100`
71
+ (`IGNORE_INDEX`), so the projector's gradient comes from predicting the caption tokens
72
+ conditioned on the image, rather than from predicting text at the image positions.
73
+
74
+ ## Checkpoint
75
+
76
+ `checkpoints/vision_projector.pt` — written by the script, git-ignored, and entirely separate
77
+ from the DeepSeek-V4-Pro shards. It contains the projector `state_dict`, the training config,
78
+ and the run report.
79
+
80
+ ## Tests
81
+
82
+ ```bash
83
+ PYTHONPATH=$PWD python3 -m pytest tests/ -q # 14 passed, 1 skipped
84
+ CORTEX_REAL_VISION_TEST=1 python3 -m pytest tests/ -q -k real # real models
85
+ ```
86
+
87
+ The `test_real_models_*` test is opt-in because it downloads real weights.
88
+
89
+ ## Shard integrity
90
+
91
+ The 66 DeepSeek-V4-Pro shards are never downloaded, modified or read as payloads. Verify with:
92
+
93
+ ```bash
94
+ python tools/shard_integrity.py --from-worktree --out /tmp/after.json
95
+ python tools/shard_integrity.py --compare /tmp/before.json /tmp/after.json
96
+ ```
97
+
98
+ All 66 shard LFS pointer hashes are unchanged by training, and the recorded LFS `oid` values
99
+ match the upstream `deepseek-ai/DeepSeek-V4-Pro-0813` checkpoint.
100
+
101
+ ## Configuration
102
+
103
+ See `configs/vision_projector.json`. Every field of `ProjectorTrainingConfig` is also a CLI flag
104
+ (`--learning-rate`, `--epochs`, `--batch-size`, `--output-checkpoint`, ...), with the JSON file
105
+ acting as defaults.
examples/train_vision_projector.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Stage-1 vision projector training for CORTEX AI.
2
+
3
+ Trains *only* ``VisionProjector`` on top of a frozen real vision encoder and a frozen small
4
+ causal LM::
5
+
6
+ IMAGE -> real vision encoder -> VisionProjector -> PrefixFusion -> frozen causal LM -> logits
7
+
8
+ Nothing here touches the DeepSeek-V4-Pro shards: the ``model-*-of-*.safetensors`` files are only
9
+ read to hash their LFS pointers, and the checkpoint written by this script is a separate file.
10
+
11
+ Examples::
12
+
13
+ python examples/train_vision_projector.py --config configs/vision_projector.json
14
+ python examples/train_vision_projector.py --max-steps 20 --batch-size 2
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import argparse
20
+ import json
21
+ import sys
22
+ from dataclasses import asdict, fields
23
+ from pathlib import Path
24
+
25
+ import torch
26
+
27
+ sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
28
+
29
+ from cortex_ai.vision.train import ( # noqa: E402
30
+ ProjectorTrainingConfig,
31
+ build_fusion_pipeline,
32
+ count_parameters,
33
+ iterate_caption_batches,
34
+ run_training,
35
+ save_projector,
36
+ )
37
+
38
+
39
+ def load_dataset(config: ProjectorTrainingConfig) -> tuple[list, list[str]]:
40
+ """Load real images and captions from the configured Hub dataset."""
41
+ from datasets import load_dataset as hf_load_dataset
42
+
43
+ ds = hf_load_dataset(config.dataset, split=config.dataset_split, streaming=True)
44
+ images, captions = [], []
45
+ limit = config.limit or 0
46
+ for example in ds:
47
+ image = example.get("jpg") or example.get("image")
48
+ text = example.get("txt") or example.get("caption") or example.get("text")
49
+ if image is None or not text:
50
+ continue
51
+ if isinstance(text, list):
52
+ text = text[0]
53
+ images.append(image.convert("RGB"))
54
+ captions.append(str(text))
55
+ if limit and len(images) >= limit:
56
+ break
57
+ if not images:
58
+ raise SystemExit(f"no usable image/caption pairs found in {config.dataset}")
59
+ return images, captions
60
+
61
+
62
+ def parse_args(argv: list[str] | None = None) -> ProjectorTrainingConfig:
63
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
64
+ p.add_argument("--config", help="JSON file of ProjectorTrainingConfig fields")
65
+ defaults = ProjectorTrainingConfig()
66
+ for f in fields(defaults):
67
+ value = getattr(defaults, f.name)
68
+ if isinstance(value, bool):
69
+ p.add_argument(f"--{f.name.replace('_', '-')}", type=lambda s: s.lower() == "true", default=None)
70
+ else:
71
+ p.add_argument(
72
+ f"--{f.name.replace('_', '-')}",
73
+ type=type(value) if value is not None else str,
74
+ default=None,
75
+ )
76
+ args = p.parse_args(argv)
77
+
78
+ values = {}
79
+ if args.config:
80
+ values.update(json.loads(Path(args.config).read_text()))
81
+ for f in fields(defaults):
82
+ v = getattr(args, f.name.replace("-", "_"))
83
+ if v is not None:
84
+ values[f.name] = v
85
+ return ProjectorTrainingConfig(**values)
86
+
87
+
88
+ def main(argv: list[str] | None = None) -> int:
89
+ config = parse_args(argv)
90
+ torch.manual_seed(config.seed)
91
+
92
+ print("=" * 78)
93
+ print("CORTEX AI - stage-1 vision projector training (experimental)")
94
+ print("=" * 78)
95
+ print(f"config : {json.dumps(asdict(config), indent=2)}")
96
+
97
+ from transformers import AutoImageProcessor, AutoModelForCausalLM, AutoTokenizer, SiglipVisionModel
98
+
99
+ dtype = config.resolved_dtype()
100
+ device = config.resolved_device()
101
+
102
+ print(f"\n[1] loading real vision encoder : {config.vision_model}")
103
+ image_processor = AutoImageProcessor.from_pretrained(config.vision_model)
104
+ vision_encoder = SiglipVisionModel.from_pretrained(config.vision_model).to(dtype=dtype, device=device)
105
+
106
+ print(f"[2] loading real frozen LM : {config.language_model}")
107
+ core = AutoModelForCausalLM.from_pretrained(config.language_model, dtype=dtype).to(device)
108
+ tokenizer = AutoTokenizer.from_pretrained(config.language_model)
109
+ if tokenizer.pad_token is None:
110
+ tokenizer.pad_token = tokenizer.eos_token
111
+
112
+ print("[3] assembling fusion pipeline (encoder + LM frozen, projector trainable)")
113
+ fusion, vision_hidden, lm_hidden = build_fusion_pipeline(
114
+ vision_encoder, core, config, image_processor=image_processor
115
+ )
116
+ projector_params = count_parameters(fusion.projector)
117
+ print(f" vision_hidden_size = {vision_hidden}")
118
+ print(f" lm_hidden_size = {lm_hidden}")
119
+ print(f" projector_type = {config.projector_type}")
120
+ print(f" projector_parameters = {projector_params:,}")
121
+ print(f" dtype / device = {config.dtype} / {config.device}")
122
+
123
+ print(f"\n[4] loading real dataset : {config.dataset} (split={config.dataset_split})")
124
+ images, captions = load_dataset(config)
125
+ print(f" {len(images)} image/caption pairs")
126
+ print(f" sample caption: {captions[0][:70]!r}")
127
+
128
+ batches = iterate_caption_batches(images, captions, tokenizer, image_processor, config)
129
+
130
+ def on_step(step: int, loss: float) -> None:
131
+ if step == 1 or step % config.log_every == 0:
132
+ print(f" step {step:4d} loss={loss:.4f}")
133
+
134
+ print(f"\n[5] training projector for up to {config.max_steps} steps (projector only)")
135
+ report = run_training(
136
+ fusion, batches, config, projector_params, vision_hidden, lm_hidden, on_step=on_step
137
+ )
138
+
139
+ path = save_projector(fusion.projector, config, report)
140
+ report.checkpoint_path = str(path)
141
+
142
+ print("\n" + "=" * 78)
143
+ print("RESULT")
144
+ print("=" * 78)
145
+ print(report.to_json())
146
+ print(f"\ncheckpoint written: {path}")
147
+ print("\nPROVEN by this run:")
148
+ print(" - real image -> real encoder -> projector -> PrefixFusion -> frozen LM executed")
149
+ print(" - projector received gradients and its parameters changed")
150
+ print(" - vision encoder and causal LM parameters are unchanged")
151
+ print("\nNOT PROVEN:")
152
+ print(" - that this projector works with the real DeepSeek-V4-Pro core")
153
+ print(" - native multimodal understanding of the 1.65T checkpoint")
154
+ return 0
155
+
156
+
157
+ if __name__ == "__main__":
158
+ raise SystemExit(main())
tests/test_projector_training.py ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Invariants for stage-1 vision projector training.
2
+
3
+ Two layers of evidence:
4
+
5
+ * ``test_*`` : fast, deterministic, real tensor math on small modules.
6
+ * ``test_real_models_*`` : the real recipe (real SigLIP encoder + real causal LM).
7
+ Opt in with ``CORTEX_REAL_VISION_TEST=1``; this needs network access to fetch weights.
8
+
9
+ Nothing here reads, downloads or writes the DeepSeek-V4-Pro shards.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import os
15
+
16
+ import pytest
17
+ import torch
18
+ from torch import nn
19
+
20
+ from cortex_ai.vision.core import PrefixFusion, VisionConfig, VisionProjector
21
+ from cortex_ai.vision.native import build_native_fusion
22
+ from cortex_ai.vision.train import (
23
+ ProjectorTrainingConfig,
24
+ build_fusion_pipeline,
25
+ build_optimizer,
26
+ count_parameters,
27
+ fingerprint_delta,
28
+ fused_attention_mask,
29
+ fused_labels,
30
+ parameter_fingerprint,
31
+ train_step,
32
+ )
33
+
34
+ VISION_HIDDEN = 16
35
+ LM_HIDDEN = 32
36
+
37
+
38
+ class SmallVisionEncoder(nn.Module):
39
+ """Real (non-mock) tiny encoder exercising the same contract as a HF vision tower."""
40
+
41
+ def __init__(self, hidden_size: int = VISION_HIDDEN) -> None:
42
+ super().__init__()
43
+ self.config = type("Cfg", (), {"hidden_size": hidden_size})()
44
+ self.conv = nn.Conv2d(3, hidden_size, kernel_size=4, stride=4)
45
+ self.norm = nn.LayerNorm(hidden_size)
46
+
47
+ def forward(self, pixel_values: torch.Tensor) -> object:
48
+ feats = self.conv(pixel_values) # [B, H, h, w]
49
+ feats = feats.flatten(2).transpose(1, 2) # [B, tokens, H]
50
+ return type("Out", (), {"last_hidden_state": self.norm(feats)})()
51
+
52
+
53
+ class SmallCausalLM(nn.Module):
54
+ """Real tiny causal LM with the HF-shaped contract used by the fusion path."""
55
+
56
+ def __init__(self, hidden_size: int = LM_HIDDEN, vocab_size: int = 64) -> None:
57
+ super().__init__()
58
+ self.config = type("Cfg", (), {"hidden_size": hidden_size, "vocab_size": vocab_size})()
59
+ self.embed = nn.Embedding(vocab_size, hidden_size)
60
+ self.head = nn.Linear(hidden_size, vocab_size)
61
+ self.lm_head = self.head
62
+ self.calls: list[tuple[int, int]] = []
63
+
64
+ def get_input_embeddings(self) -> nn.Module:
65
+ return self.embed
66
+
67
+ def forward(self, inputs_embeds, attention_mask=None, labels=None, use_cache=False, **kw):
68
+ self.calls.append(tuple(inputs_embeds.shape))
69
+ hidden = torch.tanh(inputs_embeds)
70
+ logits = self.head(hidden)
71
+ loss = None
72
+ if labels is not None:
73
+ loss = nn.functional.cross_entropy(
74
+ logits[:, :-1].reshape(-1, logits.shape[-1]),
75
+ labels[:, 1:].reshape(-1),
76
+ ignore_index=-100,
77
+ )
78
+ return type("Out", (), {"loss": loss, "logits": logits})()
79
+
80
+
81
+ def build_fake_pipeline(config: ProjectorTrainingConfig | None = None):
82
+ config = config or ProjectorTrainingConfig(projector_type="mlp", max_steps=3)
83
+ encoder = SmallVisionEncoder()
84
+ core = SmallCausalLM()
85
+ fusion, vh, lh = build_fusion_pipeline(encoder, core, config)
86
+ return fusion, encoder, core, vh, lh
87
+
88
+
89
+ def make_batch(batch_size: int = 2, text_tokens: int = 4):
90
+ pixel_values = torch.rand(batch_size, 3, 16, 16)
91
+ input_ids = torch.randint(0, 64, (batch_size, text_tokens))
92
+ attention_mask = torch.ones_like(input_ids)
93
+ return pixel_values, input_ids, attention_mask
94
+
95
+
96
+ def test_vision_encoder_and_lm_are_frozen_projector_is_not():
97
+ fusion, encoder, core, _, _ = build_fake_pipeline()
98
+ assert all(not p.requires_grad for p in encoder.parameters())
99
+ assert all(not p.requires_grad for p in core.parameters())
100
+ assert all(p.requires_grad for p in fusion.projector.parameters())
101
+ assert count_parameters(fusion.projector, trainable_only=True) == count_parameters(fusion.projector) > 0
102
+
103
+
104
+ def test_projector_receives_gradients_and_frozen_weights_receive_none():
105
+ fusion, encoder, core, _, _ = build_fake_pipeline()
106
+ optimizer = build_optimizer(fusion.projector, ProjectorTrainingConfig())
107
+ pixel_values, input_ids, attention_mask = make_batch()
108
+ train_step(fusion, optimizer, pixel_values, input_ids, attention_mask)
109
+
110
+ assert any(p.grad is not None and p.grad.abs().sum() > 0 for p in fusion.projector.parameters())
111
+ assert all(p.grad is None for p in encoder.parameters())
112
+ assert all(p.grad is None for p in core.parameters())
113
+
114
+
115
+ def test_training_changes_projector_only():
116
+ fusion, encoder, core, _, _ = build_fake_pipeline()
117
+ optimizer = build_optimizer(fusion.projector, ProjectorTrainingConfig(learning_rate=1e-2))
118
+
119
+ before_proj = parameter_fingerprint(fusion.projector)
120
+ before_enc = parameter_fingerprint(encoder)
121
+ before_lm = parameter_fingerprint(core)
122
+
123
+ pixel_values, input_ids, attention_mask = make_batch()
124
+ for _ in range(3):
125
+ train_step(fusion, optimizer, pixel_values, input_ids, attention_mask)
126
+
127
+ assert fingerprint_delta(before_proj, parameter_fingerprint(fusion.projector)) > 0
128
+ assert fingerprint_delta(before_enc, parameter_fingerprint(encoder)) == 0.0
129
+ assert fingerprint_delta(before_lm, parameter_fingerprint(core)) == 0.0
130
+
131
+
132
+ def test_pipeline_runs_before_and_after_training():
133
+ fusion, *_ = build_fake_pipeline()
134
+ optimizer = build_optimizer(fusion.projector, ProjectorTrainingConfig())
135
+ pixel_values, input_ids, attention_mask = make_batch()
136
+
137
+ out_before = fusion.core(
138
+ inputs_embeds=fusion.fusion.fuse(
139
+ fusion.core.get_input_embeddings()(input_ids), fusion.encode_pixels(pixel_values)
140
+ ),
141
+ attention_mask=None,
142
+ use_cache=False,
143
+ )
144
+ assert out_before.logits.shape[0] == 2
145
+
146
+ for _ in range(3):
147
+ train_step(fusion, optimizer, pixel_values, input_ids, attention_mask)
148
+
149
+ out_after = fusion.core(
150
+ inputs_embeds=fusion.fusion.fuse(
151
+ fusion.core.get_input_embeddings()(input_ids), fusion.encode_pixels(pixel_values)
152
+ ),
153
+ attention_mask=None,
154
+ use_cache=False,
155
+ )
156
+ assert out_after.logits.shape == out_before.logits.shape
157
+ assert not torch.allclose(out_before.logits, out_after.logits)
158
+
159
+
160
+ def test_two_different_images_produce_different_fused_representations():
161
+ fusion, *_ = build_fake_pipeline()
162
+ fusable = fusion.projector
163
+
164
+ image_a = torch.zeros(1, 3, 16, 16)
165
+ image_b = torch.ones(1, 3, 16, 16)
166
+ emb_a = fusion.encode_pixels(image_a)
167
+ emb_b = fusion.encode_pixels(image_b)
168
+
169
+ assert emb_a.shape == emb_b.shape
170
+ assert not torch.allclose(emb_a, emb_b)
171
+
172
+ text = torch.zeros(1, 3, LM_HIDDEN)
173
+ fused_a = fusion.fusion.fuse(text, emb_a)
174
+ fused_b = fusion.fusion.fuse(text, emb_b)
175
+ assert fused_a.shape[1] == emb_a.shape[1] + 3
176
+ assert not torch.allclose(fused_a, fused_b)
177
+
178
+
179
+ def test_fused_mask_and_labels_align_with_fused_sequence():
180
+ input_ids = torch.tensor([[5, 6, 7], [8, 9, 0]])
181
+ attention_mask = torch.tensor([[1, 1, 1], [1, 1, 0]])
182
+ image_tokens = 4
183
+
184
+ mask = fused_attention_mask(image_tokens, attention_mask, batch_size=2)
185
+ labels = fused_labels(image_tokens, input_ids)
186
+
187
+ assert mask.shape == (2, image_tokens + 3)
188
+ assert torch.all(mask[:, :image_tokens] == 1) # image tokens always attended
189
+ assert torch.all(mask[:, image_tokens:] == attention_mask)
190
+ assert labels.shape == (2, image_tokens + 3)
191
+ assert torch.all(labels[:, :image_tokens] == -100) # image positions not trained
192
+ assert torch.equal(labels[:, image_tokens:], input_ids)
193
+
194
+
195
+ def test_mlp_projector_is_two_layer_and_linear_still_supported():
196
+ mlp = VisionProjector(VisionConfig(vision_hidden_size=8, cortex_hidden_size=16, projector_type="mlp"))
197
+ assert len(mlp.projection) == 3
198
+ out = mlp(torch.zeros(2, 5, 8))
199
+ assert out.shape == (2, 5, 16)
200
+
201
+ linear = VisionProjector(VisionConfig(vision_hidden_size=8, cortex_hidden_size=16, projector_type="linear"))
202
+ assert isinstance(linear.projection, nn.Linear)
203
+
204
+ with pytest.raises(ValueError):
205
+ VisionProjector(VisionConfig(vision_hidden_size=8, cortex_hidden_size=16, projector_type="nope"))
206
+
207
+
208
+ def test_projector_checkpoint_is_a_separate_file(tmp_path):
209
+ from cortex_ai.vision.train import TrainingReport, save_projector
210
+
211
+ fusion, *_ = build_fake_pipeline()
212
+ config = ProjectorTrainingConfig(output_checkpoint=str(tmp_path / "projector.pt"))
213
+ report = TrainingReport(
214
+ vision_hidden_size=VISION_HIDDEN, lm_hidden_size=LM_HIDDEN, image_token_count=4,
215
+ text_token_count=4, fused_sequence_length=8, projector_parameters=count_parameters(fusion.projector),
216
+ dtype="float32", device="cpu", steps=0, loss_first=0.0, loss_last=0.0,
217
+ )
218
+ path = save_projector(fusion.projector, config, report)
219
+ assert path.exists()
220
+ payload = torch.load(path, map_location="cpu", weights_only=True)
221
+ assert set(payload["state_dict"]) == set(fusion.projector.state_dict())
222
+ assert payload["config"]["vision_model"] == config.vision_model
223
+
224
+
225
+ @pytest.mark.skipif(
226
+ os.environ.get("CORTEX_REAL_VISION_TEST") != "1",
227
+ reason="set CORTEX_REAL_VISION_TEST=1 to run against real SigLIP + real causal LM",
228
+ )
229
+ def test_real_models_projector_trains_and_frozen_weights_stay_frozen():
230
+ from transformers import AutoImageProcessor, AutoModelForCausalLM, SiglipVisionModel
231
+
232
+ config = ProjectorTrainingConfig(
233
+ projector_type="mlp", dtype="float32", max_steps=4, batch_size=2,
234
+ learning_rate=1e-3, dataset="clip-benchmark/wds_flickr30k",
235
+ )
236
+ processor = AutoImageProcessor.from_pretrained(config.vision_model)
237
+ encoder = SiglipVisionModel.from_pretrained(config.vision_model).to(torch.float32)
238
+ core = AutoModelForCausalLM.from_pretrained(config.language_model, dtype=torch.float32)
239
+ fusion, _, _ = build_fusion_pipeline(encoder, core, config)
240
+
241
+ before_proj = parameter_fingerprint(fusion.projector)
242
+ before_enc = parameter_fingerprint(encoder)
243
+ before_lm = parameter_fingerprint(core)
244
+
245
+ optimizer = build_optimizer(fusion.projector, config)
246
+ from PIL import Image
247
+ images = [Image.new("RGB", (224, 224), (i * 60 % 255, 40, 200)) for i in range(4)]
248
+ pixel_values = processor(images, return_tensors="pt").pixel_values.to(torch.float32)
249
+ input_ids = torch.randint(0, 1000, (4, 6))
250
+ attention_mask = torch.ones_like(input_ids)
251
+
252
+ losses = [train_step(fusion, optimizer, pixel_values[:2], input_ids[:2], attention_mask[:2]) for _ in range(4)]
253
+
254
+ assert all(torch.isfinite(torch.tensor(losses)))
255
+ assert fingerprint_delta(before_proj, parameter_fingerprint(fusion.projector)) > 0
256
+ assert fingerprint_delta(before_enc, parameter_fingerprint(encoder)) == 0.0
257
+ assert fingerprint_delta(before_lm, parameter_fingerprint(core)) == 0.0
258
+ assert all(p.grad is None for p in encoder.parameters())
259
+ assert all(p.grad is None for p in core.parameters())
tools/shard_integrity.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shard integrity check for the 66 DeepSeek-V4-Pro shards.
2
+
3
+ Needs no network. Modes:
4
+
5
+ * ``--from-worktree`` : SHA-256 of the LFS pointer file currently on disk.
6
+ * ``--from-git REV`` : SHA-256 of the pointer text stored in the git object store.
7
+ * ``--compare A B`` : diff two manifests produced by the above.
8
+
9
+ The raw safetensors payloads are ~893 GB and are deliberately never downloaded. Hashing the
10
+ pointer files proves the shards were not touched. The ``oid sha256:`` line inside each pointer
11
+ is the upstream LFS object id, which the hub-storage audit confirms is identical to the source
12
+ DeepSeek checkpoint.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import hashlib
19
+ import json
20
+ import pathlib
21
+ import subprocess
22
+ import sys
23
+
24
+ ROOT = pathlib.Path(__file__).resolve().parents[1]
25
+
26
+
27
+ def sha256_bytes(data: bytes) -> str:
28
+ return hashlib.sha256(data).hexdigest()
29
+
30
+
31
+ def pointer_oid(text: str) -> str | None:
32
+ for line in text.splitlines():
33
+ if line.startswith("oid sha256:"):
34
+ return line.split(":", 1)[1].strip()
35
+ return None
36
+
37
+
38
+ def shard_names() -> list[str]:
39
+ names = sorted(p.name for p in ROOT.glob("model-*-of-*.safetensors"))
40
+ if len(names) != 66:
41
+ raise SystemExit(f"expected 66 shards, found {len(names)}")
42
+ return names
43
+
44
+
45
+ def from_worktree() -> dict:
46
+ out = {}
47
+ for name in shard_names():
48
+ data = (ROOT / name).read_bytes()
49
+ out[name] = {
50
+ "pointer_sha256": sha256_bytes(data),
51
+ "lfs_oid": pointer_oid(data.decode("utf-8", "replace")),
52
+ "size": len(data),
53
+ }
54
+ return out
55
+
56
+
57
+ def from_git(revision: str) -> dict:
58
+ out = {}
59
+ for name in shard_names():
60
+ blob = subprocess.run(
61
+ ["git", "show", f"{revision}:{name}"],
62
+ capture_output=True, check=True, cwd=ROOT,
63
+ ).stdout
64
+ out[name] = {
65
+ "pointer_sha256": sha256_bytes(blob),
66
+ "lfs_oid": pointer_oid(blob.decode("utf-8", "replace")),
67
+ "size": len(blob),
68
+ }
69
+ return out
70
+
71
+
72
+ def main() -> int:
73
+ ap = argparse.ArgumentParser(description=__doc__)
74
+ ap.add_argument("--from-git", metavar="REV")
75
+ ap.add_argument("--from-worktree", action="store_true")
76
+ ap.add_argument("--out", default=None)
77
+ ap.add_argument("--compare", nargs=2, metavar=("A", "B"))
78
+ args = ap.parse_args()
79
+
80
+ if args.compare:
81
+ a = json.loads(pathlib.Path(args.compare[0]).read_text())
82
+ b = json.loads(pathlib.Path(args.compare[1]).read_text())
83
+ if a == b:
84
+ print(f"IDENTICAL: {len(a)} shards match between {args.compare[0]} and {args.compare[1]}")
85
+ return 0
86
+ keys = sorted(set(a) | set(b))
87
+ diffs = [k for k in keys if a.get(k) != b.get(k)]
88
+ print(f"MISMATCH on {len(diffs)} shard(s): {diffs[:10]}")
89
+ return 1
90
+
91
+ manifest = from_git(args.from_git) if args.from_git else from_worktree()
92
+ oids = {v["lfs_oid"] for v in manifest.values()}
93
+ if args.out:
94
+ pathlib.Path(args.out).write_text(json.dumps(manifest, indent=1, sort_keys=True))
95
+ print(f"wrote {args.out}: {len(manifest)} shards, {len(oids)} distinct LFS oids")
96
+ else:
97
+ print(f"{len(manifest)} shards, {len(oids)} distinct LFS oids")
98
+ return 0
99
+
100
+
101
+ if __name__ == "__main__":
102
+ sys.exit(main())