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;