Sitelet https://github.com/mlx-node/mlx-node/pull/74
Skip to content

feat(gemma4): Gemma 4 12B unified (gemma4_unified) — encoder-free text + vision + audio - #74

Merged
Brooooooklyn merged 41 commits into
mainfrom
feat/gemma4-unified-12b
Jun 24, 2026
Merged

Brooooooklyn merged 41 commits into
mainfrom
feat/gemma4-unified-12b

Conversation

@Brooooooklyn

@Brooooooklyn Brooooooklyn commented Jun 23, 2026 •

Copy link
Copy Markdown
Contributor

What

Adds support for gemma4_unified — Google's Gemma 4 12B encoder-free multimodal model (Gemma4UnifiedForConditionalGeneration). Previously ./.cache/models/gemma-4-12b-it failed to load: detectModelType only knew gemma4 / gemma4_text, so gemma4_unified threw Unsupported model_type.

Delivered in 3 independently-shippable phases, extending the existing gemma4 Rust module (shared text decoder + projection) behind an is_unified flag — no new model family.

                 current gemma4 (e2b/26b/31b)     gemma4_unified (12B) adds
text decoder     shared (k_eq_v, global_head)     reuse as-is
vision           SigLIP vision_tower              encoder-free VisionEmbedder + bidir overlay
audio            weights dropped                  embed_audio + raw-window PCM pipeline

How it works

Vision (encoder-free): VisionEmbedder = patch_ln1 → patch_dense(6912→3840) → patch_ln2 → +2D pos_embedding → pos_norm (one matmul, no SigLIP). Image preproc resizes to 48-multiples, reshapes into [n_patches, 6912] patches with a meshgrid position_ids, rescales ÷255 (no normalize). Merge scatters image features at token 258880. A blockwise bidirectional attention overlay lets image tokens attend each other on both global+sliding prefill masks (gemma4 masks are boolean keep-masks → overlay = base | same_block), single-chunk prefill, guarded against KV-shared layers.

Audio (encoder-free, raw-window — NOT mel): raw 16 kHz mono PCM → pad to ×640 → embed_audio = RMSNormNoScale(640) + Linear(640→3840) (1 token = 640 samples = 40 ms). WAV bytes decoded by a dependency-free hand-rolled RIFF/WAVE parser (PCM16 + IEEE float32, downmix, 16 kHz required). Tokens expand to BOA(256000) + audio(258881)×n + EOA(258883); features scatter at 258881 unscaled (text is ×√3840). Audio is causal and its presence disables the vision overlay. Public surface mirrors images field-for-field (ChatMessage.audio → NAPI → ChatSession.send(text, { audio })).

Test plan

Gate Result
gemma4 lib tests 139 passed / 0 failed
qianfan lib tests (touched family) 127 passed / 0 failed
audio start/continue reject guards 4 passed
TS typecheck / chat-session unit / lint 0 / 86 passed / 0 errors
cargo clippy -D warnings / fmt clean
real-checkpoint e2e (24 GB 12B) text "Paris" · vision reads ocr.png ("BANK RECONCILIATION…") · audio coherent · mixed image+audio causal

Non-unified gemma4 (e2b/26b/31b) + all other families (qwen3/qwen3.5/lfm2/qianfan) load + chat byte-identically — audio is purely additive (active only for unified + has_audio).

Review

Per-phase /codex:adversarial-review (each finding verified by control-flow subagent) + a final whole-branch pass:

  • 5-dim adversarial Workflow → clean.
  • codex final → 2 findings, both verified independently: [high] audio-follow-up rejection = not a defect (faithfully mirrors the pre-existing single-shot image contract; identical restart formula; covered by a Rust guard test); [med] Qianfan start path silently dropped first-turn audio → fixed (added the same reject guard the continue path uses).

Deferred (intentional, noted)

Audio resampling (16 kHz-only enforced) · exact mlx-vlm token-parity (semantic parity is the bar; CatmullRom vs PIL-bicubic resize + bf16) · quantized mlx convert for gemma4_unified · ChatSession catch-restart-replay UX (pre-existing for images, would change that contract).

🤖 Generated with Claude Code


Note

High Risk
Touches chat session routing, NAPI ABI for all families, and core Gemma 4 attention/KV paths for multimodal prefill; regressions could mis-route config, drop history on restarts, or break non-unified models if guards are wrong.

Overview
Adds gemma4_unified (Gemma 4 12B encoder-free multimodal) on the existing gemma4 Rust path via is_unified / has_audio, routing gemma4_unified / gemma4_text checkpoints through one registry key and loading unified vision/audio embedder weights instead of dropping them.

Multimodal inference: encoder-free vision (patch embedder, scatter at image tokens, optional bidirectional prefill mask on global paged layers) and audio (16 kHz mono WAV decode, 640-sample frames, embed_audio, BOA/audio/EOA token expansion, causal merge; audio disables the vision overlay). Fresh turns with images/audio go through vision_turn; continue/delta stays text-only and rejects non-empty image/audio with the typed IMAGE_CHANGE_REQUIRES_SESSION_RESTART: prefix.

Public API: ChatMessage.audio, SendOptions.audio, and a new audio positional argument on chatSessionContinue / streaming continue (between images and config) across NAPI typings and families (including Qianfan ABI alignment). ChatSession updates cover media-key restarts, gemma4 streaming history when the final chunk is empty, and transparent cold replay when native code rejects a text-only delta while media KV is held (sync, stream, tool paths).

Stability: removes the NAPI module-load hook that eagerly created a GPU stream (avoids Metal init abort under concurrent test workers).

Tests: large chat-session regression suite, Gemma 4 unified detection/load/e2e (text, vision, audio, media→text continuation), and stub signature fixes; .gitignore adds /.superpowers/.

Reviewed by Cursor Bugbot for commit ea9c562. Bugbot is set up for automated code reviews on this repo. Configure here.

Brooooooklyn and others added 19 commits June 23, 2026 17:03
…ghts

