Skip to content

fix(models/moondream2): resolve EOS/prompt so real checkpoint generates - #609

Merged
inureyes merged 2 commits into
mainfrom
fix/issue-522-moondream2-eos-prompt
Jul 2, 2026
Merged

fix(models/moondream2): resolve EOS/prompt so real checkpoint generates#609
inureyes merged 2 commits into
mainfrom
fix/issue-522-moondream2-eos-prompt

Conversation

@inureyes

@inureyes inureyes commented Jul 2, 2026

Copy link
Copy Markdown
Member

Follow-up hardening for #522: the merged Moondream2 port loads its real checkpoint (3.48 GB, prepares 16 text + 730 image-prefix tokens) but prints [Generated 0 tokens] for a normal caption prompt. This fixes the EOS/BOS wiring so generation produces a non-empty description.

Root cause

The port inherited Moondream3's bos_id = eos_id = 0 convention. Moondream3 uses id 0 with its starmie tokenizer, but Moondream2 ships a GPT-2/CodeGen tokenizer (tokenizer_config.json / special_tokens_map.json) whose special token is <|endoftext|> = id 50256 and doubles as bos/eos/unk; id 0 there is the literal !.

The loader moondream2_text_config_value hardcoded eos_token_id: 0, so the generation stop set was exactly {0} (the checkpoint's config.json has an empty nested config object and no top-level eos, and generation_config.json carries no eos). The decode loop breaks the instant the first sampled token is read as a member of the stop set, before any token is pushed, which is exactly the observed [Generated 0 tokens]. Separately, the BOS prefix embedding used id 0 (!) instead of the real <|endoftext|>, feeding the decoder a malformed sequence start.

Fix

Resolve the special-token id from the checkpoint instead of hardcoding it. Order: explicit eos_token_id in config.json (top-level, then the nested config object), then the tokenizer's declared eos_token string looked up in added_tokens_decoder, then the GPT-2 fallback 50256. The resolved id feeds the wrapper eos set, the text-config eos, and the BOS prefix, so a caption prompt now generates and terminates on the genuine end-of-text.

What changed

  • src/loading/vlm_special.rs: add resolve_moondream2_eos_token_id plus a tokenizer_config.json reader; moondream2_text_config_value now takes the resolved id and emits both eos_token_id and bos_token_id; load_moondream2_vlm wires the resolved id into the wrapper eos set.
  • src/models/moondream2.rs: default eos/bos to 50256, add a bos_token_id config field, and have bos_token_id() read it.
  • src/multimodal/moondream2_prompt.rs: MOONDREAM2_BOS_ID 0 -> 50256.
  • tests: corrected EOS/BOS default assertions, three resolver fixtures built from the real config.json / tokenizer_config.json shapes, and a BOS-id lock.

Test plan

  • cargo test --lib moondream2 (17 passed, 0 failed)
  • cargo check --lib --tests
  • cargo clippy --lib --tests -- -D warnings (clean)
  • cargo fmt --check (clean)

The bug manifests at GPU generation time; the two real-model checks in tests/moondream2_parity.rs stay #[ignore]-gated and are validated separately on device.

Follow-up hardening for #522: the merged Moondream2 port loads its real checkpoint but emits zero tokens because it inherited Moondream3's bos_id = eos_id = 0 convention. Moondream3 uses id 0 with its starmie tokenizer, but Moondream2 ships a GPT-2/CodeGen tokenizer whose special token is <|endoftext|> (id 50256); id 0 there is the literal "!".

The loader hardcoded eos_token_id: 0, so the generation stop set was {0} (both generation_config.json and config.json carry no eos, and the nested config object is empty). The decode loop breaks the instant the first sampled token is read as a stop id, before pushing anything, which prints "[Generated 0 tokens]". The BOS prefix embedding also used id 0 instead of the real <|endoftext|>, feeding the decoder a malformed sequence start.

Resolve the special-token id from the checkpoint instead: explicit eos_token_id (top-level, then the nested config object), then the tokenizer's declared eos_token looked up in added_tokens_decoder, then the GPT-2 fallback 50256. The resolved id feeds the wrapper eos set, the text-config eos, and the BOS prefix so a normal caption prompt now generates and terminates on the genuine end-of-text.

Changes:

- src/loading/vlm_special.rs: add resolve_moondream2_eos_token_id plus a tokenizer_config.json reader; moondream2_text_config_value now takes the resolved id and emits both eos and bos; the loader wires the resolved id into the wrapper eos set.
- src/models/moondream2.rs: default eos/bos to 50256, add a bos_token_id config field, and have bos_token_id() read it.
- src/multimodal/moondream2_prompt.rs: MOONDREAM2_BOS_ID 0 -> 50256.
- tests: corrected EOS/BOS defaults, resolver fixtures built from the real config.json / tokenizer_config.json shapes, and a BOS-id lock.
@inureyes inureyes added type:bug Bug fixes, error corrections, or issue resolutions area:models Model architectures, weights, loading, metadata status:review Under review labels Jul 2, 2026
@inureyes
inureyes merged commit 25763dc into main Jul 2, 2026
inureyes added a commit that referenced this pull request Jul 2, 2026
…#616)

The round-1 hardening (#609) made the real vikhyatk/moondream2 checkpoint load and generate, but the output was pure garbage (`!NCJNCJ...`). The forward math itself is correct; auditing it layer by layer against the checkpoint's own reference code (text.py, rope.py, layers.py, vision.py) and the working in-tree Moondream3 port turned up no numeric divergence, and a new remap guard test now pins the full 592-tensor key contract. The real divergence is one level up: the 2025-06-21 revision is trained against the moondream/starmie-v1 tokenizer with Moondream3-style control-token templates (query = [1, 15381, 2] + question + [3], bos = eos = 0), but the official repository never removed its legacy GPT-2 tokenizer.json, and the port picked that stale file up, framed the prompt as GPT-2 "Question:/Answer:" text, and prepended BOS 50256. The model saw an out-of-vocabulary word salad, answered with its true EOS (id 0, which decodes to `!` under the wrong vocabulary), was not stopped because round 1 had moved EOS to 50256, and then looped degenerately.

Changes:

- `moondream2_prompt`: add `Moondream2PromptStyle` (StarmieTemplates vs LegacyQuestionAnswer) with `detect_moondream2_prompt_style`, which reads the checkpoint's bundled moondream.py (it names the tokenizer repo per revision) and falls back to sniffing tokenizer.json for `<|md_reserved_0|>`. Starmie prompts mirror the working Moondream3 template ids exactly; the legacy Question/Answer framing is kept for the 2025-01-09 .. 2025-04-14 revisions where the GPT-2 contract is genuinely correct (verified against those revisions' config.py: bos_id = eos_id = 50256, templates in GPT-2 ids).
- `tokenizer::load_tokenizer`: new starmie override ahead of the local tokenizer.json branch. For starmie-era moondream2 checkpoints whose local tokenizer.json is not starmie, resolve moondream/starmie-v1 via hf-hub (the same path the Moondream3 fallback already uses, cached after the first fetch) with an actionable offline error.
- `load_moondream2_vlm` / `resolve_moondream2_eos_token_id`: era-aware bos/eos resolution. Starmie era returns 0 and explicitly ignores the stale legacy tokenizer_config.json (which reports 50256); explicit config.json ids still win; legacy behavior is unchanged.
- Thread the detected style through the four prompt call sites (CLI generate_vlm, vlm_runtime, both model_worker paths) via a new `Moondream2VLModel::prompt_style` field, and correct the misleading round-1 comments claiming eos 0 was invalid.
- Tests: starmie/legacy prompt shaping with exact id sequences, era detection (moondream.py wins over stale tokenizer.json), tokenizer override firing/skipping matrix, era-aware eos resolution against the real config shapes, and a remap guard asserting the real checkpoint's 592 tensor keys map exactly onto the loader-required set with region tensors dropped. Real-checkpoint-gated parity tests confirm era detection on the actual snapshot and that the resolved tokenizer maps "query" to 15381 and id 0 to `<|endoftext|>`; the heavy forward tests stay `#[ignore]`-gated.
@inureyes
inureyes deleted the fix/issue-522-moondream2-eos-prompt branch July 2, 2026 13:49
@inureyes inureyes self-assigned this Aug 31, 2026
@inureyes inureyes added status:done Completed and removed status:review Under review labels Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:models Model architectures, weights, loading, metadata status:done Completed type:bug Bug fixes, error corrections, or issue resolutions

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant