Skip to content

Commit fc53794

Browse files
linuxid10tclaude
andcommitted
laguna: support official Laguna-XS-2.1 GGUF metadata conventions
Poolside's official Laguna-XS-2.1-Q4_K_M.gguf uses different GGUF metadata conventions than this fork's converter. Four fixes: - vocab: accept tokenizer.ggml.pre = "laguna" as an alias for llama-bpe (byte-identical vocab, same Llama-3 splitting regex) - arch: new LLM_KV_ATTENTION_LAYER_TYPES key; laguna derives the SWA mask from the attention.layer_types string array when the sliding_window_pattern bool array is absent - laguna: gate the shared-expert MLP on tensor presence instead of expert_shared_count > 0 — XS-2.1 omits that KV, which silently dropped the shared expert on every MoE layer (garbage output) - chat: recognize the laguna_glm_thinking_v4 template marker (shipped by XS-2.1 and M.1) in both the built-in template auto-detection and the autoparser workaround; v4 is identical to v5 except for an empty default system message Verified: coherent greedy output, SWA 3:1 pattern + iswa cache in the load log, clean reasoning split in llama-cli with no tag leaks; test-llama-archs laguna and test-chat still pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent c7c2f97 commit fc53794

7 files changed

Lines changed: 49 additions & 7 deletions

File tree

CLAUDE.md

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,25 @@ IMPORTANT: Ensure you've thoroughly reviewed the [AGENTS.md](AGENTS.md) file bef
3131
- **Step 5 — Numerical validation**: Compare against HF Transformers. Requires CUDA/ROCm — not feasible on current hardware (AMD RX 7900 XT, no ROCm setup). Defer to a CUDA machine.
3232
- **Step 7 — Quantization top-1 check**: Run f16 GGUF CPU-only (mmap) and compare top-1 tokens against Q4_K_M on GPU for a short prompt. Feasible but slow (f16 = 67 GB, exceeds 60 GB RAM; MoE means actual resident pages are manageable for a few tokens).
3333