Add `is_unified` and `use_bidirectional_attention` to Gemma4Config, parsed
in parse_config from the unified checkpoint's top-level model_type /
architectures and text_config. The unified 12B checkpoint shares the dense
gemma4 text decoder, so detection only gates load-time behavior:

- Leave vision_config None for unified checkpoints so the SigLIP
  Gemma4VisionConfig parser does not mis-read the unified vision_config and
  wrongly enable a vision tower (text-only load).
- Add vision_embedder. to the vision-skip prefix set so the unified
  checkpoint's image embedder weights drop cleanly instead of erroring on an
  unexpected weight. Other families are byte-identical (prefix-only addition).

tie_word_embeddings=true checkpoints (no lm_head.weight) already derive a
tied head from embed_tokens.

Both .d.cts artifacts hand-synced. Rust tests cover unified detection via
model_type and via architecture, and that plain gemma4 stays is_unified=false.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Normalize gemma4_unified model_type onto the shared gemma4 registry key
(alongside the existing gemma4_text normalization) so loadSession on the
unified 12B checkpoint drives the gemma4 decoder. No new registry row.

Tests:
- detectModelType resolves gemma4_unified -> gemma4 (and keeps gemma4_text /
  plain gemma4 working).
- Presence-gated e2e: loadSession the 12B checkpoint and assert greedy decode
  is coherent ("Paris"; ocean sentence) with a degenerate-repetition guard.
- Add isUnified to the existing Gemma4Config TS stub (now a required field).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ction

detectModelType only normalized on config.model_type, so a unified
checkpoint that carries architectures:
["Gemma4UnifiedForConditionalGeneration"] but no top-level model_type
defaulted to qwen3 and misrouted to Qwen3Model.load(). The native loader
(gemma4/persistence.rs parse_config) already flags is_unified on EITHER
model_type == "gemma4_unified" OR that architecture; mirror the
architecture check in TS so arch-only unified checkpoints resolve to the
shared gemma4 loader. Add a regression test for the model_type-absent case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add the building blocks for the gemma4_unified (12B) encoder-free vision
path:
- UnifiedVisionConfig parsed from the unified vision_config sub-dict
  (model_patch_size 48, mm_embed_dim/output_proj_dims 3840, mm_posemb_size
  1120, num_soft_tokens 280), plumbed onto Gemma4Config as
  unified_vision_config (disjoint from the SigLIP vision_config).
- Gemma4UnifiedVisionEmbedder: patch_ln1 -> patch_dense -> patch_ln2 ->
  add 2D positional embedding -> pos_norm, mirroring mlx-vlm's VisionEmbedder.
- Gemma4ImageProcessor unified path: patchify at model_patch_size=48 into
  [n, 6912] flattened patches + [n, 2] xy position ids (num_soft_tokens=n,
  no pooling division); ProcessedGemma4Image carries Option<position_ids>.

Both .d.cts artifacts hand-synced. Unit tests cover embedder forward shape +
position-mask wiring and the config-parse field population.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Activate the encoder-free vision path for gemma4_unified end-to-end:
- parse_config populates unified_vision_config when is_unified.
- Gemma4Inner builds the unified vision_embedder + embed_vision + unified
  image processor (vision_tower stays None); has_vision now true for unified.
- sanitize_weights keeps vision_embedder./embed_vision. when either vision
  path is active (drops them only when both configs are absent).
- persistence loads vision_embedder.* (patch LayerNorms + dense + pos table +
  pos_norm) and the shared embed_vision.embedding_projection (factored into a
  helper reused by both the SigLIP and unified paths).
- build_gemma4_vision_embeds branches on the unified embedder: forward patches
  + position ids, add the batch dim, then the existing scaled-text masked
  scatter with the mask_count==feature_count assertion.

Presence-gated vision e2e (model + ocr.png) asserts a coherent, non-degenerate
image description. Validated on the real 24GB checkpoint: the model reads the
document image ('financial document ... bank statement', 'BAN REC').

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The unified encoder-free vision loader installed each tensor only when
present in the checkpoint, so a missing key silently kept the constructor
default (pos_embedding mx.zeros, LayerNorms/Linear constructor-init). A
truncated shard or bad conversion could load "successfully" with random or
zero vision weights, producing coherent-looking but image-wrong output that
the smoke test would not catch.

validate_required_weights now rejects a load that declares a unified vision
config but is missing any required vision_embedder weight/bias, the
pos_embedding tensor, or the embed_vision.embedding_projection weight,
erroring with the missing key name. This runs on the same load path that
already validates the text weights, before any weight application, matching
the text loader's fail-closed behavior. The SigLIP vision_tower path and
non-unified loads are unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Port mlx-vlm gemma4/language.py _block_sequence_ids_for_mask +
_apply_blockwise_bidirectional_overlay as boolean keep-masks (true=keep),
matching create_sliding_mask / create_causal_mask BF16-safe semantics.
Each contiguous image run attends to itself bidirectionally; text stays
causal. Unit-tested: group numbering, single/two-block bidirectionality,
no cross-block attend, length-mismatch no-op, type-id builder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…fill

Wire the blockwise bidirectional attention overlay into the unified-vision
paged prefill (run_paged_vlm_prefill -> run_paged_vlm_prefill_layer_loop).
The gate (is_unified + use_bidirectional_attention=="vision" + image tokens
present + seq_len>1; audio is Phase 3 and never enters the expanded stream)
materializes explicit boolean keep-masks for BOTH layer types only when
active, leaving the None/causal fast path byte-identical otherwise:
  - global/full: explicit causal mask threaded through Gemma4Attention
    forward_paged's fresh-prefill branch (new explicit_prefill_mask arg).
  - sliding: causal+window mask, overlaid.
Force a single pass-1 chunk when the overlay is active so the image block is
never split across chunks (default chunk size is already single-shot; this
guards MLX_PAGED_PREFILL_CHUNK_SIZE). Pass-2 (final token, seq_len==1) and
decode never apply the overlay.

ocr.png greedy T=0 now reads 'Trunch Parish Council' / 'BANK RECONCILIATION
AS AT 31' (vs Phase 2a causal 'BAN REC').

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The bidirectional vision overlay mask only reaches GlobalPaged/Sliding
layers. KV-shared layers (SharedOnGlobal/SharedOnSliding) run
forward_paged_shared, which takes no mask and would silently stay causal
— a half-applied overlay across the stack. The 12B unified checkpoint
has num_kv_shared_layers==0 so this never fires today; convert the latent
silent-corruption into a loud error if a shared unified checkpoint is
ever loaded. Also drop a duplicate #[allow(clippy::too_many_arguments)]
on forward_paged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ghts

Parse the unified checkpoint's audio ids and frame size (audio_token_id,
boa_token_id, eoa_token_id from eoa_token_index, audio_samples_per_token) and
a has_audio flag keyed on the audio_config sub-dict. Non-unified gemma4 leaves
all of these inert.

sanitize_weights now keeps model.embed_audio.* for a checkpoint that declares
an audio_config (splitting the old unconditional embed_audio drop from the
always-dropped audio encoder prefixes); non-unified loads stay byte-identical.
apply_audio_weights loads embed_audio.embedding_projection (dense bf16, affine
fallback), mirroring the vision projection, and validate_required_weights fails
closed on a missing audio projection weight when has_audio.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… overlay gate

Build embed_audio in Gemma4Inner::new (RMSNormNoScale + Linear, 640->hidden,
reusing Gemma4MultimodalEmbedder) when the checkpoint declares an audio_config.

Add the encoder-free audio_processor: frames_from_pcm pads a mono f32 PCM clip
to a multiple of 640 and reshapes to [n_frames, 640] with no scaling (one frame
= one 40 ms audio token), and expand_audio_tokens turns each audio placeholder
into boa + audio_token x n_frames + eoa.

build_gemma4_audio_embeds projects the raw windows through embed_audio and
masked_scatters them into the (sqrt(hidden)-scaled) text stream at every audio
token — causal, no bidirectional overlay, audio features unscaled. Mirrors
build_gemma4_vision_embeds; vision stays byte-identical.

Extend the unified-vision overlay gate via a pure vision_overlay_active helper:
the bidirectional overlay is disabled whenever audio tokens are present in the
expanded stream (mixed image+audio is causal, audio wins). Image-only behavior
is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… audio

Two correctness fixes for the Phase 3a audio pipeline:

1. has_audio was keyed on `audio_config` key PRESENCE, which serde reports as
   true even for `audio_config: null` and for E2B's legacy mel `audio_config`
   dict. That regressed every non-unified gemma4 load: 26B/31B (audio_config:
   null) failed the embed_audio validator, and E2B tried to keep its [1536,1536]
   mel projection under the raw-window 640->hidden embedder. Gate has_audio on
   `is_unified && audio_config non-null`; gate the audio ids on has_audio too.
   Regression test covers the E2B (legacy dict) and 26B/31B (null) shapes.

2. build_gemma4_audio_embeds passed zero-frame audio (mask_count==feature_count
   ==0) into masked_scatter, which divides by an empty source
   (indices.remainder(0)). Short-circuit feature_count==0 to return the scaled
   text embeds unchanged. Added a zero-frame expansion test.

Both found by adversarial review; verified against the real e2b/26b/31b configs
and the masked_scatter source-size handling.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Anti-aliased polyphase resample (24kHz→16kHz) of mlx-vlm's ask_voice.wav.
The unified audio feature extractor requires mono float32 16kHz; this is the
presence-gated fixture the Phase 3b audio e2e loads (mirrors examples/ocr.png).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Add decode_wav_to_pcm: a dependency-free RIFF/WAVE parser supporting
PCM16 and IEEE float32, multi-channel→mono downmix, with a 16 kHz-only
guard (resampling deferred). Mirrors the encoded-bytes-in image path so
the public audio turn can decode an audio clip Rust-side. 6 unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…odal turn

Public audio surface mirroring images: ChatMessage.audio, audio params on
chat_session_continue / chat_stream_session_continue (guard-only, reject with
the typed restart prefix on text-only continue), and the gemma4 image-guard
now also gates audio on a has_audio load flag.

Engine: extract_audio_from_messages + WholeTurnArgs.audio; the multimodal
dispatch fires on images OR audio; ChatBackend::supports_audio() (default
false; gemma4 = embed_audio.is_some()) rejects audio on non-audio families
pre-render.

Gemma4 turn: generalize the vision paged cores into a multimodal turn —
prepare_multimodal_tokens expands <|image|> then <|audio|> placeholders and
decodes+frames the audio; build_gemma4_multimodal_embeds chains the image
(@258880) and audio (@258881) masked_scatters into the same scaled text
stream (image-only stays byte-identical; audio-only skips the image scatter).
cached_audio_key parallels cached_image_key (audio-held sessions reject text
deltas; audio change cold-restarts). serialize_message_for_jinja emits a
{type:"audio"} content part (image-first, then audio) → the template's
<|audio|> placeholder. Deleted the now-subsumed build_gemma4_vision_embeds /
build_gemma4_audio_embeds.

Tests: 6 WAV-decode units already landed; +3 multimodal serializer units
(audio-only, mixed image+audio ordering). Both index.d.cts synced.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
stream.ts forwards the new `audio` positional into chatStreamSessionContinue.
ChatSession gains SendOptions.audio, a lastAudioKey + computeAudioKey
(shared FNV-1a framing with images), audioChanged folded into the
cold-restart decision in send()/sendStream(), audio threaded through
runStartPath/runStartStreamPath + buildUserMessage, computeTrailingAudioKey
hydration on cold replay, and lastAudioKey reset on reset()/rollback. Delta
continues pass null audio. SessionCapableModel continue signatures carry the
audio arg. Test mocks + arg-index assertions updated for the new arity.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Presence-gated e2e mirroring the vision e2e: drives ChatSession.sendStream
with { audio } against the real 24GB unified checkpoint and asserts coherent,
non-degenerate output (no exact-string match). A second case drives a combined
image+audio turn to prove both scatters build, the prompt carries both token
runs, and the turn runs causally (overlay off when audio present).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Phase 3b added an `audio` positional argument between `images` and
`config` on the shared chat surface — the `chat_napi_surface!` macro
families, the `SessionCapableModel` TS interface, `makeStreamingModel`,
and `ChatSession.send`'s delta path all expect it. QianfanOCR uses
HANDWRITTEN NAPI continue methods (not the macro), which still had the
pre-audio signature, so driving Qianfan through `ChatSession` /
`makeStreamingModel` shifted its positional args: the caller's `config`
landed in the audio slot and was silently dropped (or the NAPI dispatch
errored).

