atlas_core/config/parsers/lora.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! PEFT `adapter_config.json` parser for runtime LoRA adapters.
4//!
5//! Split out of `config.rs` for file-size budget, mirroring
6//! [`super::quantization`]. Unlike that parser (which returns `Option` so
7//! callers fall through to tensor-name heuristics), this one is **hard-fail**:
8//! the adapter is explicitly requested via `--lora-adapter`, so anything
9//! Atlas cannot faithfully apply must error with a named reason — never be
10//! silently skipped (wrong output).
11//!
12//! NAMING DISCIPLINE: everything here is `peft_*` / `adapter_*`.
13//! `kv_lora_rank` / `q_lora_rank` / `o_lora_rank` (`config.rs:182-207`) are
14//! MLA low-rank *attention compression*, unrelated to adapters — never reuse
15//! those names.
16
17use anyhow::{Context, Result, bail};
18use serde::Deserialize;
19
20/// v0 target-module allow-list. Deltas apply on full-attention layers
21/// (holo-3.1-0.8b: layer indices 3,7,11,15,19,23) plus the dense SwiGLU FFN.
22/// `q_proj` IS supported: on `attn_output_gate=true` models the raw projection
23/// emits the interleaved `[Q|gate]` at width `2·q_heads·head_dim` — the FULL
24/// width the PEFT `lora_B` was trained against (verified `[8192,16]` on
25/// holo-3.1-35b), so the delta folds onto the raw interleaved basis exactly
26/// like k/v/o (the deinterleave is deferred past the fold). GDN/linear-attention
27/// modules stay rejected (no exact-replay parity harness for the recurrence yet).
28pub const PEFT_SUPPORTED_TARGET_MODULES: &[&str] = &[
29 "q_proj",
30 "k_proj",
31 "v_proj",
32 "o_proj",
33 "gate_proj",
34 "up_proj",
35 "down_proj",
36 // GDN / linear-attention block output projection (value_dim -> hidden).
37 "out_proj",
38];
39
40/// Parsed subset of a PEFT `adapter_config.json` that Atlas consumes.
41///
42/// `lora_dropout` is intentionally ignored (train-time only, inference
43/// no-op). Everything else PEFT can emit that would change inference
44/// output is validated in [`parse_peft_adapter_config`] and rejected by
45/// name if unsupported.
46#[derive(Debug, Clone)]
47pub struct PeftAdapterConfig {
48 /// LoRA rank. Must be > 0.
49 pub r: usize,
50 /// LoRA alpha. PEFT serializes int or float; both accepted.
51 pub lora_alpha: f64,
52 /// Verbatim `target_modules` entries (bare module names, or full paths
53 /// which are validated on their final `.`-segment). The weight loader's
54 /// bidirectional audit is the authority on actual per-layer matching.
55 pub target_modules: Vec<String>,
56 /// PEFT's REGEX form of `target_modules` (a JSON string rather than a
57 /// list) — e.g. Dxniz/Novelist1.0-27b-Adapter, whose pattern matches
58 /// `q|k|v|o_proj` and `gate|up|down_proj` under several possible
59 /// parent-module spellings.
60 ///
61 /// Held verbatim and NOT expanded: expanding it would mean resolving a
62 /// Python-flavoured regex against a module tree this layer cannot see.
63 /// It does not need to be. The adapter's own TENSOR NAMES are ground
64 /// truth for what it targets, and every one of them still goes through
65 /// `classify_key`, which is strictly stricter than the name-level
66 /// allow-list a pattern bypasses. A pattern therefore DEFERS module
67 /// validation to the per-tensor gate rather than skipping it.
68 pub target_modules_pattern: Option<String>,
69 /// rsLoRA flag: switches scaling from `alpha/r` to `alpha/sqrt(r)`.
70 /// Hard-required in the on-disk config (never defaulted — a wrong scale
71 /// is silent quality loss).
72 pub use_rslora: bool,
73 /// Informational: the `layers_to_transform` restriction if present.
74 /// The weight loader's per-`LayerType` gate is the real authority on
75 /// which layers receive deltas; this is kept only for the startup log.
76 pub layers_to_transform: Option<Vec<usize>>,
77 /// Vocab-extension / trainable-token ids for the token overlay
78 /// (Feature 2). Unique ascending order from the config's
79 /// `trainable_token_indices` list, or the common order declared for
80 /// `embed_tokens` and `lm_head`. Empty ⇒ no `trainable_tokens` overlay.
81 pub trainable_token_indices: Vec<u32>,
82 /// Accepted `modules_to_save` leaves — the subset Atlas can apply as a
83 /// token overlay (`embed_tokens` / `lm_head` full-row replacement).
84 /// Anything else is still a hard `REJECT(modules_to_save)`. Empty ⇒
85 /// no full-module overlay.
86 pub modules_to_save: Vec<String>,
87 /// Classic low-rank embedding LoRA (`lora_embedding_A/B`) present.
88 /// Tier-2: parse-accepted here so the adapter is not silently dropped,
89 /// but the loader rejects it until the embedding-LoRA kernel lands.
90 /// Reserved — always `false` today (detection is at the tensor level).
91 pub lora_embedding: bool,
92}
93
94impl PeftAdapterConfig {
95 /// Delta scale applied at merge: `y += scaling() * (x @ Aᵀ) @ Bᵀ`.
96 ///
97 /// `alpha/r`, or `alpha/sqrt(r)` when `use_rslora` — read from the
98 /// adapter's own config, NEVER defaulted (a wrong scale is silent
99 /// quality loss, not an error).
100 pub fn scaling(&self) -> f32 {
101 debug_assert!(self.r > 0, "validated at parse");
102 if self.use_rslora {
103 (self.lora_alpha / (self.r as f64).sqrt()) as f32
104 } else {
105 (self.lora_alpha / self.r as f64) as f32
106 }
107 }
108}
109
110/// Raw deserialization target mirroring PEFT's on-disk field names verbatim
111/// (same approach as `DflashConfig`, `dflash_loader.rs:40`). No
112/// `deny_unknown_fields`: PEFT emits many irrelevant keys (`task_type`,
113/// `revision`, `loftq_config`, `lora_dropout`, ...).
114#[derive(Deserialize)]
115struct RawPeftAdapterConfig {
116 /// "LORA" for LoRA adapters; ADALORA/LOHA/LOKR/IA3 etc. rejected.
117 #[serde(default)]
118 peft_type: Option<String>,
119 r: usize,
120 lora_alpha: f64,
121 /// Array of strings, or the string "all-linear" (rejected — Atlas
122 /// cannot enumerate "all linear" against fused/quantized layouts).
123 /// Absent/null is tolerated for pure token-overlay adapters.
124 #[serde(default)]
125 target_modules: serde_json::Value,
126 /// Hard-required: scaling inputs are never defaulted. `None` (field
127 /// absent) is a REJECT, not a `false` default.
128 #[serde(default)]
129 use_rslora: Option<bool>,
130 #[serde(default)]
131 use_dora: bool,
132 /// "none" (default) is the only supported value.
133 #[serde(default)]
134 bias: Option<String>,
135 #[serde(default)]
136 rank_pattern: Option<serde_json::Map<String, serde_json::Value>>,
137 #[serde(default)]
138 alpha_pattern: Option<serde_json::Map<String, serde_json::Value>>,
139 /// Full (non-low-rank) modules saved alongside the adapter. The
140 /// `{embed_tokens, lm_head}` subset is now accepted as a token overlay
141 /// (Feature 2); any other leaf stays a hard reject.
142 #[serde(default)]
143 modules_to_save: Option<Vec<String>>,
144 /// Layer-subset restriction. ACCEPTED (array form) and kept for logging;
145 /// the loader's per-`LayerType` gate is the authority. A non-null,
146 /// non-array form is rejected as malformed.
147 #[serde(default)]
148 layers_to_transform: Option<serde_json::Value>,
149 /// PEFT `trainable_token_indices` — vocab ids whose embed/lm_head rows the
150 /// adapter fully replaces. Emitted as a bare list `[id, …]` OR a per-module
151 /// dict `{"embed_tokens":[…], "lm_head":[…]}`. Parsed by
152 /// [`parse_trainable_tokens`] into one shared unique ascending `Vec<u32>`.
153 #[serde(default)]
154 trainable_token_indices: Option<serde_json::Value>,
155 /// PEFT `target_parameters` — LoRA attached to fused `nn.Parameter`
156 /// tensors (routed MoE experts on Holo/Qwen3.6). Deferred to Feature 1
157 /// phase 3; a non-empty value is a NAMED reject (never silently dropped,
158 /// which the lack of `deny_unknown_fields` would otherwise do).
159 #[serde(default)]
160 target_parameters: Option<Vec<String>>,
161}
162
163/// Parse a PEFT `adapter_config.json` payload.
164///
165/// Hard-fails with a `REJECT(<feature>)`-prefixed message on every PEFT
166/// feature v0 does not support. The caller supplies file-path context.
167pub fn parse_peft_adapter_config(json: &str) -> Result<PeftAdapterConfig> {
168 let raw: RawPeftAdapterConfig = serde_json::from_str(json)
169 .context("Parsing PEFT adapter_config.json (r / lora_alpha / target_modules required)")?;
170
171 if let Some(ref pt) = raw.peft_type
172 && !pt.eq_ignore_ascii_case("LORA")
173 {
174 bail!("REJECT(peft_type): adapter declares peft_type='{pt}'; only LORA is supported");
175 }
176 if raw.use_dora {
177 bail!(
178 "REJECT(use_dora): DoRA adapters are unsupported (magnitude decomposition has no runtime-delta form)"
179 );
180 }
181 if let Some(ref b) = raw.bias
182 && b != "none"
183 {
184 bail!("REJECT(bias): bias='{b}' ships trained bias deltas; only bias='none' is supported");
185 }
186 if raw.rank_pattern.as_ref().is_some_and(|m| !m.is_empty()) {
187 bail!(
188 "REJECT(rank_pattern): per-module rank overrides are unsupported in v0 (uniform r only)"
189 );
190 }
191 if raw.alpha_pattern.as_ref().is_some_and(|m| !m.is_empty()) {
192 bail!(
193 "REJECT(alpha_pattern): per-module alpha overrides are unsupported in v0 (uniform lora_alpha only)"
194 );
195 }
196 // `modules_to_save`: partition by leaf. `{embed_tokens, lm_head}` are a
197 // token overlay (Feature 2) and accepted; everything else stays a hard
198 // reject (full-weight replacement of arbitrary modules is unsupported).
199 let modules_to_save = partition_modules_to_save(raw.modules_to_save.as_deref())?;
200
201 // `target_parameters` (fused expert LoRA) is deferred — never silently
202 // dropped (no `deny_unknown_fields` would otherwise swallow it).
203 if raw
204 .target_parameters
205 .as_ref()
206 .is_some_and(|v| !v.is_empty())
207 {
208 bail!(
209 "REJECT(target_parameters): fused-parameter LoRA {:?} (routed MoE experts) \
210 is deferred to Feature 1 phase 3",
211 raw.target_parameters.as_deref().unwrap_or_default()
212 );
213 }
214
215 // rsLoRA flag is a scaling input — never defaulted (locked decision).
216 let use_rslora = raw.use_rslora.ok_or_else(|| {
217 anyhow::anyhow!(
218 "REJECT(use_rslora): field absent — scaling inputs are never defaulted \
219 (PEFT <0.7 config; re-export the adapter with peft>=0.7)"
220 )
221 })?;
222
223 // layers_to_transform: accept the array form (kept for logging), reject a
224 // malformed non-array form. The loader's per-LayerType gate is authority.
225 let layers_to_transform = parse_layers_to_transform(&raw.layers_to_transform)?;
226
227 if raw.r == 0 {
228 bail!("REJECT(r): LoRA rank must be > 0");
229 }
230 if !(raw.lora_alpha.is_finite() && raw.lora_alpha > 0.0) {
231 bail!(
232 "REJECT(lora_alpha): must be a finite positive number, got {}",
233 raw.lora_alpha
234 );
235 }
236
237 let trainable_token_indices = parse_trainable_tokens(&raw.trainable_token_indices)?;
238
239 let (target_modules, target_modules_pattern) = parse_target_modules(&raw.target_modules)?;
240 for entry in &target_modules {
241 validate_target_module(entry)?;
242 }
243
244 // A pure-overlay adapter (only `trainable_tokens` / `modules_to_save`)
245 // legitimately targets no LoRA module. Otherwise an empty `target_modules`
246 // means the adapter applies nothing at all.
247 let has_overlay = !trainable_token_indices.is_empty() || !modules_to_save.is_empty();
248 if target_modules.is_empty() && target_modules_pattern.is_none() && !has_overlay {
249 bail!("REJECT(target_modules): empty list — adapter targets nothing");
250 }
251
252 Ok(PeftAdapterConfig {
253 r: raw.r,
254 lora_alpha: raw.lora_alpha,
255 target_modules,
256 target_modules_pattern,
257 use_rslora,
258 layers_to_transform,
259 trainable_token_indices,
260 modules_to_save,
261 lora_embedding: false,
262 })
263}
264
265/// Partition `modules_to_save` into the accepted token-overlay subset
266/// (`embed_tokens` / `lm_head`, matched on the leaf `.`-segment) and reject
267/// anything else by name — full-weight replacement of arbitrary modules is
268/// unsupported. Returns the accepted leaves.
269fn partition_modules_to_save(mods: Option<&[String]>) -> Result<Vec<String>> {
270 let Some(mods) = mods else {
271 return Ok(Vec::new());
272 };
273 let mut accepted = Vec::new();
274 for m in mods {
275 let leaf = m.rsplit('.').next().unwrap_or(m);
276 match leaf {
277 "embed_tokens" | "lm_head" => accepted.push(leaf.to_string()),
278 other => bail!(
279 "REJECT(modules_to_save): adapter saves full module '{other}'; only the \
280 token-overlay subset {{embed_tokens, lm_head}} is supported"
281 ),
282 }
283 }
284 accepted.sort();
285 accepted.dedup();
286 Ok(accepted)
287}
288
289/// Parse PEFT `trainable_token_indices` into one unique ascending `Vec<u32>` shared by
290/// the embed and lm-head overlay paths.
291///
292/// Accepts three on-disk forms: absent/null ⇒ empty; a bare list `[id, …]`;
293/// or a per-module dict `{"embed_tokens":[…], "lm_head":[…]}` whose non-null
294/// lists must be identical. Negative, duplicate, unknown-module, and differing
295/// per-module entries are named rejects.
296fn parse_trainable_tokens(v: &Option<serde_json::Value>) -> Result<Vec<u32>> {
297 fn parse_ids(arr: &[serde_json::Value]) -> Result<Vec<u32>> {
298 let mut ids = Vec::with_capacity(arr.len());
299 let mut seen = std::collections::HashSet::new();
300 let mut previous: Option<u32> = None;
301 for e in arr {
302 let n = e.as_u64().context(
303 "REJECT(trainable_token_indices): entries must be non-negative integers",
304 )?;
305 if n > u32::MAX as u64 {
306 bail!("REJECT(trainable_token_indices): id {n} exceeds u32 range");
307 }
308 let id = n as u32;
309 if !seen.insert(id) {
310 bail!("REJECT(trainable_token_indices): duplicate id {id}");
311 }
312 if let Some(prev) = previous
313 && id < prev
314 {
315 bail!(
316 "REJECT(trainable_token_indices): ids must be ascending to preserve \
317 trainable_tokens_delta row order; {id} follows {prev}"
318 );
319 }
320 previous = Some(id);
321 ids.push(id);
322 }
323 Ok(ids)
324 }
325
326 match v {
327 None | Some(serde_json::Value::Null) => Ok(Vec::new()),
328 Some(serde_json::Value::Array(arr)) => parse_ids(arr),
329 Some(serde_json::Value::Object(map)) => {
330 let mut shared: Option<Vec<u32>> = None;
331 for (module, val) in map {
332 if module != "embed_tokens" && module != "lm_head" {
333 bail!(
334 "REJECT(trainable_token_indices): unsupported module '{module}'; only \
335 embed_tokens and lm_head are supported"
336 );
337 }
338 let module_ids = match val {
339 serde_json::Value::Array(arr) => parse_ids(arr)?,
340 serde_json::Value::Null => continue,
341 other => bail!(
342 "REJECT(trainable_token_indices): dict value must be an array, got {other}"
343 ),
344 };
345 if let Some(ref expected) = shared
346 && expected != &module_ids
347 {
348 bail!(
349 "REJECT(trainable_token_indices): per-module token lists differ; \
350 Atlas requires one shared embed_tokens/lm_head order"
351 );
352 }
353 shared = Some(module_ids);
354 }
355 Ok(shared.unwrap_or_default())
356 }
357 Some(other) => bail!(
358 "REJECT(trainable_token_indices): expected null, an array, or a per-module \
359 object, got {other}"
360 ),
361 }
362}
363
364fn parse_layers_to_transform(v: &Option<serde_json::Value>) -> Result<Option<Vec<usize>>> {
365 match v {
366 None | Some(serde_json::Value::Null) => Ok(None),
367 Some(serde_json::Value::Array(arr)) => {
368 let layers = arr
369 .iter()
370 .map(|e| {
371 e.as_u64().map(|n| n as usize).context(
372 "REJECT(layers_to_transform): entries must be non-negative integers",
373 )
374 })
375 .collect::<Result<Vec<_>>>()?;
376 Ok(Some(layers))
377 }
378 Some(other) => bail!(
379 "REJECT(layers_to_transform): expected null or an array of layer indices, got {other}"
380 ),
381 }
382}
383
384fn parse_target_modules(v: &serde_json::Value) -> Result<(Vec<String>, Option<String>)> {
385 match v {
386 // PEFT's regex form. Accepted as an opaque pattern (see
387 // `PeftAdapterConfig::target_modules_pattern`): this widens what
388 // PARSES, not what APPLIES, because the per-tensor `classify_key`
389 // gate still decides what loads. `all-linear` lands here too and is
390 // equally safe — its GDN tensors meet the same per-tensor reject they
391 // always did.
392 // `all-linear` is a PEFT KEYWORD, not a regex: it means "every linear
393 // layer", which Atlas cannot enumerate against fused/quantized layouts
394 // (a fused qkv or a packed MoE expert is one tensor here and several
395 // `nn.Linear`s there). It stays a named reject, as it always was.
396 serde_json::Value::String(s) if s == "all-linear" => bail!(
397 "REJECT(target_modules): string form '{s}' is unsupported — Atlas cannot \
398 enumerate 'all linear' against fused/quantized layouts; re-export the \
399 adapter with an explicit module list or a regex"
400 ),
401 // Any other string is PEFT's regex form — kept verbatim, never
402 // expanded. See `PeftAdapterConfig::target_modules_pattern`: the
403 // per-tensor `classify_key` gate remains the authority on what loads,
404 // so this widens what PARSES, not what APPLIES.
405 serde_json::Value::String(s) => Ok((Vec::new(), Some(s.clone()))),
406 serde_json::Value::Array(arr) => {
407 let mods: Vec<String> = arr
408 .iter()
409 .map(|e| {
410 e.as_str()
411 .map(str::to_string)
412 .context("REJECT(target_modules): entries must be strings")
413 })
414 .collect::<Result<_>>()?;
415 // Emptiness is judged by the caller: a pure token-overlay adapter
416 // (only `trainable_tokens` / `modules_to_save`) legitimately lists
417 // no LoRA target module.
418 Ok((mods, None))
419 }
420 // Absent / null `target_modules` is legal for a pure token-overlay
421 // adapter; the caller enforces "targets nothing" against overlay
422 // presence.
423 serde_json::Value::Null => Ok((Vec::new(), None)),
424 other => bail!("REJECT(target_modules): expected an array of module names, got {other}"),
425 }
426}
427
428/// Per-module-name allow-list gate. PEFT entries may be bare names
429/// (`"k_proj"`) or full paths (`"model.layers.3.self_attn.k_proj"`); both
430/// validate on the final `.`-segment. Per-`LayerType` enforcement (deltas
431/// land on full-attention layers only) is the weight loader's job — this is
432/// the name-level gate.
433/// `ATLAS_LORA_ALLOW_PARTIAL=1` — load an adapter naming target modules Atlas
434/// cannot apply, skipping those and applying the rest.
435///
436/// THE canonical definition; `spark_model::lora::env::allow_partial_targets`
437/// delegates here. It has to live this low because the FIRST gate an adapter
438/// meets is this parse-time allow-list, below the layer that owns the LoRA
439/// runtime — a copy up there alone would be consulted only after this one had
440/// already refused the load.
441///
442/// Default OFF and it must stay OFF: a partially-applied adapter is a model
443/// that quietly does not do what its author trained it to do, and nothing
444/// downstream can tell that apart from the adapter simply being bad.
445pub fn allow_partial_targets() -> bool {
446 static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
447 *V.get_or_init(|| {
448 std::env::var("ATLAS_LORA_ALLOW_PARTIAL")
449 .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
450 })
451}
452
453fn validate_target_module(entry: &str) -> Result<()> {
454 let leaf = entry.rsplit('.').next().unwrap_or(entry);
455 match leaf {
456 // GDN / linear-attention projections — reject both the fused
457 // (`in_proj_qkvz`/`in_proj_ba`) and split (`in_proj_qkv`/`in_proj_z`/
458 // `in_proj_a`/`in_proj_b`) spellings, plus `out_proj`/`conv1d`.
459 // `out_proj` is NO LONGER here: the GDN block's output projection is
460 // supported (it is downstream of the recurrence, so it needs no
461 // exact-replay parity harness). The remaining names all feed the
462 // recurrence, where an error compounds across timesteps.
463 "in_proj_qkvz" | "in_proj_ba" | "in_proj_qkv" | "in_proj_z" | "in_proj_a" | "in_proj_b"
464 | "conv1d"
465 if !allow_partial_targets() =>
466 {
467 bail!(
468 "REJECT(gdn): target module '{leaf}' is a GDN/linear-attention projection; GDN \
469 layers are unsupported in v0 (full-attention layers only). Set \
470 ATLAS_LORA_ALLOW_PARTIAL=1 to load the adapter anyway, applying only its \
471 supported modules — it will then be PARTIALLY applied."
472 )
473 }
474 "in_proj_qkvz" | "in_proj_ba" | "in_proj_qkv" | "in_proj_z" | "in_proj_a" | "in_proj_b"
475 | "conv1d" => Ok(()),
476 "embed_tokens" | "lm_head" => {
477 bail!("REJECT(embedding): target module '{leaf}' is unsupported in v0")
478 }
479 // Feature-1: the MoE router (`mlp.gate`, leaf `gate` — DISTINCT from the
480 // dense `gate_proj`). Expert projections (`mlp.experts.N.gate_proj`) share
481 // the dense leaves already in the allow-list. The runtime key gate
482 // (`classify_key`) + `ATLAS_LORA_EXPERTS` are the authority on whether the
483 // routed-expert / router path is actually loaded.
484 "gate" => Ok(()),
485 m if PEFT_SUPPORTED_TARGET_MODULES.contains(&m) => Ok(()),
486 other => bail!(
487 "REJECT(unknown_module): target module '{other}' is not in the v0 allow-list \
488 {PEFT_SUPPORTED_TARGET_MODULES:?}"
489 ),
490 }
491}
492
493#[cfg(test)]
494#[path = "lora_tests.rs"]
495mod tests;