spark_model/lora/
key.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! LoRA key classification + adapter identity: the FNV-1a `adapter_id_hash`
4//! cache key and the `classify_key` PEFT-key → (layer, module, A|B) decoder
5//! (every unsupported shape a NAMED hard rejection). Split out of the former
6//! monolithic `lora/mod.rs` (SDD seam: KEY CLASSIFICATION) — visibility
7//! unchanged.
8
9use anyhow::{Result, anyhow, bail};
10use atlas_core::config::{LayerType, ModelConfig};
11
12use super::*;
13
14/// Stable u64 identity for an adapter, derived from its human NAME (never the
15/// runtime pool slot index, which is reused across swap/rotation). Task #24:
16/// this is the cache-identity key that keeps the KV/prefix cache adapter-correct
17/// so a request reuses ONLY blocks computed under the same adapter.
18///
19/// FNV-1a over the name bytes. `0` is the RESERVED base/no-adapter sentinel, so
20/// a real name that would hash to 0 is bumped to 1 — a real adapter never aliases
21/// base. Two different names never collide (modulo the 64-bit hash); the SAME
22/// adapter re-staged into a different pool slot keeps its name, hence its id.
23///
24/// Task #25 (slot generation): `generation` folds into the identity ONLY when it
25/// is non-zero, so `generation == 0` returns byte-identically to the pre-#25
26/// value (first-load ids and the base sentinel are unchanged — the #24 base
27/// byte-identity pins hold). A re-staged slot bumps its generation, changing the
28/// id so a later request under the SAME name misses the stale prior-generation
29/// prefix/KV. The `if h == 0 { 1 }` base-reserve is re-applied AFTER the fold so
30/// no (name, generation) pair can alias the base sentinel.
31pub fn adapter_id_hash(name: &str, generation: u64) -> u64 {
32    let mut h: u64 = 0xcbf29ce484222325; // FNV-1a basis
33    for &b in name.as_bytes() {
34        h ^= b as u64;
35        h = h.wrapping_mul(0x100000001b3); // FNV-1a prime
36    }
37    // gen 0 = strict no-op → byte-identical to the pre-#25 name-only hash.
38    if generation != 0 {
39        for &b in generation.to_le_bytes().iter() {
40            h ^= b as u64;
41            h = h.wrapping_mul(0x100000001b3);
42        }
43    }
44    if h == 0 { 1 } else { h }
45}
46
47/// Whether `key` names a GDN / linear-attention tensor — the family
48/// `classify_key` rejects outright.
49///
50/// Exists so a caller can SKIP such a tensor under
51/// `ATLAS_LORA_ALLOW_PARTIAL` instead of pattern-matching on the reject
52/// message. The prefix test is the same one `classify_key` uses; keep them
53/// together so the skip can never drift from the reject.
54pub fn is_gdn_key(key: &str) -> bool {
55    // Mirrors classify_key's own walk: PEFT prefix, then `.layers.N.`, then
56    // the module tail it matches `linear_attn.` against. Deliberately the same
57    // sequence of splits so the skip cannot recognise a different set of keys
58    // than the reject does.
59    let Some(stripped) = key.strip_prefix("base_model.model.") else {
60        return false;
61    };
62    let Some((_prefix, rest)) = stripped.split_once(".layers.") else {
63        return false;
64    };
65    let Some((_idx, tail)) = rest.split_once('.') else {
66        return false;
67    };
68    // `linear_attn.out_proj` is SUPPORTED, so it is not skippable — skipping
69    // it would silently drop a delta Atlas can actually apply. Only the
70    // input-side projections, which still have no delta path, are skippable.
71    tail.starts_with("linear_attn.") && tail != "linear_attn.out_proj"
72}
73
74/// PEFT key → (layer, module, A|B). Every unsupported shape is a NAMED
75/// hard rejection — never a skip. Prefix-agnostic on purpose: the Holo
76/// base checkpoint keys are `model.language_model.layers.{i}.*`
77/// (weight_prefix auto-detected server-side), but a PEFT trainer wrapping
78/// the text trunk emits `model.layers.{i}.*`; both carry the layer index
79/// right after ".layers.".
80pub fn classify_key(key: &str, cfg: &ModelConfig) -> Result<(usize, LoraTarget, AdapterAb)> {
81    let stripped = key.strip_prefix("base_model.model.").ok_or_else(|| {
82        anyhow!("REJECT[not-peft-key]: '{key}' lacks the 'base_model.model.' PEFT prefix")
83    })?;
84    if stripped.contains("lora_embedding_") {
85        bail!("REJECT[embedding-lora]: '{key}' — embed_tokens/lm_head LoRA is out of v0 scope");
86    }
87    let (module_path, ab) = if let Some(p) = stripped.strip_suffix(".lora_A.weight") {
88        (p, AdapterAb::A)
89    } else if let Some(p) = stripped.strip_suffix(".lora_B.weight") {
90        (p, AdapterAb::B)
91    } else {
92        bail!(
93            "REJECT[unrecognized-tensor]: '{key}' is not a lora_A/lora_B weight \
94             (modules_to_save exports and old '.lora_A.<adapter>.weight' layouts \
95             are not supported in v0)"
96        );
97    };
98    let (_prefix, rest) = module_path.split_once(".layers.").ok_or_else(|| {
99        anyhow!("REJECT[non-layer-module]: '{key}' targets '{module_path}' outside the layer stack")
100    })?;
101    let (idx_str, tail) = rest
102        .split_once('.')
103        .ok_or_else(|| anyhow!("REJECT[malformed-key]: '{key}'"))?;
104    let layer_idx: usize = idx_str
105        .parse()
106        .map_err(|_| anyhow!("REJECT[malformed-layer-index]: '{key}'"))?;
107    if layer_idx >= cfg.num_hidden_layers {
108        bail!(
109            "REJECT[layer-out-of-range]: '{key}' targets layer {layer_idx} \
110             (model has {})",
111            cfg.num_hidden_layers
112        );
113    }
114    let target = match tail {
115        "self_attn.q_proj" => LoraTarget::Attn(LoraModule::QProj),
116        "self_attn.k_proj" => LoraTarget::Attn(LoraModule::KProj),
117        "self_attn.v_proj" => LoraTarget::Attn(LoraModule::VProj),
118        "self_attn.o_proj" => LoraTarget::Attn(LoraModule::OProj),
119        "mlp.gate_proj" => LoraTarget::Attn(LoraModule::GateProj),
120        "mlp.up_proj" => LoraTarget::Attn(LoraModule::UpProj),
121        "mlp.down_proj" => LoraTarget::Attn(LoraModule::DownProj),
122        // Feature-1: the MoE router (`mlp.gate`, DISTINCT from the dense
123        // `mlp.gate_proj` above — do not confuse the two).
124        "mlp.gate" => LoraTarget::Router,
125        // Feature-1: a routed expert projection `mlp.experts.{N}.{proj}`.
126        // Every unsupported spelling is a NAMED reject (never a silent skip).
127        t if t.starts_with("mlp.experts.") => {
128            classify_expert_tail(key, &t["mlp.experts.".len()..], cfg)?
129        }
130        // The GDN block's OUTPUT projection is supported. It is the block's
131        // last stage (value_dim -> hidden), downstream of the recurrence: a
132        // delta there is an ordinary per-token linear delta on the block's
133        // output and never enters the state update. That is why it does not
134        // need the exact-replay parity harness the rest of this family waits
135        // on — `in_proj_*` and `conv1d` DO feed the recurrence, so an error in
136        // them compounds across timesteps, and they stay rejected.
137        "linear_attn.out_proj" => LoraTarget::Attn(LoraModule::OutProj),
138        t if t.starts_with("linear_attn.") => bail!(
139            "REJECT[gdn-target]: '{key}' — GDN/linear-attention INPUT-side \
140             projections (in_proj_qkv / in_proj_z / in_proj_a / in_proj_b / \
141             conv1d) feed the recurrence and stay rejected until an \
142             exact-replay parity harness exists. `out_proj` IS supported."
143        ),
144        other => bail!("REJECT[unsupported-module]: '{key}' targets '{other}'"),
145    };
146    // The full-attention gate applies ONLY to attention + dense-mlp targets:
147    // those projections exist per-attention-layer and the v0 delta path is wired
148    // only on full-attention layers. MoE router (`mlp.gate`) and routed experts
149    // (`mlp.experts.N.*`) live on EVERY MoE layer — including the GDN /
150    // linear-attention layers — so a real Qwen3.6/Holo MoE adapter targets them
151    // there too; gating those to full-attention would wrongly reject the majority
152    // of a MoE adapter's layers. The fold runs in `MoeLayer::forward_*`, which is
153    // present on all MoE layers, so no layer-type restriction applies to them.
154    match target {
155        // Dense FFN on a dense-FFN model: allowed on ANY layer. The SwiGLU FFN
156        // is present on every layer of a hybrid — Qwen3.8-27B has 16
157        // full-attention and 48 linear-attention layers and all 64 carry an
158        // FFN, which is why real adapters for it ship 128 dense-mlp tensors
159        // (64 layers x A/B). Restricting these to full-attention layers
160        // rejected three quarters of such an adapter and the old message could
161        // only suggest retraining with `layers_to_transform`.
162        //
163        // `Qwen3SsmLayer` holds its FFN as `FfnComponent::Dense`, the same
164        // `DenseFfnLayer` the full-attention layers use and the same one the
165        // M1 delta path is wired into, so the fold runs identically there.
166        //
167        // Restricted to `num_experts == 0`: on a MoE model `mlp.*` on a GDN
168        // layer belongs to the routed-expert path (LoraTarget::Expert), which
169        // has its own handling below, not to a dense FFN.
170        LoraTarget::Attn(m) if m.is_dense_ffn() && cfg.num_experts == 0 => {}
171        // GDN out_proj: linear-attention layers only — a full-attention layer
172        // has no GDN block for it to land on.
173        LoraTarget::Attn(m)
174            if m.is_gdn_out() && cfg.layer_type(layer_idx) == LayerType::FullAttention =>
175        {
176            bail!(
177                "REJECT[gdn-out-on-attention-layer]: '{key}' targets layer \
178                 {layer_idx}, which is a full-attention layer with no GDN out_proj"
179            )
180        }
181        LoraTarget::Attn(m) if m.is_gdn_out() => {}
182        LoraTarget::Attn(_) => match cfg.layer_type(layer_idx) {
183            LayerType::FullAttention => {}
184            lt => bail!(
185                "REJECT[non-full-attention-layer]: '{key}' targets layer {layer_idx} \
186                 ({lt:?}); attention-projection LoRA applies only on the full-attention \
187                 layers {:?}",
188                full_attention_layers(cfg),
189            ),
190        },
191        LoraTarget::Router | LoraTarget::Expert { .. } => {}
192    }
193    Ok((layer_idx, target, ab))
194}
195
196/// Parse the `{N}.{proj}` remainder of a `mlp.experts.` tail into a routed-expert
197/// [`LoraTarget`]. `rest` is e.g. `"7.gate_proj"`. Named rejects for the fused /
198/// unindexed layout (`target_parameters` on a real Holo/Qwen3.6 export — Feature-1
199/// phase 3), a dense (`num_experts == 0`) model, an out-of-range expert, and any
200/// unknown projection.
201fn classify_expert_tail(key: &str, rest: &str, cfg: &ModelConfig) -> Result<LoraTarget> {
202    if cfg.num_experts == 0 {
203        bail!(
204            "REJECT[expert-lora-on-dense-model]: '{key}' targets a routed expert but \
205             the model has num_experts=0 (dense) — use mlp.{{gate,up,down}}_proj instead"
206        );
207    }
208    let (n_str, proj_str) = rest.split_once('.').ok_or_else(|| {
209        anyhow!(
210            "REJECT[fused-expert-lora]: '{key}' — fused/unindexed expert layout \
211             (e.g. experts.gate_up_proj via target_parameters) is deferred to \
212             Feature-1 phase 3; export per-expert mlp.experts.{{N}}.{{proj}} tensors"
213        )
214    })?;
215    let n: usize = n_str.parse().map_err(|_| {
216        anyhow!("REJECT[malformed-expert-index]: '{key}' — '{n_str}' is not an index")
217    })?;
218    if n >= cfg.num_experts {
219        bail!(
220            "REJECT[expert-out-of-range]: '{key}' targets expert {n} \
221             (model has {} experts)",
222            cfg.num_experts
223        );
224    }
225    let proj = match proj_str {
226        "gate_proj" => ExpertProj::Gate,
227        "up_proj" => ExpertProj::Up,
228        "down_proj" => ExpertProj::Down,
229        "gate_up_proj" => bail!(
230            "REJECT[fused-expert-lora]: '{key}' — fused gate_up_proj is deferred to \
231             Feature-1 phase 3 (needs a per-expert decomposer)"
232        ),
233        other => bail!("REJECT[unsupported-expert-proj]: '{key}' proj '{other}'"),
234    };
235    Ok(LoraTarget::Expert { n: n as u16, proj })
236}
237
238#[cfg(test)]
239#[path = "key_tests.rs"]
240mod tests;