34+
### Laguna-XS-2.1 compatibility (2026-07-05, complete)
35+
36+
Poolside shipped an official `Laguna-XS-2.1-Q4_K_M.gguf` (in `/mnt/7dd.../LM Studio Models/poolside/Laguna-XS-2.1-GGUF/`). It is architecturally the same as XS.2 (same 40-layer 3:1 SWA layout, same per-layer head counts `[48,64,64,64,...]`, same MoE 256+1, same gate tensor sizes -> per-head, same n_embd=2048/head_dim=128) and the **tokenizer is byte-for-byte identical** (same 100352 tokens, 100026 merges, same token_type). But the GGUF metadata conventions differ from this fork's converter, which broke loading. Diagnosis + fixes:
37+
38+
**Fixed (model now loads):**
39+
- `tokenizer.ggml.pre = "laguna"` (XS.2 wrote `"llama-bpe"`). llama.cpp rejected it with `unknown pre-tokenizer type: 'laguna'`. Since the vocab is identical to the working XS.2 (which gguf-py auto-detects as `llama-bpe` = `LLAMA_VOCAB_PRE_TYPE_LLAMA3`), `"laguna"` is just an alias. Added `"laguna"` to the LLAMA3 branch in `src/llama-vocab.cpp`.
40+
- SWA pattern encoding differs: XS.2 writes `laguna.attention.sliding_window_pattern` (bool array); XS-2.1 writes `laguna.attention.layer_types` (string array: `"full_attention"`/`"sliding_attention"`) and omits the bool array. The laguna loader populated `is_swa_impl` only from the bool array, so XS-2.1 read every layer as global (wrong RoPE dims, no iswa cache). Added `LLM_KV_ATTENTION_LAYER_TYPES` (`%s.attention.layer_types`) to `llama-arch.h`/`.cpp`, and a fallback in `laguna.cpp::load_arch_hparams` that derives `is_swa_impl` from the strings when `sliding_window_pattern` is absent.
41+
42+
**Fixed (2026-07-05, second pass — model now fully working):**
43+
- **Shared expert skipped (was the garbage root cause — confirmed).** XS-2.1 omits `laguna.expert_shared_count`, so `n_expert_shared` stayed 0 and the shared-expert MLP was gated off. Fix: gate on tensor presence (`if (model.layers[il].ffn_gate_shexp)`) instead of `n_expert_shared > 0` — matches the `wqkv_gate` pattern and is safe when tensors are absent. Output is now coherent ("The capital of France is" -> "…Paris." greedy).
44+
- **SWA fix verified end-to-end**: load log shows `is_swa_any = 1`, correct 3:1 layer pattern (`is_swa` per-layer), `n_rot = 64` global / `n_rot_swa = 128`, and `llama_kv_cache_iswa` created with SWA layers filtered into the SWA cache.
45+
- **XS-2.1 ships the `laguna_glm_thinking_v4` template marker** (same as M.1 — the two Open Items were one issue). The v4 template is functionally identical to v5 except the default system message is empty (v5 hardcodes the Poolside default). Added `laguna_glm_thinking_v4` to (a) the built-in template auto-detection in `src/llama-chat.cpp` (fixes `llama-completion` abort "this custom template is not supported") and (b) the Laguna autoparser workaround trigger in `common/chat-diff-analyzer.cpp` (without it, `llama-cli` classified the entire reply incl. `</think>`/`</assistant>` as reasoning).
46+
- **Conversation mode works**: `llama-cli` peg crash is gone (it was downstream of the garbage); reasoning/content split cleanly, no `</think>`/`</assistant>` leaks.
47+
- Benign: loader warns `special_eos_id/special_eot_id is not in special_eog_ids`, but both EOS (2) and EOT (24 `</assistant>`) do end up in the EOG set and generation stops correctly — cosmetic ordering quirk, no action.
48+
- Note: `--no-conversation` is silently rejected by this `llama-cli` ("please use llama-completion instead"); `llama-completion` in turn asserts on `n_gpu_layers`, so raw-completion smoke tests need `llama-completion` left at its auto `-ngl` (do not pass `-ngl`).
49+
- Regression: `test-llama-archs laguna` OK (all 3 backends), `test-chat` passes. `test-chat-auto-parser` has 206 pre-existing failures at baseline HEAD (incl. `poolside-Laguna.jinja`, 3/3) — unchanged by these fixes; not investigated.
50+
51+
**XS.2 vs XS-2.1 metadata diff (full):** XS.2-only keys: `attention.gate_per_head`, `attention.sliding_window_pattern`, `expert_shared_count`. XS-2.1-only keys: `attention.layer_types`, `mlp.layer_types`, `expert_gating_func` (=2, sigmoid), `vocab_size`, `quantize.imatrix.*`. Also `rope.scaling.factor` 64->32 and `original_context_length` 4096->8192 (YaRN base changed; data-driven, self-consistent, no code change needed). Of these, only `sliding_window_pattern` and `expert_shared_count` were load-bearing (both fixed).
52+
3453
### Key learnings from implementation
3554

