Commit fecdc81ee for llama.cpp

commit fecdc81eec0922b229e7e6f4069cea603e10d6a7
Author: Nik Bogatyrev <50734723+notforu777@users.noreply.github.com>
Date:   Thu Nov 5 21:55:15 2026 +0300

    model : add Ripple (ricursiveai) (#31692)

    * feat : added ricursiveai ripple support

    * fix : mandatiry SWA, scale through helper, comments back

    * fix : metal

    * model : make ricursive RoPE/NoPE explicit and validate the layer pattern

    Write attention.rope_pattern and read it as optional, falling back to the
    sliding window pattern so existing GGUFs keep loading. Refuse a
    sliding_window_pattern that does not cover every layer, a model with no
    sliding layer, and a gate or value residual present in only some layers.
    The converter derives layer_types when the config omits it, ignores a stale
    hybrid_layer_pattern and drops tensors of features the config disables.

    Assisted-by: Claude Opus 5.5

    * model : make ricursive swiglu_clamp per-layer

    Store swiglu_clamp as a per-layer array, read it with get_key_or_arr so the
    scalar in existing GGUFs still applies to every layer, and refuse a negative
    or non-finite value. Add a test-llama-archs fixture with mixed per-layer
    clamps and no rope_pattern, so the saver roundtrip checks both.

    Assisted-by: Claude Opus 5.5

    * model : use the ricursive per-tensor scales and RoPE parameters

    Pass the attn_gate and output weight scales to build_lora_mm, take
    ext_factor and attn_factor from the graph instead of hardcoding them, and
    copy rope_freq_scale_train to the SWA layers instead of pinning it to 1.
    Cite the reference lines for the value residual and the NoPE layers.

    Assisted-by: Claude Opus 5.5

    * convert : drop the ricursive chat_template.jinja override

    The precedence between tokenizer_config.json and chat_template.jinja is to
    be changed in SpecialVocab for all models instead. Until then a new
    conversion stores the short tokenizer_config.json template; GGUFs that
    already carry the tool-aware template are not affected.

    Assisted-by: Claude Opus 5.5

    * fix : remove NVFP4 checkpoint quantization block

    * match #31733 refactor

    ---------

    Co-authored-by: Piotr Wilkin <ilintar@gmail.com>
    Co-authored-by: Sigbjørn Skjæret <sigbjorn.skjaeret@huggingface.co>

diff --git a/conversion/__init__.py b/conversion/__init__.py
index a4359f21f..fcbfb6a3a 100644
--- a/conversion/__init__.py
+++ b/conversion/__init__.py
@@ -20,6 +20,7 @@ TEXT_MODEL_MAP: dict[str, str] = {
     "NEOChatModel": "sensenova_u1",
     "AfmoeForCausalLM": "afmoe",
     "LagunaForCausalLM": "laguna",
+    "RicursiveForCausalLM": "ricursive",
     "FlintForCausalLM": "flint",
     "ApertusForCausalLM": "llama",
     "ArceeForCausalLM": "llama",
diff --git a/conversion/ricursive.py b/conversion/ricursive.py
new file mode 100644
index 000000000..13cb77367
--- /dev/null
+++ b/conversion/ricursive.py
@@ -0,0 +1,92 @@
+from __future__ import annotations
+
+import math
+
+from typing import Iterable, TYPE_CHECKING
+
+if TYPE_CHECKING:
+    from torch import Tensor
+
+from .base import ModelBase, TextModel, gguf, logger
+
+
+@ModelBase.register("RicursiveForCausalLM")
+@ModelBase.example("ricursiveAI/Ripple-65B-Preview")
+class RicursiveModel(TextModel):
+    model_arch = gguf.MODEL_ARCH.RICURSIVE
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        hparams = self.hparams
+
+        if (act := hparams.get("hidden_act", "silu")) != "silu":
+            raise ValueError(f"Ricursive: unsupported hidden_act {act!r}")
+
+        # RicursiveConfig derives the layer pattern from layer_types alone, by default full attention iff i % 5 == 4
+        if (layer_types := hparams.get("layer_types")) is None:
+            layer_types = ["full_attention" if i % 5 == 4 else "sliding_attention" for i in range(self.block_count)]
+        if len(layer_types) != self.block_count:
+            raise ValueError(f"Ricursive: layer_types has {len(layer_types)} entries, expected {self.block_count}")
+        if unknown := set(layer_types) - {"sliding_attention", "full_attention"}:
+            raise ValueError(f"Ricursive: unsupported layer_types {sorted(unknown)}")
+        self.is_swa = [t == "sliding_attention" for t in layer_types]
+        # only sliding layers use RoPE, an all-full-attention model would have no positional encoding
+        if not any(self.is_swa):
+            raise ValueError("Ricursive: layer_types has no sliding_attention layer, all-full-attention models are not supported")
+        # RicursiveConfig pops a stored hybrid_layer_pattern and recomputes it from layer_types
+        if (pattern := hparams.get("hybrid_layer_pattern")) is not None and list(pattern) != [int(swa) for swa in self.is_swa]:
+            logger.warning("Ricursive: hybrid_layer_pattern does not match layer_types, ignoring it")
+
+        if not hparams.get("gate_full_precision", True):
+            logger.warning("Ricursive: gate_full_precision is false, llama.cpp computes the attention gate in f32")
+
+        # RicursiveConfig defaults; the reference ignores the tensors of a disabled feature
+        self.use_valres = hparams.get("use_valres", False)
+        self.use_gate = hparams.get("use_gated_attention", True)
+        names = list(self.model_tensors)
+        if bias := [name for name in names if name.endswith("bias")]:
+            raise ValueError(f"Ricursive: bias tensors are not supported, found {bias[0]!r}")
+        for enabled, suffix in ((self.use_valres, ".self_attn.value_res_lambda"), (self.use_gate, ".self_attn.g_proj.weight")):
+            if enabled and sum(name.endswith(suffix) for name in names) != self.block_count:
+                raise ValueError(f"Ricursive: expected one {suffix!r} tensor per layer")
+
+    def set_vocab(self):
+        self._set_vocab_gpt2()
+        # eos_token_id is [<|im_end|>, <|endoftext|>]: register the turn end as EOT so generation stops on it
+        eos = self.hparams.get("eos_token_id")
+        if isinstance(eos, list):
+            extra = [e for e in eos if e != self.hparams.get("bos_token_id")]
+            if extra:
+                self.gguf_writer.add_eot_token_id(extra[0])
+                logger.info(f"gguf: eot_token_id = {extra[0]}")
+
+    def set_gguf_parameters(self):
+        super().set_gguf_parameters()
+        hparams = self.hparams
+        self.gguf_writer.add_vocab_size(hparams["vocab_size"])
+        self.gguf_writer.add_rope_dimension_count(hparams["head_dim"])
+
+        self.gguf_writer.add_sliding_window(hparams["sliding_window"])
+        self.gguf_writer.add_sliding_window_pattern(self.is_swa)
+        # sliding layers use RoPE, full attention layers use no positional encoding
+        self.gguf_writer.add_rope_pattern(self.is_swa)
+
+        if (embed_scale := hparams.get("embed_scale")) is not None:
+            if embed_scale == 0:
+                raise ValueError(f"Ricursive: unsupported embed_scale {embed_scale!r}")
+            self.gguf_writer.add_embedding_scale(float(embed_scale))
+        # absent and 0 both mean no clamp (the reference tests `if clamp_threshold:`)
+        if (clamp := hparams.get("ffn_clamp_threshold")) is not None:
+            if clamp < 0 or not math.isfinite(clamp):
+                raise ValueError(f"Ricursive: unsupported ffn_clamp_threshold {clamp!r}")
+            self.gguf_writer.add_swiglu_clamp([float(clamp)] * self.block_count)
+
+    def modify_tensors(self, data_torch: Tensor, name: str, bid: int | None) -> Iterable[tuple[str, Tensor]]:
+        # value_res_lambda has no .weight suffix in the checkpoint
+        if name.endswith(".value_res_lambda"):
+            if not self.use_valres:
+                return
+            name += ".weight"
+        elif name.endswith(".g_proj.weight") and not self.use_gate:
+            return
+        yield from super().modify_tensors(data_torch, name, bid)
diff --git a/gguf-py/gguf/constants.py b/gguf-py/gguf/constants.py
index 9fe16a6fa..170df4e6f 100644
--- a/gguf-py/gguf/constants.py
+++ b/gguf-py/gguf/constants.py
@@ -162,6 +162,7 @@ class Keys:
         EMBD_LENGTH_PER_LAYER_INP         = "{arch}.embedding_length_per_layer_input"
         SWIGLU_CLAMP_EXP                  = "{arch}.swiglu_clamp_exp"
         SWIGLU_CLAMP_SHEXP                = "{arch}.swiglu_clamp_shexp"
+        SWIGLU_CLAMP                      = "{arch}.swiglu_clamp"
         HIDDEN_ACT                        = "{arch}.hidden_activation"
         DENSE_FEAT_IN_SIZE                = "{arch}.{dense}_feat_in"
         DENSE_FEAT_OUT_SIZE               = "{arch}.{dense}_feat_out"
@@ -710,6 +711,7 @@ class MODEL_ARCH(IntEnum):
     ARCEE            = auto()
     AFMOE            = auto()
     LAGUNA           = auto()
+    RICURSIVE        = auto()
     ERNIE4_5         = auto()
     ERNIE4_5_MOE     = auto()
     HUNYUAN_MOE      = auto()
@@ -878,6 +880,7 @@ class MODEL_TENSOR(IntEnum):
     ATTN_K_NORM          = auto()
     LAYER_OUT_NORM       = auto()
     LAYER_OUT_SCALE      = auto()
+    ATTN_VRES_LAMBDA     = auto()
     PER_LAYER_TOKEN_EMBD = auto() # gemma3n
     PER_LAYER_MODEL_PROJ = auto() # gemma3n
     PER_LAYER_INP_GATE   = auto() # gemma3n
@@ -1624,6 +1627,7 @@ MODEL_ARCH_NAMES: dict[MODEL_ARCH, str] = {
     MODEL_ARCH.ARCEE:            "arcee",
     MODEL_ARCH.AFMOE:            "afmoe",
     MODEL_ARCH.LAGUNA:           "laguna",
+    MODEL_ARCH.RICURSIVE:        "ricursive",
     MODEL_ARCH.ERNIE4_5:         "ernie4_5",
     MODEL_ARCH.ERNIE4_5_MOE:     "ernie4_5-moe",
     MODEL_ARCH.FALCON_H1:        "falcon-h1",
@@ -1791,6 +1795,7 @@ TENSOR_NAMES: dict[MODEL_TENSOR, str] = {
     MODEL_TENSOR.FFN_POST_EXP_NORM:         "blk.{bid}.ffn_post_exp_norm",
     MODEL_TENSOR.LAYER_OUT_NORM:            "blk.{bid}.layer_output_norm",
     MODEL_TENSOR.LAYER_OUT_SCALE:           "blk.{bid}.layer_output_scale",
+    MODEL_TENSOR.ATTN_VRES_LAMBDA:          "blk.{bid}.attn_vres_lambda",
     MODEL_TENSOR.PER_LAYER_TOKEN_EMBD:      "per_layer_token_embd",           # gemma3n
     MODEL_TENSOR.PER_LAYER_MODEL_PROJ:      "per_layer_model_proj",           # gemma3n
     MODEL_TENSOR.PER_LAYER_PROJ_NORM:       "per_layer_proj_norm",            # gemma3n
@@ -5580,6 +5585,26 @@ MODEL_TENSORS: dict[MODEL_ARCH, list[MODEL_TENSOR]] = {
         MODEL_TENSOR.FFN_POST_NORM,
         MODEL_TENSOR.FFN_EXP_PROBS_B,
     ],
+    MODEL_ARCH.RICURSIVE: [
+        MODEL_TENSOR.TOKEN_EMBD,
+        MODEL_TENSOR.OUTPUT_NORM,
+        MODEL_TENSOR.OUTPUT,
+        MODEL_TENSOR.ATTN_NORM,
+        MODEL_TENSOR.ATTN_Q,
+        MODEL_TENSOR.ATTN_K,
+        MODEL_TENSOR.ATTN_V,
+        MODEL_TENSOR.ATTN_OUT,
+        MODEL_TENSOR.ATTN_Q_NORM,
+        MODEL_TENSOR.ATTN_K_NORM,
+        MODEL_TENSOR.ATTN_GATE,
+        MODEL_TENSOR.ATTN_VRES_LAMBDA,
+        MODEL_TENSOR.ATTN_POST_NORM,
+        MODEL_TENSOR.FFN_PRE_NORM,
+        MODEL_TENSOR.FFN_GATE,
+        MODEL_TENSOR.FFN_DOWN,
+        MODEL_TENSOR.FFN_UP,
+        MODEL_TENSOR.FFN_POST_NORM,
+    ],
     MODEL_ARCH.LAGUNA: [
         MODEL_TENSOR.TOKEN_EMBD,
         MODEL_TENSOR.OUTPUT_NORM,
diff --git a/gguf-py/gguf/gguf_writer.py b/gguf-py/gguf/gguf_writer.py
index 0e7aa5012..f6b98a57d 100644
--- a/gguf-py/gguf/gguf_writer.py
+++ b/gguf-py/gguf/gguf_writer.py
@@ -912,6 +912,9 @@ class GGUFWriter:
     def add_swiglu_clamp_exp(self, values: Sequence[float]) -> None:
         self.add_array(Keys.LLM.SWIGLU_CLAMP_EXP.format(arch=self.arch), values)

+    def add_swiglu_clamp(self, values: Sequence[float]) -> None:
+        self.add_array(Keys.LLM.SWIGLU_CLAMP.format(arch=self.arch), values)
+
     def add_swiglu_clamp_shexp(self, values: Sequence[float]) -> None:
         self.add_array(Keys.LLM.SWIGLU_CLAMP_SHEXP.format(arch=self.arch), values)

diff --git a/gguf-py/gguf/tensor_mapping.py b/gguf-py/gguf/tensor_mapping.py
index ff22a60bc..eeadd6a9a 100644
--- a/gguf-py/gguf/tensor_mapping.py
+++ b/gguf-py/gguf/tensor_mapping.py
@@ -451,6 +451,10 @@ class TensorNameMap:
             "model.layers.{bid}.self_attn.attn_gate_proj",  # k2-horizon
         ),

+        MODEL_TENSOR.ATTN_VRES_LAMBDA: (
+            "model.layers.{bid}.self_attn.value_res_lambda",  # ricursive
+        ),
+
         # Feed-forward norm
         MODEL_TENSOR.FFN_NORM: (
             "layers.{bid}.moe_connection.norm",                              # metis16
diff --git a/src/llama-arch.cpp b/src/llama-arch.cpp
index 6bb200f84..265c2ddeb 100644
--- a/src/llama-arch.cpp
+++ b/src/llama-arch.cpp
@@ -177,6 +177,7 @@ static const std::map<llm_arch, const char *> LLM_ARCH_NAMES = {
     { LLM_ARCH_K2_HORIZON,       "k2-horizon"       },
     { LLM_ARCH_BEAM,             "beam"             },
     { LLM_ARCH_KOLIBRI1,         "kolibri1"         },
+    { LLM_ARCH_RICURSIVE,        "ricursive"        },
     { LLM_ARCH_UNKNOWN,          "(unknown)"        },
 };

@@ -226,6 +227,7 @@ static const std::map<llm_kv, const char *> LLM_KV_NAMES = {
     { LLM_KV_EXPERT_CHUNK_FEED_FORWARD_LENGTH,  "%s.expert_chunk_feed_forward_length"  },
     { LLM_KV_SWIGLU_CLAMP_EXP,                  "%s.swiglu_clamp_exp"                  },
     { LLM_KV_SWIGLU_CLAMP_SHEXP,                "%s.swiglu_clamp_shexp"                },
+    { LLM_KV_SWIGLU_CLAMP,                      "%s.swiglu_clamp"                      },
     { LLM_KV_USE_PARALLEL_RESIDUAL,             "%s.use_parallel_residual"             },
     { LLM_KV_TENSOR_DATA_LAYOUT,                "%s.tensor_data_layout"                },
     { LLM_KV_EXPERT_COUNT,                      "%s.expert_count"                      },
@@ -594,6 +596,7 @@ static const std::map<llm_tensor, const char *> LLM_TENSOR_NAMES = {
     { LLM_TENSOR_ATTN_QKV,                               "blk.%d.attn_qkv" },
     { LLM_TENSOR_LAYER_OUT_NORM,                         "blk.%d.layer_output_norm" },
     { LLM_TENSOR_LAYER_OUT_SCALE,                        "blk.%d.layer_output_scale" },
+    { LLM_TENSOR_ATTN_VRES_LAMBDA,                       "blk.%d.attn_vres_lambda" },
     { LLM_TENSOR_ATTN_OUT_NORM,                          "blk.%d.attn_output_norm" },
     { LLM_TENSOR_POS_EMBD,                               "position_embd" },
     { LLM_TENSOR_FFN_ACT,                                "blk.%d.ffn.act" },
@@ -1099,6 +1102,7 @@ static const std::map<llm_tensor, llm_tensor_info> LLM_TENSOR_INFOS = {
     {LLM_TENSOR_ATTN_K_NORM,                {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL}},
     {LLM_TENSOR_LAYER_OUT_NORM,             {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL}},
     {LLM_TENSOR_LAYER_OUT_SCALE,            {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL}},
+    {LLM_TENSOR_ATTN_VRES_LAMBDA,           {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL}},
     {LLM_TENSOR_ATTN_Q_A_NORM,              {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL}},
     {LLM_TENSOR_ATTN_KV_A_NORM,             {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL}},
     {LLM_TENSOR_ATTN_SUB_NORM,              {LLM_TENSOR_LAYER_REPEATING, GGML_OP_MUL}},
diff --git a/src/llama-arch.h b/src/llama-arch.h
index 5dfad29ad..7bf0d3ae5 100644
--- a/src/llama-arch.h
+++ b/src/llama-arch.h
@@ -181,6 +181,7 @@ enum llm_arch {
     LLM_ARCH_BEAM,
     LLM_ARCH_KOLIBRI1,
     LLM_ARCH_LIMITE,
+    LLM_ARCH_RICURSIVE,
     LLM_ARCH_UNKNOWN,
 };

@@ -230,6 +231,7 @@ enum llm_kv {
     LLM_KV_EXPERT_CHUNK_FEED_FORWARD_LENGTH,
     LLM_KV_SWIGLU_CLAMP_EXP,
     LLM_KV_SWIGLU_CLAMP_SHEXP,
+    LLM_KV_SWIGLU_CLAMP,
     LLM_KV_USE_PARALLEL_RESIDUAL,
     LLM_KV_TENSOR_DATA_LAYOUT,
     LLM_KV_EXPERT_COUNT,
@@ -612,6 +614,7 @@ enum llm_tensor {
     LLM_TENSOR_ATTN_K_NORM,
     LLM_TENSOR_LAYER_OUT_NORM,
     LLM_TENSOR_LAYER_OUT_SCALE,
+    LLM_TENSOR_ATTN_VRES_LAMBDA,
     LLM_TENSOR_POST_ATTN_NORM,
     LLM_TENSOR_POST_MLP_NORM,
     LLM_TENSOR_PER_LAYER_TOKEN_EMBD, // gemma3n
diff --git a/src/llama-hparams.h b/src/llama-hparams.h
index 95daf482d..2f7b97823 100644
--- a/src/llama-hparams.h
+++ b/src/llama-hparams.h
@@ -431,6 +431,7 @@ struct llama_hparams {
     // Step35: optional per-layer clamps for (Swi)GLU
     std::array<float, LLAMA_MAX_LAYERS> swiglu_clamp_exp; // clamping for expert FFN
     std::array<float, LLAMA_MAX_LAYERS> swiglu_clamp_shexp; // shared expert
+    std::array<float, LLAMA_MAX_LAYERS> swiglu_clamp;       // dense FFN

     // this value n_pattern means that every nth layer is dense (i.e. non-SWA)
     // dense_first means whether the pattern is start with a dense layer
diff --git a/src/llama-model-saver.cpp b/src/llama-model-saver.cpp
index b8a8f77df..b667bc49f 100644
--- a/src/llama-model-saver.cpp
+++ b/src/llama-model-saver.cpp
@@ -210,6 +210,8 @@ void llama_model_saver::add_kv_from_model() {
     add_kv(LLM_KV_MOE_LATENT_SIZE,                   hparams.moe_latent_size);
     add_kv(LLM_KV_EXPERT_SHARED_FEED_FORWARD_LENGTH, hparams.n_ff_shexp);
     add_kv(LLM_KV_EXPERT_CHUNK_FEED_FORWARD_LENGTH,  hparams.n_ff_chexp);
+    add_kv(LLM_KV_SWIGLU_CLAMP, std::vector<float>(
+            hparams.swiglu_clamp.begin(), hparams.swiglu_clamp.begin() + hparams.n_layer_all));
     add_kv(LLM_KV_SWIGLU_CLAMP_EXP, std::vector<float>(
             hparams.swiglu_clamp_exp.begin(), hparams.swiglu_clamp_exp.begin() + hparams.n_layer_all));
     add_kv(LLM_KV_SWIGLU_CLAMP_SHEXP, std::vector<float>(
diff --git a/src/llama-model.cpp b/src/llama-model.cpp
index 03aab320f..27809ccad 100644
--- a/src/llama-model.cpp
+++ b/src/llama-model.cpp
@@ -294,6 +294,8 @@ static llama_model * llama_model_mapping(llm_arch arch, const llama_model_params
             return new llama_model_afmoe(params);
         case LLM_ARCH_LAGUNA:
             return new llama_model_laguna(params);
+        case LLM_ARCH_RICURSIVE:
+            return new llama_model_ricursive(params);
         case LLM_ARCH_ERNIE4_5:
             return new llama_model_ernie4_5(params);
         case LLM_ARCH_ERNIE4_5_MOE:
@@ -1548,6 +1550,7 @@ void llama_model_base::load_hparams(llama_model_loader & ml) {

     std::fill(hparams.swiglu_clamp_exp.begin(),   hparams.swiglu_clamp_exp.end(),   0.0f);
     std::fill(hparams.swiglu_clamp_shexp.begin(), hparams.swiglu_clamp_shexp.end(), 0.0f);
+    std::fill(hparams.swiglu_clamp.begin(),       hparams.swiglu_clamp.end(),       0.0f);

     ml.get_key_or_arr(LLM_KV_FEED_FORWARD_LENGTH,  hparams.n_ff_arr,   hparams.n_layer_all, false);
     ml.get_key_or_arr(LLM_KV_ATTENTION_HEAD_COUNT, hparams.n_head_arr, hparams.n_layer_all, false);
@@ -3709,6 +3712,7 @@ llama_rope_type llama_model_rope_type(const llama_model * model) {
         case LLM_ARCH_QWEN3MOE:
         case LLM_ARCH_LLADA_MOE:
         case LLM_ARCH_RND1:
+        case LLM_ARCH_RICURSIVE:
         case LLM_ARCH_OLMO2:
         case LLM_ARCH_OLMOE:
         case LLM_ARCH_PHI2:
diff --git a/src/llama-model.h b/src/llama-model.h
index 45d2b8666..f8cffb346 100644
--- a/src/llama-model.h
+++ b/src/llama-model.h
@@ -377,6 +377,9 @@ struct llama_layer {
     struct ggml_tensor * ffn_resid_alpha  = nullptr;
     struct ggml_tensor * ffn_resid_beta   = nullptr;

+    // value residual mix
+    struct ggml_tensor * attn_vres_lambda = nullptr;
+
     // ff shared expert (shexp)
     struct ggml_tensor * ffn_gate_inp_shexp = nullptr;
     struct ggml_tensor * ffn_gate_shexp     = nullptr;
diff --git a/src/models/models.h b/src/models/models.h
index 267468525..628275289 100644
--- a/src/models/models.h
+++ b/src/models/models.h
@@ -2269,6 +2269,18 @@ struct llama_model_beam : public llama_model_base {
 };


+struct llama_model_ricursive : public llama_model_base {
+    llama_model_ricursive(const struct llama_model_params & params) : llama_model_base(params) {}
+    void load_arch_hparams(llama_model_loader & ml) override;
+    void load_arch_tensors(llama_model_loader & ml) override;
+
+    struct graph : public llm_graph_context {
+        graph(const llama_model & model, const llm_graph_params & params);
+    };
+
+    std::unique_ptr<llm_graph_context> build_arch_graph(const llm_graph_params & params) const override;
+};
+
 struct llama_model_laguna : public llama_model_base {
     llama_model_laguna(const struct llama_model_params & params) : llama_model_base(params) {}
     void load_arch_hparams(llama_model_loader & ml) override;
diff --git a/src/models/ricursive.cpp b/src/models/ricursive.cpp
new file mode 100644
index 000000000..cd5dc3547
--- /dev/null
+++ b/src/models/ricursive.cpp
@@ -0,0 +1,236 @@
+#include "models.h"
+
+void llama_model_ricursive::load_arch_hparams(llama_model_loader & ml) {
+    ml.get_key(LLM_KV_ATTENTION_LAYERNORM_RMS_EPS, hparams.f_norm_rms_eps);
+    ml.get_key(LLM_KV_EMBEDDING_SCALE,             hparams.f_embedding_scale, false);
+    // optional: absent or 0 means no clamp (the reference tests `if clamp_threshold:`); a scalar applies to every layer
+    ml.get_key_or_arr(LLM_KV_SWIGLU_CLAMP, hparams.swiglu_clamp, hparams.n_layer_all, false);
+    for (uint32_t il = 0; il < hparams.n_layer_all; ++il) {
+        if (!std::isfinite(hparams.swiglu_clamp[il]) || hparams.swiglu_clamp[il] < 0.0f) {
+            throw std::runtime_error(format("ricursive: swiglu_clamp for layer %u must be finite and non-negative, got %g", il, hparams.swiglu_clamp[il]));
+        }
+    }
+
+    if (hparams.n_embd_head_k() != hparams.n_embd_head_v()) {
+        throw std::runtime_error("ricursive: key_length and value_length must be equal");
+    }
+
+    // RoPE is only applied on the SWA layers, so a model without SWA would have no positional encoding
+    ml.get_key(LLM_KV_ATTENTION_SLIDING_WINDOW, hparams.n_swa);
+    if (hparams.n_swa == 0) {
+        throw std::runtime_error("ricursive: sliding_window must be > 0");
+    }
+    hparams.swa_type = LLAMA_SWA_TYPE_STANDARD;
+    if (ml.get_arr(LLM_KV_ATTENTION_SLIDING_WINDOW_PATTERN, hparams.is_swa_impl, false)) {
+        // load_swa_pattern only bounds the array by LLAMA_MAX_LAYERS: a short one would leave the last layers dense and without RoPE
+        uint32_t n_entries = 0;
+        ml.get_arr_n(LLM_KV_ATTENTION_SLIDING_WINDOW_PATTERN, n_entries);
+        if (n_entries != hparams.n_layer()) {
+            throw std::runtime_error(format("ricursive: sliding_window_pattern has %u entries, expected %u", n_entries, hparams.n_layer()));
+        }
+    } else {
+        // default: every 5th layer is global (RicursiveConfig layer_types: full attention iff i % 5 == 4)
+        load_swa_pattern(ml, 5);
+    }
+    if (!hparams.is_swa_any()) {
+        throw std::runtime_error("ricursive: no sliding window layer, the model would have no positional encoding");
+    }
+
+    // RoPE exactly on the SWA layers; rope_pattern is optional because the first published GGUFs do not carry it
+    hparams.rope_pattern = hparams.is_swa_impl;
+    ml.get_key_or_arr(LLM_KV_ATTENTION_ROPE_PATTERN, hparams.rope_pattern, hparams.n_layer(), false);
+    for (uint32_t il = 0; il < hparams.n_layer(); ++il) {
+        if (hparams.has_rope(il) != hparams.is_swa(il)) {
+            throw std::runtime_error(format("ricursive: layer %u has %s but %s", il,
+                    hparams.has_rope(il) ? "RoPE" : "no RoPE", hparams.is_swa(il) ? "a sliding window" : "no sliding window"));
+        }
+    }
+
+    // the SWA layers are the only RoPE layers, they use the model RoPE parameters
+    hparams.rope_freq_base_train_swa  = hparams.rope_freq_base_train;
+    hparams.rope_freq_scale_train_swa = hparams.rope_freq_scale_train;
+    ml.get_key(LLM_KV_ROPE_FREQ_BASE_SWA, hparams.rope_freq_base_train_swa, false);
+
+    switch (hparams.n_layer()) {
+        case 80: type = LLM_TYPE_65B; break;
+        default: type = LLM_TYPE_UNKNOWN;
+    }
+}
+
+void llama_model_ricursive::load_arch_tensors(llama_model_loader &) {
+    LLAMA_LOAD_LOCALS;
+
+    tok_embd = create_tensor(tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab}, 0);
+
+    output_norm = create_tensor(tn(LLM_TENSOR_OUTPUT_NORM, "weight"), {n_embd}, 0);
+    output      = create_tensor(tn(LLM_TENSOR_OUTPUT,      "weight"), {n_embd, n_vocab}, TENSOR_NOT_REQUIRED);
+    if (output == NULL) {
+        output = create_tensor(tn(LLM_TENSOR_TOKEN_EMBD, "weight"), {n_embd, n_vocab}, TENSOR_DUPLICATED);
+    }
+
+    const int64_t n_embd_q = n_embd_head_k * n_head;
+
+    for (int i = 0; i < n_layer; ++i) {
+        auto & layer = layers[i];
+
+        layer.attn_norm = create_tensor(tn(LLM_TENSOR_ATTN_NORM, "weight", i), {n_embd}, 0);
+
+        layer.wq = create_tensor(tn(LLM_TENSOR_ATTN_Q,   "weight", i), {n_embd, n_embd_q},     0);
+        layer.wk = create_tensor(tn(LLM_TENSOR_ATTN_K,   "weight", i), {n_embd, n_embd_k_gqa}, 0);
+        layer.wv = create_tensor(tn(LLM_TENSOR_ATTN_V,   "weight", i), {n_embd, n_embd_v_gqa}, 0);
+        layer.wo = create_tensor(tn(LLM_TENSOR_ATTN_OUT, "weight", i), {n_embd_q, n_embd},     0);
+
+        layer.attn_q_norm = create_tensor(tn(LLM_TENSOR_ATTN_Q_NORM, "weight", i), {n_embd_head_k}, 0);
+        layer.attn_k_norm = create_tensor(tn(LLM_TENSOR_ATTN_K_NORM, "weight", i), {n_embd_head_k}, 0);
+
+        layer.wqkv_gate        = create_tensor(tn(LLM_TENSOR_ATTN_GATE,        "weight", i), {n_embd, n_embd_q}, TENSOR_NOT_REQUIRED);
+        layer.attn_vres_lambda = create_tensor(tn(LLM_TENSOR_ATTN_VRES_LAMBDA, "weight", i), {1},                TENSOR_NOT_REQUIRED);
+
+        layer.attn_post_norm = create_tensor(tn(LLM_TENSOR_ATTN_POST_NORM, "weight", i), {n_embd}, 0);
+
+        layer.ffn_norm      = create_tensor(tn(LLM_TENSOR_FFN_NORM,      "weight", i), {n_embd},       0);
+        layer.ffn_gate      = create_tensor(tn(LLM_TENSOR_FFN_GATE,      "weight", i), {n_embd, n_ff}, 0);
+        layer.ffn_up        = create_tensor(tn(LLM_TENSOR_FFN_UP,        "weight", i), {n_embd, n_ff}, 0);
+        layer.ffn_down      = create_tensor(tn(LLM_TENSOR_FFN_DOWN,      "weight", i), {n_ff, n_embd}, 0);
+        layer.ffn_post_norm = create_tensor(tn(LLM_TENSOR_FFN_POST_NORM, "weight", i), {n_embd},       0);
+    }
+
+    // the gate and the value residual are per-model switches (use_gated_attention, use_valres)
+    for (int i = 1; i < n_layer; ++i) {
+        if ((layers[i].wqkv_gate == nullptr) != (layers[0].wqkv_gate == nullptr)) {
+            throw std::runtime_error(format("ricursive: attn_gate must be present in all layers or in none (layers 0 and %d differ)", i));
+        }
+        if ((layers[i].attn_vres_lambda == nullptr) != (layers[0].attn_vres_lambda == nullptr)) {
+            throw std::runtime_error(format("ricursive: attn_vres_lambda must be present in all layers or in none (layers 0 and %d differ)", i));
+        }
+    }
+}
+
+std::unique_ptr<llm_graph_context> llama_model_ricursive::build_arch_graph(const llm_graph_params & params) const {
+    return std::make_unique<graph>(params);
+}
+
+llama_model_ricursive::graph::graph(const llm_graph_params & params) : llm_graph_context(params) {
+    const int64_t n_embd_head = hparams.n_embd_head_v();
+
+    ggml_tensor * cur;
+    ggml_tensor * inpL;
+
+    inpL = build_inp_embd(model.tok_embd);
+
+    // value residual: x0 = token embeddings * embedding_scale, the same tensor for all layers (modeling_ricursive.py:380-381,405)
+    ggml_tensor * inp_vres = inpL;
+
+    ggml_tensor * inp_pos = build_inp_pos();
+
+    auto * inp_attn = build_attn_inp_kv_iswa();
+
+    ggml_tensor * inp_out_ids = build_inp_out_ids();
+
+    const float kq_scale = 1.0f / sqrtf(float(n_embd_head));
+
+    for (int il = 0; il < n_layer; ++il) {
+        const auto & layer = model.layers[il];
+
+        ggml_tensor * inpSA = inpL;
+
+        cur = build_norm(inpL, layer.attn_norm, NULL, LLM_NORM_RMS, il);
+        cb(cur, "attn_norm", il);
+
+        ggml_tensor * attn_in = cur;
+
+        {
+            ggml_tensor * Qcur = build_lora_mm(layer.wq, attn_in, layer.wq_s);
+            ggml_tensor * Kcur = build_lora_mm(layer.wk, attn_in, layer.wk_s);
+
+            // v_in = lerp(x0, attn_norm(x), sigmoid(lambda)), lambda is learned per layer (modeling_ricursive.py:182-185)
+            ggml_tensor * v_in = attn_in;
+            if (layer.attn_vres_lambda) {
+                ggml_tensor * mix = ggml_sigmoid(ctx0, layer.attn_vres_lambda);
+                v_in = ggml_add(ctx0, inp_vres, ggml_mul(ctx0, ggml_sub(ctx0, attn_in, inp_vres), mix));
+                cb(v_in, "vres_in", il);
+            }
+            ggml_tensor * Vcur = build_lora_mm(layer.wv, v_in, layer.wv_s);
+
+            Qcur = ggml_reshape_3d(ctx0, Qcur, n_embd_head, n_head,    n_tokens);
+            Kcur = ggml_reshape_3d(ctx0, Kcur, n_embd_head, n_head_kv, n_tokens);
+            Vcur = ggml_reshape_3d(ctx0, Vcur, n_embd_head, n_head_kv, n_tokens);
+
+            Qcur = build_norm(Qcur, layer.attn_q_norm, NULL, LLM_NORM_RMS, il);
+            Kcur = build_norm(Kcur, layer.attn_k_norm, NULL, LLM_NORM_RMS, il);
+            cb(Qcur, "Qcur_normed", il);
+            cb(Kcur, "Kcur_normed", il);
+
+            // global layers use no RoPE (NoPE): the reference rotates only sliding_attention layers (modeling_ricursive.py:127-129,203-205)
+            if (hparams.has_rope(il)) {
+                const float freq_base_l  = model.get_rope_freq_base (cparams, il);
+                const float freq_scale_l = model.get_rope_freq_scale(cparams, il);
+
+                Qcur = ggml_rope_ext(ctx0, Qcur, inp_pos, nullptr,
+                        n_rot, rope_type, n_ctx_orig, freq_base_l, freq_scale_l,
+                        ext_factor, attn_factor, beta_fast, beta_slow);
+                Kcur = ggml_rope_ext(ctx0, Kcur, inp_pos, nullptr,
+                        n_rot, rope_type, n_ctx_orig, freq_base_l, freq_scale_l,
+                        ext_factor, attn_factor, beta_fast, beta_slow);
+            }
+            cb(Qcur, "Qcur", il);
+            cb(Kcur, "Kcur", il);
+            cb(Vcur, "Vcur", il);
+
+            cur = build_attn(inp_attn, NULL, NULL, NULL, Qcur, Kcur, Vcur, nullptr, nullptr, nullptr, kq_scale, il);
+            cb(cur, "attn_out", il);
+
+            if (layer.wqkv_gate) {
+                ggml_tensor * gate = build_lora_mm(layer.wqkv_gate, attn_in, layer.wqkv_gate_s);
+                gate = ggml_sigmoid(ctx0, gate);
+                cb(gate, "attn_gate", il);
+                cur = ggml_mul(ctx0, cur, gate);
+                cb(cur, "attn_gated", il);
+            }
+
+            cur = build_lora_mm(layer.wo, cur, layer.wo_s);
+        }
+
+        if (il == n_layer - 1 && inp_out_ids) {
+            cur   = ggml_get_rows(ctx0,   cur, inp_out_ids);
+            inpSA = ggml_get_rows(ctx0, inpSA, inp_out_ids);
+        }
+
+        cur = build_norm(cur, layer.attn_post_norm, NULL, LLM_NORM_RMS, il);
+        cb(cur, "attn_post_norm", il);
+
+        ggml_tensor * ffn_inp = ggml_add(ctx0, cur, inpSA);
+        cb(ffn_inp, "ffn_inp", il);
+
+        cur = build_norm(ffn_inp, layer.ffn_norm, NULL, LLM_NORM_RMS, il);
+        cb(cur, "ffn_norm", il);
+
+        {
+            ggml_tensor * gate = build_lora_mm(layer.ffn_gate, cur, layer.ffn_gate_s);
+            ggml_tensor * up   = build_lora_mm(layer.ffn_up,   cur, layer.ffn_up_s);
+            cur = hparams.swiglu_clamp[il] > 0.0f
+                ? ggml_swiglu_clamp(ctx0, gate, up, hparams.swiglu_clamp[il])
+                : ggml_swiglu_split(ctx0, gate, up);
+            cb(cur, "ffn_swiglu", il);
+            cur = build_lora_mm(layer.ffn_down, cur, layer.ffn_down_s);
+            cb(cur, "ffn_out", il);
+        }
+
+        cur = build_norm(cur, layer.ffn_post_norm, NULL, LLM_NORM_RMS, il);
+        cb(cur, "ffn_post_norm", il);
+
+        cur = ggml_add(ctx0, cur, ffn_inp);
+        cur = build_cvec(cur, il);
+        cb(cur, "l_out", il);
+
+        inpL = cur;
+    }
+
+    cur = build_norm(inpL, model.output_norm, NULL, LLM_NORM_RMS, -1);
+    cb(cur, "result_norm", -1);
+    res->t_embd = cur;
+
+    cur = build_lora_mm(model.output, cur, model.output_s);
+    cb(cur, "result_output", -1);
+    res->t_logits = cur;
+}
diff --git a/tests/fusion/MTL.csv b/tests/fusion/MTL.csv
index cae85fc21..46aec1076 100644
--- a/tests/fusion/MTL.csv
+++ b/tests/fusion/MTL.csv
@@ -318,6 +318,8 @@ qwen4exp            ,0   ,any     ,SOFT_MAX+ARGSORT+GET_ROWS+SUM_ROWS+CLAMP+DIV,
 qwen4exp            ,0   ,any     ,SSM_CONV+UNARY              ,      1
 refact              ,0   ,any     ,RMS_NORM+MUL                ,      5
 refact              ,0   ,any     ,RMS_NORM+MUL                ,      5
+ricursive           ,0   ,any     ,RMS_NORM+MUL                ,      9
+ricursive           ,0   ,any     ,RMS_NORM+MUL+ADD            ,      4
 rnd1                ,0   ,any     ,MUL+ADD                     ,      4
 rnd1                ,0   ,any     ,RMS_NORM+MUL                ,      9
 rnd1                ,0   ,any     ,SOFT_MAX+ARGSORT+GET_ROWS+SUM_ROWS+CLAMP+DIV,      2
diff --git a/tests/test-llama-archs.cpp b/tests/test-llama-archs.cpp
index c42831d0e..39255c341 100644
--- a/tests/test-llama-archs.cpp
+++ b/tests/test-llama-archs.cpp
@@ -267,7 +267,7 @@ static gguf_context_ptr get_gguf_ctx(const llm_arch arch, const bool moe) {
         n_layer = 6;
     } else if (arch == LLM_ARCH_STEP35 || arch == LLM_ARCH_LAGUNA) {
         n_embd = 160; // exercise per-head tensor split granularity with head size 80
-    } else if (arch == LLM_ARCH_QWEN3 || arch == LLM_ARCH_MUSE_GLIMMER || arch == LLM_ARCH_AFMOE) {
+    } else if (arch == LLM_ARCH_QWEN3 || arch == LLM_ARCH_MUSE_GLIMMER || arch == LLM_ARCH_AFMOE || arch == LLM_ARCH_RICURSIVE) {
         n_head = 4;
     } else if (arch == LLM_ARCH_LIMITE) {
         n_head  = 4;
@@ -311,7 +311,7 @@ static gguf_context_ptr get_gguf_ctx(const llm_arch arch, const bool moe) {
     if (arch == LLM_ARCH_QWEN3) {
         n_head_kv = 1; // MQA coverage
     } else if (arch == LLM_ARCH_MUSE_GLIMMER || arch == LLM_ARCH_MUSE_SPARK || arch == LLM_ARCH_AFMOE || arch == LLM_ARCH_MAION_CODER ||
-            arch == LLM_ARCH_KOLIBRI1 || arch == LLM_ARCH_LIMITE || arch == LLM_ARCH_GEMMA4) {
+            arch == LLM_ARCH_KOLIBRI1 || arch == LLM_ARCH_LIMITE || arch == LLM_ARCH_GEMMA4 || arch == LLM_ARCH_RICURSIVE) {
         n_head_kv = 2; // GQA coverage
     }
     if (arch == LLM_ARCH_BEAM) {
@@ -466,7 +466,7 @@ static gguf_context_ptr get_gguf_ctx(const llm_arch arch, const bool moe) {
         ms.add_kv(LLM_KV_ATTENTION_SLIDING_WINDOW_PATTERN, pattern);
     } else if (arch == LLM_ARCH_COHERE2MOE || arch == LLM_ARCH_MIMO2 || arch == LLM_ARCH_STEP35 || arch == LLM_ARCH_SPARK2_5 ||
             arch == LLM_ARCH_MUSE_GLIMMER || arch == LLM_ARCH_MUSE_SPARK || arch == LLM_ARCH_GRANITE_SWA || arch == LLM_ARCH_DOTS3NOTE ||
-            arch == LLM_ARCH_KOLIBRI1 || arch == LLM_ARCH_MAPLE || arch == LLM_ARCH_FLINT || arch == LLM_ARCH_BEAM) {
+            arch == LLM_ARCH_KOLIBRI1 || arch == LLM_ARCH_MAPLE || arch == LLM_ARCH_FLINT || arch == LLM_ARCH_BEAM || arch == LLM_ARCH_RICURSIVE) {
         std::vector<uint32_t> pattern;
         pattern.reserve(n_layer);
         for (uint32_t il = 0; il < n_layer; il++) {
@@ -636,6 +636,17 @@ static gguf_context_ptr get_gguf_ctx(const llm_arch arch, const bool moe) {
         ms.add_kv(LLM_KV_SWIGLU_CLAMP_EXP, 7.0f);
     }

+    if (arch == LLM_ARCH_RICURSIVE) {
+        std::vector<float> swiglu_clamp;
+        swiglu_clamp.reserve(n_layer);
+        for (uint32_t il = 0; il < n_layer; il++) {
+            // the random weight scales keep the FFN activations small, a small limit still clamps so the roundtrip checks the per-layer values
+            swiglu_clamp.push_back(il % 3 == 1 ? 0.0f : 1e-3f);
+        }
+        ms.add_kv(LLM_KV_EMBEDDING_SCALE, 16.0f);
+        ms.add_kv(LLM_KV_SWIGLU_CLAMP,    swiglu_clamp);
+    }
+
     if (arch == LLM_ARCH_METIS16) {
         ms.add_kv(LLM_KV_METIS_PASS_COUNT,               uint32_t(2));
         ms.add_kv(LLM_KV_HYPER_CONNECTION_COUNT,         uint32_t(4));