Add an `audio` parameter (between `images` and `config`) to Qianfan's
`chat_session_continue` and `chat_stream_session_continue`, matching the
macro families' exact `ts_args_type` wording. Qianfan has no audio
support, so a non-empty `audio` is rejected at the boundary with the
shared no-audio message (IMAGE_CHANGE_RESTART_PREFIX-prefixed, mirroring
`engine::session`); `None` / empty is a complete no-op and audio is never
threaded into Qianfan's command processing — existing text + image
behaviour stays byte-identical.

QianfanOCR is the only handwritten (non-macro) chat family driven through
`makeStreamingModel`; Harrier is embeddings-only with no chat surface.

Sync both `.d.cts` artifacts (build-regenerated `packages/core` and
hand-maintained `crates/mlx-core`). Add Rust unit tests for the boundary
guard, a TS regression test asserting the Qianfan-shaped delta path
delivers `(userMessage, images, audio, config)` without dropping config,
and a gemma4 contract test locking that a text delta after an audio turn
is rejected exactly like one after an image turn.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… guard)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 23, 2026 •

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: c376aee3-272f-4618-b327-bfc4a8234ce2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/gemma4-unified-12b

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 0b4c51c90c

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread packages/lm/src/chat-session.ts Outdated
Brooooooklyn and others added 9 commits June 24, 2026 00:39
After an image/audio turn, gemma4's native session refuses a text-only
delta and rejects with a message starting
`IMAGE_CHANGE_REQUIRES_SESSION_RESTART:`. Previously that raw error
propagated to the caller, making any media turn effectively single-shot.

Both `send()` and `sendStream()` now catch that exact prefix on the delta
path and transparently replay the full conversation (including the earlier
media turn already stored in history) through the existing cold-start path
(`runStartPath` / `runStartStreamPath`), passing the trailing media keys so
`lastImagesKey`/`lastAudioKey` stay consistent across the replay.

The streaming rejection surfaces as a THROW on the generator's first
iteration: the native worker-thread guard fires before any prefill via
`sink.send(Err(e))`, and the stream.ts bridge re-throws it before any chunk
is yielded. The delta-continue handler wraps the for-await in a nested
try/catch, delegates to the replay stream only when nothing was emitted yet
(`!sawFinal && accumulated === ''`), and guards the commit `finally` with a
`delegated` flag so the replay path owns the single history commit.

This is the TS-only correctness floor; warm KV reuse is a later phase.
Family-agnostic and additive: the catch fires only on the typed prefix
(today only gemma4 raises it); text-only sessions, qwen3.5/lfm2/qianfan, the
happy delta path, and media-change restarts are unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…age (Phase 2)

A text follow-up after a pure-causal media turn (AUDIO, or a NON-UNIFIED
gemma4 image) now warm-continues on the live media KV instead of
cold-restarting. The vision-core finalize keeps the global paged KV
registered for content-addressed reuse and arms a new internal marker
`media_session_continuable`, so the native chatSessionContinue succeeds;
the delta restores the prefix via replay over the live global KV — the
same mechanism a text warm-continue uses. UNIFIED bidirectional-vision
image turns stay single-shot (Phase 3).