3655
1. **`test-llama-archs` roundtrip test**: The test serializes and reloads models via `llama_model_saver`. If `llama_model_saver_supports_arch()` returns true for your arch, the roundtrip will run. The saver doesn't preserve all custom hparams (e.g., `sliding_window_pattern`), so either add the arch to the unsupported list or make those hparams optional with `false` in `get_key_or_arr`.
@@ -48,6 +67,10 @@ IMPORTANT: Ensure you've thoroughly reviewed the [AGENTS.md](AGENTS.md) file bef
4867
14. **Continuation with empty `reasoning.start` (assistant prefill)**: `generate_parser` in `chat-auto-parser-generator.cpp` only rebuilt the continuation generation prompt when `reasoning.start` was non-empty. With Laguna's delimiter-style workaround (`start=""`), content continuation appended the content directly after the prefilled `<think>` (inside the think block) and reasoning continuation dropped `reasoning_content` entirely. Fix: `else if (!reasoning.end.empty() && enable_thinking)` branch appends `msg.reasoning_content` (+ `reasoning.end` for CONTENT continuation) directly — the opening tag is already in the generation prompt. Continuation only reproduces the model's native format when the client round-trips reasoning/content verbatim (reasoning ends `\n`, content starts `\n`); hand-stripping those newlines makes the model re-emit a stray `</think>`.
4968
15. **`reasoning_format=none` quirk (known, left as-is)**: with thinking on, content is the raw generated stream — `reasoning…</think>\nanswer` with **no opening `<think>`**, because the tag is prefilled in the prompt and never generated. Raw passthrough is consistent behavior; the peg parser cannot inject text that isn't in the stream.
5069
16. **`layer_types` defaults to all full_attention (Laguna-M); rotary is data-driven**: `configuration_laguna.py` defaults `layer_types` to `["full_attention"]*n_layers` when absent (Laguna-M); Laguna-XS ships an explicit mix. The converter must mirror this or the per-layer `head_count`/`sliding_window_pattern` arrays come out empty. Partial rotary is now data-driven via `rope.dimension_count`/`rope.dimension_count_swa` (written from each rope config's `partial_rotary_factor`: XS.2 0.5 global / 1.0 SWA; M.1 1.0 everywhere), replacing the old hardcoded `n_rot_full /= 2`.
70+
17. **`tokenizer.ggml.pre = "laguna"` is an alias for `llama-bpe`**: Poolside's official GGUFs label the BPE pre-tokenizer `"laguna"`; the fork's converter writes `"llama-bpe"`. Verified the vocab (tokens + merges + token_type) is byte-identical between the two, and gguf-py auto-detects this vocab as `llama-bpe` (= `LLAMA_VOCAB_PRE_TYPE_LLAMA3`), which produced correct XS.2 output. So the regex is the Llama-3 one; map `"laguna"` into the LLAMA3 branch in `src/llama-vocab.cpp`. The pre string only selects the splitting regex; it is not stored in the GGUF beyond the name.
71+
18. **`is_swa_impl` must be populated from `attention.layer_types` strings when `sliding_window_pattern` is absent**: the framework's `is_swa(il)` reads only `is_swa_impl`, which laguna filled solely from the bool `sliding_window_pattern` array. Poolside's XS-2.1 GGUF ships string `attention.layer_types` (`"full_attention"`/`"sliding_attention"`) instead. Without the fallback, every layer reads as global -> wrong `n_rot` (64 vs 128) on SWA layers, no iswa cache, `swa_type=NONE`. Fix: `get_key_or_arr(SLIDING_WINDOW_PATTERN, ..., false)` returns `false` when absent; on that, read the `LLM_KV_ATTENTION_LAYER_TYPES` string array and set `is_swa_impl[il] = (layer_types[il] == "sliding_attention")`. Must run before the `swa_type = is_swa_any() ? ... : NONE` line.
72+
19. **`n_expert_shared` defaults to 0; gate the shared expert on tensor presence, not the KV (FIXED)**: the laguna graph builder gated the shared-expert MLP on `if (hparams.n_expert_shared > 0)`. Poolside's XS-2.1 GGUF omits `laguna.expert_shared_count`, so `n_expert_shared` stayed 0 and the shared expert (tensors present and loaded via `TENSOR_NOT_REQUIRED`) was never applied -> garbage output. Fixed by gating on `if (model.layers[il].ffn_gate_shexp)` — presence-based, same pattern as `wqkv_gate`, and confirmed as the garbage root cause (coherent output immediately after).
73+
20. **`laguna_glm_thinking_v4` vs `_v5` templates**: v4 (shipped by official XS-2.1 and M.1 GGUFs) is functionally identical to v5 (shipped by the fork's XS.2 conversion) except v4's default system message is empty while v5 hardcodes the Poolside default. Both markers must be recognized in TWO places: `src/llama-chat.cpp` auto-detection (else `common_chat_format_example` throws and llama-completion aborts at startup) and the `chat-diff-analyzer.cpp` Laguna workaround trigger (else the streaming parser misclassifies the whole reply as reasoning and leaks `</think>`/`</assistant>`).
5174

5275
---
5376

@@ -141,6 +164,6 @@ Greedy decoding on `"The capital of France is"` → `" Paris.\nThe capital of Ge
141164

142165
## Open Items
143166
- `moe_router_logit_softcapping` is absent from both Laguna-XS.2 and Laguna-M.1 `config.json` — no plumbing needed.
144-
- **Laguna-M.1 chat template**: ships `laguna_glm_thinking_v4`, but auto-detection in `src/llama-chat.cpp` keys on the `laguna_glm_thinking_v5` marker, so the built-in matcher rejects it (`custom template not supported`). Workaround: pass `--jinja`. Follow-up: add the v4 marker to auto-detection.
167+
- ~~**Laguna-M.1 chat template**: ships `laguna_glm_thinking_v4`, rejected by the v5-only auto-detection.~~ **Resolved 2026-07-05** as part of XS-2.1 support: the v4 marker is now recognized in both `src/llama-chat.cpp` auto-detection and the `chat-diff-analyzer.cpp` workaround (learning 20).
145168
- **Step 5 / Step 7** still deferred — numerical validation needs CUDA; the 226B M.1 is also impractical for the quant top-1 check on current hardware.
146169
- **Pre-existing XS.2 GGUFs need reconversion** — they predate the now-required `rope.dimension_count` and would otherwise get full rotary instead of half. **In progress (2026-06-20):** re-cloning latest `poolside/Laguna-XS.2` and reconverting to f16 → Q4_K_M/IQ4_XS; also fixed a converter `gate_per_head` bug (learning 12) that would otherwise have made the new GGUF fail to load.

common/chat-diff-analyzer.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,8 @@ static std::vector<std::function<void(const common_chat_template & tmpl, autopar
183183
// Also: </assistant> is the EOT token and role-closing tag — add it to additional_stops
184184
// so it is erased from streaming output (both server API and CLI) before being sent.
185185
[](const common_chat_template & tmpl, autoparser & analysis) -> void {
186-
if (tmpl.src.find("laguna_glm_thinking_v5") != std::string::npos) {
186+
if (tmpl.src.find("laguna_glm_thinking_v5") != std::string::npos ||
187+
tmpl.src.find("laguna_glm_thinking_v4") != std::string::npos) {
187188
analysis.reasoning.start = "";
188189
analysis.reasoning.end = "</think>";
189190
analysis.reasoning.mode = reasoning_mode::TAG_BASED;

src/llama-arch.cpp

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -240,6 +240,7 @@ static const std::map<llm_kv, const char *> LLM_KV_NAMES = {
240240
{ LLM_KV_ATTENTION_RELATIVE_BUCKETS_COUNT, "%s.attention.relative_buckets_count" },
241241
{ LLM_KV_ATTENTION_SLIDING_WINDOW, "%s.attention.sliding_window" },
242242
{ LLM_KV_ATTENTION_SLIDING_WINDOW_PATTERN, "%s.attention.sliding_window_pattern" },
243+
{ LLM_KV_ATTENTION_LAYER_TYPES, "%s.attention.layer_types" },
243244
{ LLM_KV_ATTENTION_GATE_PER_HEAD, "%s.attention.gate_per_head" },
244245
{ LLM_KV_ATTENTION_SCALE, "%s.attention.scale" },
245246
{ LLM_KV_ATTENTION_OUTPUT_SCALE, "%s.attention.output_scale" },

src/llama-arch.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -245,6 +245,7 @@ enum llm_kv {
245245
LLM_KV_ATTENTION_RELATIVE_BUCKETS_COUNT,
246246
LLM_KV_ATTENTION_SLIDING_WINDOW,
247247
LLM_KV_ATTENTION_SLIDING_WINDOW_PATTERN,
248+
LLM_KV_ATTENTION_LAYER_TYPES,
248249
LLM_KV_ATTENTION_GATE_PER_HEAD,
249250
LLM_KV_ATTENTION_SCALE,
250251
LLM_KV_ATTENTION_OUTPUT_SCALE,

src/llama-chat.cpp

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,7 @@ llm_chat_template llm_chat_detect_template(const std::string & tmpl) {
236236
return LLM_CHAT_TEMPLATE_PANGU_EMBED;
237237
} else if (tmpl_contains("<|begin|>") && tmpl_contains("<|end|>") && tmpl_contains("<|content|>")) {
238238
return LLM_CHAT_TEMPLATE_SOLAR_OPEN;
239-
} else if (tmpl_contains("laguna_glm_thinking_v5")) {
239+
} else if (tmpl_contains("laguna_glm_thinking_v5") || tmpl_contains("laguna_glm_thinking_v4")) {
240240
return LLM_CHAT_TEMPLATE_LAGUNA;
241241
}
242242
return LLM_CHAT_TEMPLATE_UNKNOWN;

src/llama-vocab.cpp

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2117,7 +2117,8 @@ void llama_vocab::impl::load(llama_model_loader & ml, const LLM_KV & kv) {
21172117
tokenizer_pre == "pixtral" ||
21182118
tokenizer_pre == "midm-2.0" ||
21192119
tokenizer_pre == "lfm2" ||
2120-
tokenizer_pre == "jina-v5-nano") {
2120+
tokenizer_pre == "jina-v5-nano" ||
2121+
tokenizer_pre == "laguna") {
21212122
pre_type = LLAMA_VOCAB_PRE_TYPE_LLAMA3;
21222123
ignore_merges = true;
21232124
add_bos = true;

src/models/laguna.cpp

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,20 @@ void llama_model_laguna::load_arch_hparams(llama_model_loader & ml) {
2626

2727
ml.get_key(LLM_KV_ATTENTION_SLIDING_WINDOW, hparams.n_swa);
2828
ml.get_key(LLM_KV_ROPE_FREQ_BASE_SWA, hparams.rope_freq_base_train_swa, false);
29-
ml.get_key_or_arr(LLM_KV_ATTENTION_SLIDING_WINDOW_PATTERN, hparams.is_swa_impl, hparams.n_layer(), false);
29+
const bool has_swa_pattern =
30+
ml.get_key_or_arr(LLM_KV_ATTENTION_SLIDING_WINDOW_PATTERN, hparams.is_swa_impl, hparams.n_layer(), false);
31+
32+
// The fork converter writes sliding_window_pattern (bool array). The official Laguna
33+
// GGUFs ship attention.layer_types strings instead; derive the SWA mask from those when
34+
// the bool pattern is absent.
35+
if (!has_swa_pattern) {
36+
std::vector<std::string> layer_types;
37+
if (ml.get_arr(LLM_KV_ATTENTION_LAYER_TYPES, layer_types, false)) {
38+
for (uint32_t il = 0; il < hparams.n_layer() && il < (uint32_t) layer_types.size(); ++il) {
39+
hparams.is_swa_impl[il] = layer_types[il] == "sliding_attention";
40+
}
41+
}
42+
}
3043

3144
// create_memory() requires swa_type != NONE iff is_swa_any(): the iswa KV cache is
3245
// only allocated when at least one layer is sliding-window. Laguna-M uses full
@@ -266,8 +279,10 @@ llama_model_laguna::graph::graph(const llama_model & model, const llm_graph_para
266279
il);
267280
cb(moe_out, "ffn_moe_out", il);
268281

269-
// shared expert MLP (if present)
270-
if (hparams.n_expert_shared > 0) {
282+
// shared expert MLP. Gate on tensor presence, not expert_shared_count: the
283+
// official Laguna GGUFs omit that KV (n_expert_shared stays 0) but still ship
284+
// the shexp tensors, which must be applied on every MoE layer.
285+
if (model.layers[il].ffn_gate_shexp) {
271286
ggml_tensor * sh_out = build_ffn(cur,
272287
model.layers[il].ffn_up_shexp, nullptr, nullptr,
273288
model.layers[il].ffn_gate_shexp, nullptr, nullptr,

0 commit comments

Comments
 (0)