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;