Key reconciliation (R1): the sliding-history checkpoint is structurally a
no-op on KV-shared checkpoints (SharedOnSliding layers store no flat K/V),
exactly as for a text warm-continue — so continuation does NOT depend on
it; it rides the replay path. On a "length" finish the final token is
forwarded once (mirroring the text path's materialize_final) so the kept-
live global KV content-addresses against the saved history.

Proven byte-exact by a new warm==cold golden e2e on a non-unified image
turn (identical rawText + numTokens). Audio shares the identical finalize
code; its byte-exact golden is ill-posed via the public API (the chat
template strips prior-turn reasoning and there is no raw-token-prefill
entrypoint), so the audio test asserts warm-continuation + final-answer
parity. The text_delta_image_guard pin is flipped to a contract: media
held + continuable → ALLOW; not continuable → still REJECT.

Gates: clippy --all-targets -D warnings clean (pre-existing block v0.1.6
note only); cargo fmt --check clean; cargo test -p mlx-core --lib gemma4
141 passed; yarn typecheck clean; vp lint 0 errors; e2e golden 3 passed;
existing gemma4 + pure-text regressions green. No NAPI surface change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…estore + hoist marker reset

Two ship-blockers in the Phase-2 warm media->text continuation, found by codex
adversarial review and confirmed by control-flow tracing.

FIX 1 (correctness): finalize_vision_turn_media_state now arms
media_session_continuable ONLY when the sliding-history checkpoint actually
stored. On KV-shared checkpoints (e2b, num_kv_shared_layers=20) the
shared-on-sliding layers hold no flat K/V, so stored=false and a warm restore
would rebuild media-position sliding K/V from raw <|image|>/<|audio|>
token embeddings instead of the scattered SigLIP/audio features -- numerically
unfaithful. When not stored, release the kept-live request and downgrade to a
clean non-continuable state so the follow-up delta cold-restarts. Non-KV-shared
checkpoints (12B audio, num_kv_shared_layers=0) store real feature K/V -> stay
warm + faithful (state="live").

FIX 2 (robustness): hoist the media_session_continuable=false reset to
immediately before the side-effecting prepare_turn_with_max_cache_hit_tokens
call in both vision cores (sync + stream). The prepare releases the prior
kept-live request and can fail via `?`; resetting first means any prepare
failure cold-restarts safely instead of leaving a stale `true`.

Test rework: the e2b non-unified image e2e block now asserts cold-restart
coherence (it is KV-shared -> no longer warm-continues); the 12B audio block
keeps its warm cachedTokens>0 + final-answer-parity assertions (canonical
faithful warm path). Doc-comments updated accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rt cold-restart in e2e

Hardens the warm media->text continuation gate against two issues found by an
adversarial review:

- A keep-live with zero full blocks (a media turn shorter than block_size)
  returns Ok without registering the request, so is_live_for_continue() is
  false while keep_live_ok is true. Arming the marker there would let the next
  delta fall to a fresh paged text prefill that re-embeds the media placeholder
  ids as text (unfaithful). Now arm only when stored AND is_live_for_continue().
  Unreachable on shipped configs (media turns far exceed the 16-token block),
  but cheap defense-in-depth.

- The e2b (KV-shared) and unified image e2e blocks only asserted coherence, so a
  regression that wrongly re-armed the marker could pass on a coherent-but-wrong
  warm answer. Assert cachedTokens===0 (the vision cold prefill primes
  max_cache_hit_tokens=0, so a correct cold-restart reports 0; a wrong warm
  continuation reports >0) to fail loud on accidental warm reuse.

12B audio stays warm+faithful (media history >> block_size -> full blocks ->
is_live_for_continue true); e2b/unified cold-restart with cachedTokens===0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…-chunk turns

The streaming history commit used `buildAssistantMessage(finalRaw ?? accumulated, ...)`.
gemma4 is the only family whose native streaming done-chunk emits `text: ""` (visible
text already streamed as per-token deltas), so `finalRaw === ""` and `"" ?? accumulated`
yields `""` — an empty assistant turn was stored, degrading any cold-restart that
resends `this.history` (e.g. the gemma4 media→text restart).

Add a non-reasoning-only `accumulatedVisible` accumulator at each of the 4 streaming
methods and change the commit to `finalRaw || accumulatedVisible`. `||` returns
`finalRaw` unchanged for the other 4 families (non-empty done text — byte-identical)
and falls through to the reconstructed marker-free visible text only for gemma4's
empty-`finalRaw` case, with reasoning-body deltas excluded so thinking models do not
leak prior reasoning into history. `accumulated` is kept for the sendStream catch
guard. Adds 3 mock-level tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Relax the gemma4 media-continuation eligibility gate so a unified
bidirectional-vision image turn (12B, num_kv_shared_layers=0) warm-continues a
follow-up text delta instead of cold-restarting.

The warm text delta routes through the generic paged causal text path
(run_paged_prefill_chunk → run_paged_prefill_layer_loop) with a plain offset
causal mask — it never runs the bidirectional vision overlay (the overlay only
makes the IMAGE span bidirectional during prefill, a no-op over text queries in
both the warm and cold paths). So it is numerically faithful; eligibility no
longer needs the !overlay_active / !is_unified exclusions.

Rename gemma4_no_overlay_continuable → gemma4_media_continuable (now just
has_audio || has_image) and its finalize parameter / call-site locals. The real
safety net is unchanged: the stored && live_for_continue gate in
finalize_vision_turn_media_state keeps the 12B (non-KV-shared) warm while a
KV-shared checkpoint (e2b) stores nothing and cleanly cold-restarts. The
KV-shared-unified overlay hard-error in run_paged_vlm_prefill is untouched.

e2e (12B + e2b checkpoints, ocr.png / audio fixtures): e2b non-unified image
still cold-restarts (cachedTokens===0, negative control); 12B audio warm stays
byte-exact ("English"=="English"); 12B unified image warm-continues
(cachedTokens>0). Strict warm==cold byte parity does NOT hold for the unified
image — a deterministic ~1-ULP BF16 cache-hit-kernel reduction-order drift (the
documented paged_decode_long_context_1ulp class) flips one early near-tie argmax
("...the main subject of the image" then " provided" vs ".") that cascades into
a different-but-coherent tail, both analyzing the same image ("Trunch Parish
Council"). The block falls back to cachedTokens>0 + coherence, documented in a
code comment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…h the stored-gate code

The function header carried superseded pre-fix prose that contradicted its own
code body: it claimed the next delta "REPLAYS (re-walks) the matched prefix
(state=\"replay\")", that "continuation does NOT depend on the checkpoint
storing", and called the stored==false downgrade an "earlier wrong draft". The
shipped code does the opposite — it arms the marker ONLY when
`stored && live_for_continue` (model.rs:2036), and the path that actually
warm-continues (12B, num_kv_shared_layers=0) resolves the sliding caches to
state="live" (run_sliding_only_prefill skipped, no media position re-embedded)
with the global prefix reused in place (cachedTokens>0, only the suffix
forwarded). Rewrite the header to describe that, and the R1 tail's "replay
restore" -> "live restore". Documentation only; no code/behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The subagent-driven-development scratch ledger leaked one file
(.superpowers/sdd/mc-phase2-report.md) into commit aaae2f9. Add /.superpowers/
to .gitignore and untrack it so the branch tree carries only source.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ache

gemma4 image/audio turns publish their K/V blocks into the model's shared
cross-session content-addressed prefix cache via finalize_turn_keep_live_per_block,
but registered them with an empty media-position slice. The per-block hash then
depended only on placeholder token ids, so two requests with the same surrounding
prompt and the same number of <|image|>/<|audio|> placeholders but different media
content hashed identically — a later content-address lookup from another session
could hit stale media-feature K/V.

Fold the per-image/per-audio content keys into the per-block extra_keys at the sole
publishing site. A new gemma4_media_token_positions helper builds the
(token_pos, content_key) pairs from the expanded prompt and the image/audio token
ids, scanning placeholder positions and pairing each with new_image_key/new_audio_key.
The two vision cold-prefill prepares (skip_lookup=true) and the consumer text-path
lookup keep an empty slice: the prefills do not register into the shared cache, and
the consumer lookup now simply misses the content-keyed media blocks, which is the
safe outcome.

Behavior-neutral: only the cache keys of registered media blocks change, never the
current turn's forward/decode (live block_table). The three media-continuation e2e
blocks stay byte-identical (warm continues read the live block_table; the e2b
cold-restart uses a skip_lookup prefill — neither does a content-hash read).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Brooooooklyn and others added 5 commits June 24, 2026 10:36
A gemma4 media (image/audio) turn arms `media_session_continuable` and keeps
its paged KV live so a following text delta can warm-continue. But on the
model's SHARED cross-session paged adapter another session can run
`reset_for_new_request` and release the live request after the marker is
armed. `text_delta_image_guard` previously ALLOWED the delta on the marker
ALONE; with no live request the text path then runs a content-address prefix
lookup (`find_cached_prefix`, skip_lookup=false) over `[media-prefix + delta]`,
which can hit stale media-feature K/V from another session, or unfaithfully
re-prefill the media placeholders.

Require BOTH the marker AND `paged_adapter.is_live_for_continue()` to allow the
warm continue. When the live request is gone, fall through to the existing
`IMAGE_CHANGE_RESTART_PREFIX` rejection so the TS floor cold-restarts (resend
full history → faithful vision/audio prefill, skip_lookup=true). The warm
continue itself reads the live `block_table` (no lookup), and a vision prefill
uses skip_lookup=true, so after this change no skip_lookup=false content lookup
ever walks media-placeholder blocks — closing the cross-session stale-KV
consumer at its only site and fixing the unfaithful-re-prefill edge.

This replaces the reverted content-keying attempt (ef379e1): media blocks are
also re-registered under token-only hashes by the generic text terminal
`finalize_paged_turn`, so keying only the vision finalize did not isolate them;
closing the consumer makes the registered media-block keys irrelevant.

Single-session sequential use keeps `is_live_for_continue()==true` at the
delta, so the guard still returns None and warm-continues exactly as before.
Updates the guard-matrix unit test: a continuable delta on a not-live (None)
adapter now correctly REJECTS (the cross-session-released hazard); the
marker-true AND live → warm-continue path is covered by the 12B e2e (a live
adapter needs real Metal allocation, not unit-constructible).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…lders

The shared paged adapter's continue-turn-failure fallback re-runs a
content-address prefix lookup using the caller's skip_lookup flag. The
gemma4 text path passed false, so an alloc-pressure failure inside
continue_turn could content-HIT media blocks registered token-only by
another ChatSession (per-block hashes cover token ids, not media feature
K/V) and reuse that session's stale media K/V.

Gate skip_lookup on whether the prompt tokens still carry an image or
audio placeholder id. Media-placeholder prompts then never trigger a
content-address lookup in any fallback branch, so the cross-session
stale-media-KV consumer is gone. The happy path reuses the live
block_table and never consults skip_lookup, so warm continuation stays
byte-identical; only the rare continue_turn-failure branch changes, now
re-prefilling the placeholders as text instead of doing the leaky lookup.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ry points

The Phase-1 cold-restart floor caught the typed media-held native
rejection (IMAGE_CHANGE_REQUIRES_SESSION_RESTART:) only on send() and
sendStream(), transparently replaying the full conversation through the
cold start path so a text follow-up after a gemma4 image/audio turn never
hard-errors. The tool-result twins sendToolResult()/sendToolResultStream()
hit the same native guard but had no catch, so a tool result rejected on
held media surfaced as a hard error (HTTP 500 / SSE error in the server).

Extract a shared restart core that takes a pre-built pending ChatMessage
(runStartPathWithMessage / runStartStreamPathWithMessage) and make
runStartPath / runStartStreamPath thin wrappers that build the user message
and delegate, so the send/sendStream call sites stay byte-identical. Add the
media-held catch to both tool-result methods: the restart pushes the pending
{ role: 'tool', content, toolCallId, isError } message and cold-replays the
full history (which holds the prior media turn) with mediaChanged=true /
isFirstTurn=false, preserving isError and the media bytes. Streaming mirrors
sendStream's delegated flag and gates the finally commit on
(sawFinal && !delegated) so the restart core is not double-committed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…e session

`sendToolResult`/`sendToolResultStream` always dispatched a delta continue
(`chatSessionContinueTool`/`chatStreamSessionContinueTool`) first, with no
`turnCount===0` start-path routing. After an interrupted media-held replay,
the start-path rollback wipes the native KV cache and resets `turnCount` to 0
while leaving the unresolved tool-call flag set. The caller's natural retry
then dispatched a delta against the wiped cache, which the native side
rejects with an un-prefixed "requires an initialized session" error that
does not match the media-held catch — a hard error on every retry.

Add a `turnCount===0` precheck to both tool-result entry points that routes
the pending tool message through the cold start path, mirroring how
`send`/`sendStream` self-heal via their `isFirstTurn` routing. The precheck
and the existing media-held catch now share a per-twin
`replayToolResultThroughStart(Stream)Path` helper. The repair is idempotent:
a re-interrupted cold replay rolls back to `turnCount=0` again, so the next
retry re-routes. A normal tool result always has `turnCount>=1`, so the
happy path is unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 79f13d7d57

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/mlx-core/src/models/gemma4/audio_processor.rs
Brooooooklyn and others added 2 commits June 24, 2026 15:58
…start-path restart

The start path always re-renders the full preserved history, so the
post-restart sticky media keys are the trailing media keys of that
history, not the single-turn literal args. A restart driven by a change
in only one modality (e.g. an audio-only turn after an earlier image
turn) passed a null images key for the untouched modality, nulling
lastImagesKey even though image A was still live in the native cache.
hasImages then lied, and a later send with that same image A was
mis-detected as a change, triggering a spurious extra restart that
duplicated image A in the prompt.

Both start-path success branches now set lastImagesKey/lastAudioKey from
computeTrailingImagesKey()/computeTrailingAudioKey(), matching the
existing startFromHistory*/tool-result-replay precedent. The now-unused
newImagesKey/newAudioKey params are dropped from the two *WithMessage
cores and their wrappers, with all call sites updated.

Adds a regression test asserting hasImages stays true across an
audio-change restart and that a subsequent same-image turn stays on the
delta path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The template-less manual prompt formatter emits no <|audio|> placeholder, so
a unified/audio checkpoint without a chat template failed every audio turn:
expand_audio_tokens treated zero placeholders with supplied clips as a hard
error, which prepare_audio_tokens propagated before prefill.

Mirror the image path (expand_image_tokens): when no placeholder is present but
clips are supplied, insert each clip's boa + audio_token x n_frames + eoa span
after BOS, in clip order. The Err is now reserved for a genuine placeholder/
clip-count mismatch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 53f21c3648

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/mlx-core/src/models/gemma4/model.rs
Comment thread crates/mlx-core/src/models/gemma4/model.rs
Brooooooklyn and others added 2 commits June 24, 2026 17:01
The macOS Build job produced a non-deterministically miscompiled native
addon under CARGO_INCREMENTAL=1 + the restored rust-cache: it aborted at
napi module registration (mlx_core::_napi_rs_internal_register_init) on
dlopen in every test worker, even though the generated register glue is
byte-identical to the passing base commit (0b4c51c) and a clean local
build of the same HEAD loads fine. Switch all jobs to CARGO_INCREMENTAL=0
(deterministic non-incremental release codegen) and bump the rust-cache
prefix-key v1-rust -> v2-rust to orphan the poisoned archives.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two defensive no-op-in-the-common-case hardening edits in gemma4 model.rs:

- PLE prefill mask: exclude both image AND audio token positions from the
  per-layer-embedding residual, not just image positions. Audio positions
  hold projected audio features (not learned-token embeddings), same as
  image positions. Gated on config.audio_token_id; inert today because every
  audio/unified checkpoint ships hidden_size_per_layer_input=0 so self.ple is
  None and the masking block is skipped.

- text_delta_image_guard: reject a continuable media session by the
  media_session_continuable marker when the paged request is no longer live,
  not solely by the cached media key. A warm text delta clears
  cached_image_key but leaves the marker armed, so the key-only fallback could
  silently warm-continue against a released request; gate the cold-restart on
  the true media-held signal.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 10d3fe04d7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/mlx-core/src/models/gemma4/model.rs
…t parity)

`save_paged_history` cleared `cached_image_key` but not `cached_audio_key`
on a fresh text-only paged save, while the flat `save_cache_state` clears
both. After an audio turn on a reused model, a text-only paged start (no
`reset()`) therefore left `cached_audio_key` stale, and the next text
delta's `text_delta_image_guard` wrongly forced an "audio state" media
restart on a text-only session.

Mirror the flat path: clear `cached_audio_key` alongside `cached_image_key`
in both branches of `save_paged_history`. On a warm reuse the key is already
`None` (the delta guard keeps it so), making the clear a no-op; on a fresh
start it drops the stale key. This drops no cold-restart signal: the
audio-change decision is computed in TS (`computeAudioKey(opts.audio) !==
this.lastAudioKey`), never from the native `cached_audio_key`, which is set
fresh each media turn and consumed only by the guard's reject messages.

Add a regression test driving a fresh text-only `save_paged_history` after a
simulated audio turn; it asserts the key clears and the guard no longer
forces an audio restart, and fails on the pre-fix code.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 40b5bf9. Configure here.

Comment thread crates/mlx-core/src/models/gemma4/model.rs Outdated
Comment thread crates/mlx-core/src/models/gemma4/model.rs
…prefix

`verify_cache_prefix` forced a flat prefix-reuse miss when a non-continuable
session still held a cached image key, but not when it held a cached audio key.
Extend the existing guard to OR in `cached_audio_key` so a non-continuable
session holding stale audio KV also misses (resetting the media KV instead of
reusing it as a token-id prefix hit), and update the comment to "image or
audio". Continuable (warm-continue) media sessions stay excluded by the
`&& !media_session_continuable` term, and clean text-only sessions (both keys
None) are unaffected.

This is symmetry hardening: the guard is currently unreachable for
media-capable gemma4 (a paged-adapter gemma4 returns early from `paged_turn`
before the flat path's `verify_cache_prefix`, and `cached_audio_key` is only
set on the paged finalize), but it matches the existing image guard and
future-proofs a refactor that routes a media-capable gemma4 through the flat
path. Adds a unit test that drives the audio guard directly over an
otherwise-hitting prefix; it fails on the pre-fix image-only guard.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: befca77360

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/mlx-core/src/models/gemma4/audio_processor.rs
…back

prepare_multimodal_tokens ran image expansion before audio expansion. On
the manual no-placeholder fallback (tokenizer without a chat template,
emitting neither <|image|> nor <|audio|>), each modality's span is
inserted right after BOS, so the expansion that runs LAST lands first.
Running image first then audio therefore produced BOS -> audio -> image
-> text, reversing the serializer's canonical image-then-audio order.

Run audio expansion FIRST and image expansion LAST so the image span
lands first after BOS, restoring BOS -> image -> audio -> text. Features
are unaffected (the merge scatters by token id); this only fixes the
modality sequence the model sees (attention/RoPE positions). The
chat-template path is order-independent: each expansion replaces only
its own placeholder id in place. Add a pure-Vec<u32> regression test
pinning image-runs-last => image-first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@Brooooooklyn Brooooooklyn added the model-e2e Run the heavy Model E2E workflow (per-family real-checkpoint tests) on this PR label Jun 24, 2026
The native addon aborted intermittently in CI's test job — every vitest
worker hit `panic in a function that cannot unwind` at
`mlx_core::_napi_rs_internal_register_init` on `dlopen`, failing the run.

Root cause (verified by loading the CI-built artifacts locally + lldb): the
addon binary and the `mlx.metallib` were BOTH valid — the cycle-4 metallib
is byte-identical to a local one that loads fine, and the CI log shows the
metallib downloaded successfully before the abort. The real cause is
`#[napi(module_exports)] init()`, which eagerly forced `GENERATION_STREAM`
(a `LazyLock<Stream>`) at module load — i.e. it ran `Stream::new(Gpu)`,
doing MLX/Metal GPU work during `dlopen`, inside a non-unwinding `extern
"C"` boundary. When that Metal init fails (here ~70 vitest workers each
creating a GPU stream at the same instant on the shared CI runner), the
panic cannot unwind across the boundary and the process `abort`s. The
created stream was never read anywhere, so the eager init had no purpose.

Fix: remove the `module_exports` init hook and the unused
`GENERATION_STREAM` static. MLX brings up its default device/stream lazily
on the first real op, so generation is unaffected; module load now does no
GPU work and cannot abort at `dlopen`. Confirmed: the rebuilt addon no
longer emits a `_napi_rs_internal_register_init` symbol at all.

Also reverts 10d3fe0 (CARGO_INCREMENTAL=0 + rust-cache prefix-key v2):
that was based on an incorrect "incremental miscompile" diagnosis — the
artifacts were never miscompiled — and had no effect on the real failure.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: ea9c562f86

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/mlx-core/src/models/gemma4/persistence.rs
@Brooooooklyn
Brooooooklyn merged commit 441afcf into main Jun 24, 2026
11 checks passed
@Brooooooklyn
Brooooooklyn deleted the feat/gemma4-unified-12b branch June 24, 2026 12:16
Brooooooklyn added a commit that referenced this pull request Jun 25, 2026
…n_embedder bf16 (#76)

## What

Enable `mlx convert` to quantize **`gemma4_unified`** (Gemma 4 12B —
encoder-free text+vision+audio) checkpoints. The runtime loader already
supports `gemma4_unified` (PR #74); only the **converter** didn't
recognize it, and the default quant predicate would have corrupted one
vision weight.

Motivating model: `google/gemma-4-12B-it-qat-q4_0-unquantized` (dense
12B, bf16, QAT-trained for llama.cpp `q4_0`). The best quality+speed
target is **4-bit affine group_size 32** — near-bf16 quality at 4-bit
memory/speed.

```
mlx convert <ckpt> -o <out> --quantize --q-mode affine --q-bits 4 --q-group-size 32
```

## Changes

1. **Recognize `gemma4_unified`** — `Gemma4Recipe::model_types` /
`recipe_for` / `CONVERTIBLE_MODEL_TYPES` route it to the existing shared
`Gemma4Recipe` (it is dense — no MoE split, sym8-supported, no MTP — so
it behaves identically to `gemma4` on every `recipe_for`-keyed path).
2. **Keep `vision_embedder.*` bf16** — `should_quantize()` excludes any
key containing `vision_embedder`. The encoder-free unified vision patch
projection is installed **dense-bf16-only** by the loader
(`apply_unified_vision_embedder_weights`, no `.scales` branch), so
quantizing `patch_dense.weight` would corrupt the vision path.
`embed_vision`/`embed_audio` projections **still quantize** (they have
affine loader branches). `vision_embedder` is unified-only → zero
collateral on other families.
3. **Sanitize keeps `vision_embedder.*` bare** — sibling of
`embed_vision.*`, not mis-prefixed under `language_model.model.`.
4. **CLI passes `gemma4_unified` through unchanged** — *not* collapsed
to `'gemma4'`. The driver's `model_type` is the CLI value, and the
gemma-QAT (wNa8o8) prequantized importer gate is exact `Some("gemma4")`;
collapsing would (a) dead-code the native `recipe_for("gemma4_unified")`
arm and (b) misroute a gemma-QAT unified checkpoint into the E2B-only
importer.

## End-to-end validation

Converted the real 23GB checkpoint → **8.4GB** `{bits:4, group_size:32,
mode:affine}`:
- auto-detected `gemma4_unified` (passthrough confirmed)
- `vision_embedder.*` → **bf16, no `.scales`** ✓ · text / `embed_vision`
/ `embed_audio` → quantized (U32 + scales) ✓
- model **loads and generates coherent text** (e.g. *"The capital of
France is Paris."*)

## Tests

- Rust: `should_quantize` excludes `vision_embedder` (with positive
controls that `embed_vision` + text layers still quantize);
`gemma4_unified` resolves to a recipe + is in `CONVERTIBLE_MODEL_TYPES`;
sanitize keeps `vision_embedder.*` bare; extended
`recipe_registry_reproduces_inline_flags` (sym8 allowlist).
- TS: `convert-cmd.test.ts` drives `run()` and asserts the `modelType`
handed to native is `gemma4_unified` (and `gemma4`/`gemma4_text` still
map to `gemma4`).
- Gates: `convert::` 106 pass, fmt + clippy clean, CLI test 4/4,
typecheck clean.

## Review

Three `/codex:adversarial-review` cycles → final verdict **approve, no
material findings**. The CLI-collapse misroute (cycle #1) was fixed; a
flagged sanitize-test SIGABRT (cycle #2) was confirmed to be a
review-harness/metallib environment artifact (the pre-existing
`gemma4_recipe_sanitize_transforms` test aborts identically; CI runs one
process per binary).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> **Medium Risk**
> Changes conversion routing and quantization rules for a new model type
(wrong passthrough could misroute QAT checkpoints); CI runner change is
low risk but affects all macOS jobs.
> 
> **Overview**
> Adds first-class **`gemma4_unified`** support in `mlx convert` so
encoder-free Gemma 4 unified checkpoints can be quantized without
breaking vision or misrouting QAT imports.
> 
> The native converter registers **`gemma4_unified`** on the shared
**`Gemma4Recipe`**, excludes **`vision_embedder.*`** from quantization
(bf16-only loader path), and keeps those weights at **bare keys** during
sanitize. The CLI **auto-detects** `gemma4_unified` (including
architecture-only configs) and **passes the string through** instead of
collapsing to `gemma4`, avoiding the E2B prequantized importer gate.
> 
> CI moves macOS jobs to **`macos-26`** and runs ignored cargo model e2e
tests with **`--test-threads=1`** to avoid Metal GPU watchdog timeouts
on shared runners. Docs and TS/Rust tests cover detection, quant
exclusions, and sanitize routing.
> 
> <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit
fb4e8e0. Bugbot is set up for automated
code reviews on this repo. Configure
[here](https://www.cursor.com/dashboard/bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

model-e2e Run the heavy Model E2E workflow (per-family real-checkpoint tests) on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant