Skip to content

feat(vlm): add the Cohere Compass (North-Micro-Vision) VLM - #1735

Merged
inureyes merged 4 commits into
mainfrom
feature/issue-1354-cohere-compass-vlm
Sep 9, 2026
Merged

inureyes merged 4 commits into
mainfrom
feature/issue-1354-cohere-compass-vlm

Conversation

@inureyes

@inureyes inureyes commented Sep 9, 2026 •

Copy link
Copy Markdown
Member

Summary

Adds model_type: "cohere_compass" (CohereLabs North-Micro-Vision-Instruct, 2.5B): a Qwen3-VL deepstack vision tower in front of a Command-style parallel text decoder. The vision tower, image processor, <|IMAGE_PAD|> expansion, MRoPE position-id rule and weight-prefix remap are the existing Qwen3-VL ones reused; the decoder and its rotation are the new code.

The rotation is not Qwen3-VL's interleaved MRoPE

The issue specified InterleavedMRoPE on the sliding layers. That is wrong, and it is the one thing worth reading the diff for. The checkpoint carries mrope_interleaved: true, but upstream lists that key under ignore_keys_at_rope_validation and never reads it. CohereCompassRotaryEmbedding pre-permutes inv_freq over the section[0] + section[1] head block into evens-then-odds, then assigns axes in contiguous [H, W, T] sections. Over the published [24, 20, 20] that gives H 24 channels; Qwen3-VL's step-3 interleave over the same list gives T 24 channels, over unpermuted frequencies. Measured against the transformers class itself at (t, h, w) = (7, 11, 13): this implementation is within 2.2e-7 in cos, the specified interleave is off by 1.96. For the same reason there is no fast_rope text-only shortcut here, since a permuted inv_freq does not collapse to natural-order 1-D RoPE when the three axes agree.

What changed

  • src/models/cohere_compass{,_config,_layers,_rope}.rs: the text decoder. Parallel block (one input LayerNorm feeds both attention and the SwiGLU MLP, both land on the residual, no post_attention_layernorm), tied head times logit_scale 0.25, per-layer-type RoPE where a JSON null entry means no positional encoding at all (the seven full-attention layers are NoPE), and the sliding/full split enforced by two prefill masks plus the SDPA window over a dense KVCache, the same contract cohere2.rs and olmo3.rs use.
  • src/vision/cohere_compass.rs: the VLM wrapper over Qwen3VLVisionEncoder, DeepStack state, visual position mask and MRoPE state, plus the QwenVlRuntime impl so CLI and server image paths route through the shared Qwen-VL machinery.
  • src/loading/vlm_cohere_compass.rs: loader. Both published weight layouts land on one key space through the existing remap_qwen3_vl_weights. Resize bounds come from the nested image_processor block of processor_config.json, which is what HF's AutoProcessor reads; the standalone preprocessor_config.json on this checkpoint disagrees and is only the fallback.
  • Detection, ModelType, LoadedModel, registry, metadata and docs rows, plus the bilingual technical report.

Test plan

Oracle is transformers 5.18.0.dev0 reading the same weights: the MLX 4-bit checkpoint dequantized offline into one float32 file in the CohereLabs key layout, so both sides load byte-identical weights and any divergence is the forward pass. Every run was done on both the 4-bit and the float32 checkpoint, which covers both published weight-prefix layouts.

  • cargo test --profile test-fast --features cuda --lib cohere_compass on GB10: test result: ok. 23 passed; 0 failed.
  • Rotary table against CohereCompassRotaryEmbedding: max|cos diff| 2.23e-07, max|sin diff| 3.80e-07; the interleave the issue specified is off by 1.96.
  • MRoPE position ids bit-identical to get_rope_index, one image (rope_delta = -182) and two images (rope_delta = -224).
  • Text-only token-exact: server /v1/chat/completions with logprobs: true gives 32/32 identical tokens against the oracle on both checkpoints; the CLI produces the identical string on three prompts, one of them a 79-token prompt.
  • Server image path: prompt_tokens 210 for one image and 245 image tokens for two, both exactly the oracle's counts, completion_tokens non-zero, EOS reached. The server text equals the CLI text on every prompt. This is the family's known zero-decoder-layer trap, checked rather than assumed.
  • mlxcel arch lists the family, mlxcel list shows the checkpoint, mlxcel inspect reports sliding-window: 21 layer(s) capped at 4096 tokens, 7 global.
  • cargo clippy --workspace --all-targets -- -D warnings and cargo fmt --all -- --check: green in CI.
  • Image greedy ids do not match the oracle. Everything up to the decoder input matches, and mlxcel picks the oracle's rank-2 token, but the flip is real: single image diverges at step 2 (' displays' -1.5065 against ' features' -1.7449, 0.238 nats), two images at step 0 ('3' -0.511 against "Let's" -1.3771, 0.866 nats). Ruled out: TF32 (MLX_ENABLE_TF32=0 changes nothing), both vision activations, DeepStack order and depth, quantization (4-bit and float32 outputs are byte-identical). What remains is the shared Qwen3-VL tower, which this PR does not touch. Filed as Qwen3-VL vision tower is not token-exact against transformers on the image path #1738 with the numbers.
  • cargo test --workspace --profile test-fast --features metal,accelerate was not run. It cannot run on this host, which is Linux with CUDA and has neither Metal nor Accelerate. The CUDA equivalent above was run instead.

Closes #1354

Adds `model_type: "cohere_compass"` (CohereLabs North-Micro-Vision-Instruct): a Qwen3-VL deepstack vision tower in front of a Command-style parallel text decoder. The vision half, the image processor, the `<|IMAGE_PAD|>` expansion, the MRoPE position-id rule and the weight-prefix remap are the existing Qwen3-VL ones reused; only the decoder and its rotation are new.

The rotation is the one thing the issue body got wrong, and it is worth naming. Despite `mrope_interleaved: true` in `config.json`, upstream lists that key under `ignore_keys_at_rope_validation` and never reads it: `CohereCompassRotaryEmbedding` pre-permutes `inv_freq` over the `section[0] + section[1]` head block into evens-then-odds, then assigns axes in contiguous `[H, W, T]` sections. Qwen3-VL's step-3 interleave over the same `[24, 20, 20]` list gives T 24 channels against Compass's 24 to H, over unpermuted frequencies, so substituting one for the other rotates every sliding layer against the wrong table. `cohere_compass_rope.rs` implements the Compass table and `cohere_compass_rope_tests.rs` gates it against a re-derivation of upstream's definition.

For the same reason there is no `fast_rope` text-only shortcut on this family: with a permuted `inv_freq`, an all-axes-equal position does not collapse to natural-order 1-D RoPE.

Per-layer-type RoPE reads a JSON `null` entry as "no positional encoding at all" rather than "fall back to the default", which is what makes the seven full-attention layers NoPE. The sliding/full split is enforced by two prefill masks plus the SDPA window over a dense `KVCache`, the same contract `cohere2.rs` and `olmo3.rs` use. `preprocessor_config.json`'s resize bounds are read explicitly (65536 / 16777216) because they are not the Qwen family defaults and they decide the image grid.

Refs #1354
@inureyes inureyes added type:enhancement New features, capabilities, or significant additions priority:medium Medium priority area:models Model architectures, weights, loading, metadata area:docs User and developer documentation area:inference Generation, sampling, decoding (incl. speculative, DRY) modelsize:small 4-bit checkpoint <= 10GB; fast iteration, safe for smoke tests modeltype:vlm Vision-language model arch:dense Dense transformer decoder status:review Under review labels Sep 9, 2026
The three failures were all in the fixtures, not the model, and the DeepStack one is worth keeping the reason for.

`deepstack_injects_first_three_layers_only` injected a tensor that was constant across channels. This family normalizes with a mean-subtracting LayerNorm and carries a parallel-block residual, so a uniform per-position offset is invisible end to end: each block's input norm removes it before attention and the MLP see it, and the final norm removes it again. Measured, the "injected" logits differed from the plain run by 1.1e-8, pure float noise, so the assertion would have passed or failed on nothing. The branches are now non-uniform rows from the deterministic Rng.

`frequencies_are_pre_permuted_over_the_hw_block` compared `x.powf(-a)` against `1.0 / x.powf(a)` at 1e-9. They are the same value with different f32 rounding, measured 6e-8 apart; the bound is now 1e-6, still three orders below what a wrong permutation moves.

`image_pad_expands_to_merged_grid` read `<|VISION_END|>` one index early: 196 pads starting at index 2 end at 197, not 196.

Refs #1354
The published checkpoint ships two sidecars that disagree on the image resize bounds, and this was reading the wrong one. `preprocessor_config.json` says `size.shortest_edge` 65536 / `size.longest_edge` 16777216, while the nested `image_processor` block of `processor_config.json` says `min_pixels` 16384 / `max_pixels` 3868706. HF's `AutoProcessor` builds the image processor from the nested block, so 16384 is what the reference actually resizes against.

Measured against transformers on `tests/fixtures/test_image.png` (224x224): the reference keeps a 14x14 grid, 49 image tokens, while the 65536 bound upscales to 16x16 and 64 tokens. A two-image prompt then carried 260 image tokens against the oracle's 245, so the whole prompt was a different length and the run had nothing to compare. Nothing failed; the counts just silently disagreed.

Precedence is now the nested block, then `preprocessor_config.json`, then the Qwen family defaults, with explicit `min_pixels` / `max_pixels` beating a `size` block within each file, which is the order the HF processor reads them in. Also adds the sliding-window receptive-field gate that was missing: two sliding layers at window 3 must leave position 11 unable to see token 0, and a window wider than the prefix must make the same edit reach it.

Refs #1354
@inureyes inureyes added status:done Completed and removed status:review Under review labels Sep 9, 2026
@inureyes
inureyes merged commit b5891fb into main Sep 9, 2026
13 checks passed
@inureyes
inureyes deleted the feature/issue-1354-cohere-compass-vlm branch September 14, 2026 11:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

arch:dense Dense transformer decoder area:docs User and developer documentation area:inference Generation, sampling, decoding (incl. speculative, DRY) area:models Model architectures, weights, loading, metadata modelsize:small 4-bit checkpoint <= 10GB; fast iteration, safe for smoke tests modeltype:vlm Vision-language model priority:medium Medium priority status:done Completed type:enhancement New features, capabilities, or significant additions

Projects

None yet

1 participant