metrale_config/parsers/
lora.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-26: Parser for a PEFT `adapter_config.json` (runtime LoRA adapters).
4//!
5//! Unlike `parse_quantization_config`, which returns `Option`, this parser returns an error
6//! with a `REJECT(<field>)` reason for every setting it cannot apply, so a requested adapter
7//! is never partly skipped without notice.
8//!
9//! The `ModelConfig` fields `kv_lora_rank`, `q_lora_rank` and `o_lora_rank` are MLA
10//! attention compression ranks, unrelated to adapters; names here use `peft_` / `adapter_`.
11//!
12//! Owner: config (LoRA adapters).
13//! Invariants:
14//! - A parsed `PeftAdapterConfig` has `r > 0`, a finite positive `lora_alpha`, an explicit
15//!   `use_rslora`, and at least one target module, target pattern or token overlay.
16
17use anyhow::{Context, Result, bail};
18use serde::Deserialize;
19
20/// 2026-09-26: The module names (last `.`-segment of a `target_modules` entry) this parser
21/// accepts; `validate_target_module` also accepts the MoE router leaf `gate`. On a
22/// gated-attention model `q_proj` is the whole Q-and-gate projection,
23/// `2 * num_attention_heads * head_dim` wide (`metrale_model_layers` `LoraModule::dims`).
24/// Which layers a module may target is decided per tensor by `classify_key` in the same
25/// crate.
26pub const PEFT_SUPPORTED_TARGET_MODULES: &[&str] = &[
27    "q_proj",
28    "k_proj",
29    "v_proj",
30    "o_proj",
31    "gate_proj",
32    "up_proj",
33    "down_proj",
34    // 2026-09-26: The GDN / linear-attention block's output projection (value_dim -> hidden).
35    "out_proj",
36];
37
38/// 2026-09-26: The fields of a PEFT `adapter_config.json` that the engine uses, as validated
39/// by [`parse_peft_adapter_config`]. `lora_dropout`, a training-only setting, is ignored.
40#[derive(Debug, Clone)]
41pub struct PeftAdapterConfig {
42    /// 2026-09-26: LoRA rank, greater than 0.
43    pub r: usize,
44    /// 2026-09-26: LoRA alpha, finite and positive; the JSON may hold an integer or a float.
45    pub lora_alpha: f64,
46    /// 2026-09-26: The `target_modules` list as written: bare module names or full paths, each
47    /// validated on its last `.`-segment. Per-layer matching is the weight loader's.
48    pub target_modules: Vec<String>,
49    /// 2026-09-26: PEFT's regex form of `target_modules` (a JSON string other than
50    /// `all-linear`), kept as written and not expanded. It skips the name-level allow-list,
51    /// but every adapter tensor is still checked by `classify_key`, which accepts only the
52    /// projections it names.
53    pub target_modules_pattern: Option<String>,
54    /// 2026-09-26: rsLoRA: scaling is `alpha/sqrt(r)` instead of `alpha/r`. The config must
55    /// state it; an absent key is rejected rather than read as false.
56    pub use_rslora: bool,
57    /// 2026-09-26: The `layers_to_transform` list, if present. Nothing reads it; the weight
58    /// loader decides which layers receive deltas.
59    pub layers_to_transform: Option<Vec<usize>>,
60    /// 2026-09-26: Token ids for the trainable-token overlay, unique and ascending, from the
61    /// `trainable_token_indices` list or the list shared by its `embed_tokens` and `lm_head`
62    /// entries. Empty means no overlay.
63    pub trainable_token_indices: Vec<u32>,
64    /// 2026-09-26: The accepted `modules_to_save` leaves, `embed_tokens` and `lm_head`, sorted
65    /// and deduplicated. Any other leaf is a `REJECT(modules_to_save)`. Empty means no
66    /// full-module overlay.
67    pub modules_to_save: Vec<String>,
68    /// 2026-09-26: Always `false` from this parser. Low-rank embedding LoRA
69    /// (`lora_embedding_A/B`) is detected from tensor names by the overlay loader, which
70    /// rejects it.
71    pub lora_embedding: bool,
72}
73
74impl PeftAdapterConfig {
75    /// 2026-09-26: The delta scale, `y += scaling() * (x @ Aᵀ) @ Bᵀ`: `alpha/r`, or
76    /// `alpha/sqrt(r)` with `use_rslora`.
77    pub fn scaling(&self) -> f32 {
78        debug_assert!(self.r > 0, "validated at parse");
79        if self.use_rslora {
80            (self.lora_alpha / (self.r as f64).sqrt()) as f32
81        } else {
82            (self.lora_alpha / self.r as f64) as f32
83        }
84    }
85}
86
87/// 2026-09-26: Deserialization target with PEFT's field names. Unknown keys are ignored
88/// (no `deny_unknown_fields`), so any key that changes inference must be named here to be
89/// checked.
90#[derive(Deserialize)]
91struct RawPeftAdapterConfig {
92    /// 2026-09-26: Absent or `LORA` (any case); anything else is rejected.
93    #[serde(default)]
94    peft_type: Option<String>,
95    r: usize,
96    lora_alpha: f64,
97    /// 2026-09-26: A list of module names, or a string: `all-linear` is rejected and any other
98    /// string is a regex pattern. Absent or null is accepted for a token-overlay adapter.
99    #[serde(default)]
100    target_modules: serde_json::Value,
101    /// 2026-09-26: `None` (absent) is rejected, not read as false.
102    #[serde(default)]
103    use_rslora: Option<bool>,
104    #[serde(default)]
105    use_dora: bool,
106    /// 2026-09-26: Absent or `none`; any other value is rejected.
107    #[serde(default)]
108    bias: Option<String>,
109    #[serde(default)]
110    rank_pattern: Option<serde_json::Map<String, serde_json::Value>>,
111    #[serde(default)]
112    alpha_pattern: Option<serde_json::Map<String, serde_json::Value>>,
113    /// 2026-09-26: Full (not low-rank) modules saved with the adapter. The leaves
114    /// `embed_tokens` and `lm_head` are accepted as a token overlay; any other is rejected.
115    #[serde(default)]
116    modules_to_save: Option<Vec<String>>,
117    /// 2026-09-26: Layer subset. Null or an array of non-negative integers is accepted;
118    /// anything else is rejected.
119    #[serde(default)]
120    layers_to_transform: Option<serde_json::Value>,
121    /// 2026-09-26: Token ids whose `embed_tokens` / `lm_head` rows the adapter replaces, as a
122    /// list or as a per-module object; [`parse_trainable_tokens`] reads both forms.
123    #[serde(default)]
124    trainable_token_indices: Option<serde_json::Value>,
125    /// 2026-09-26: LoRA on fused parameter tensors (routed MoE experts). A non-empty value is
126    /// rejected; without this field it would be ignored as an unknown key.
127    #[serde(default)]
128    target_parameters: Option<Vec<String>>,
129}
130
131/// 2026-09-26: Parse a PEFT `adapter_config.json` payload.
132///
133/// Errors with a `REJECT(<field>)` message for each unsupported setting: a non-LoRA
134/// `peft_type`, DoRA, a bias other than `none`, non-empty `rank_pattern` / `alpha_pattern` /
135/// `target_parameters`, a `modules_to_save` leaf other than `embed_tokens` / `lm_head`, an
136/// absent `use_rslora`, `r == 0`, a non-positive `lora_alpha`, a malformed
137/// `layers_to_transform` or `trainable_token_indices`, a rejected target module, and an
138/// adapter that targets nothing. The caller adds the file path to the error.
139pub fn parse_peft_adapter_config(json: &str) -> Result<PeftAdapterConfig> {
140    let raw: RawPeftAdapterConfig = serde_json::from_str(json)
141        .context("Parsing PEFT adapter_config.json (r / lora_alpha / target_modules required)")?;
142
143    if let Some(ref pt) = raw.peft_type
144        && !pt.eq_ignore_ascii_case("LORA")
145    {
146        bail!("REJECT(peft_type): adapter declares peft_type='{pt}'; only LORA is supported");
147    }
148    if raw.use_dora {
149        bail!(
150            "REJECT(use_dora): DoRA adapters are unsupported (magnitude decomposition has no runtime-delta form)"
151        );
152    }
153    if let Some(ref b) = raw.bias
154        && b != "none"
155    {
156        bail!("REJECT(bias): bias='{b}' ships trained bias deltas; only bias='none' is supported");
157    }
158    if raw.rank_pattern.as_ref().is_some_and(|m| !m.is_empty()) {
159        bail!(
160            "REJECT(rank_pattern): per-module rank overrides are unsupported in v0 (uniform r only)"
161        );
162    }
163    if raw.alpha_pattern.as_ref().is_some_and(|m| !m.is_empty()) {
164        bail!(
165            "REJECT(alpha_pattern): per-module alpha overrides are unsupported in v0 (uniform lora_alpha only)"
166        );
167    }
168    let modules_to_save = partition_modules_to_save(raw.modules_to_save.as_deref())?;
169
170    if raw
171        .target_parameters
172        .as_ref()
173        .is_some_and(|v| !v.is_empty())
174    {
175        bail!(
176            "REJECT(target_parameters): fused-parameter LoRA {:?} (routed MoE experts) \
177             is deferred to Feature 1 phase 3",
178            raw.target_parameters.as_deref().unwrap_or_default()
179        );
180    }
181
182    let use_rslora = raw.use_rslora.ok_or_else(|| {
183        anyhow::anyhow!(
184            "REJECT(use_rslora): field absent — scaling inputs are never defaulted \
185             (PEFT <0.7 config; re-export the adapter with peft>=0.7)"
186        )
187    })?;
188
189    let layers_to_transform = parse_layers_to_transform(&raw.layers_to_transform)?;
190
191    if raw.r == 0 {
192        bail!("REJECT(r): LoRA rank must be > 0");
193    }
194    if !(raw.lora_alpha.is_finite() && raw.lora_alpha > 0.0) {
195        bail!(
196            "REJECT(lora_alpha): must be a finite positive number, got {}",
197            raw.lora_alpha
198        );
199    }
200
201    let trainable_token_indices = parse_trainable_tokens(&raw.trainable_token_indices)?;
202
203    let (target_modules, target_modules_pattern) = parse_target_modules(&raw.target_modules)?;
204    for entry in &target_modules {
205        validate_target_module(entry)?;
206    }
207
208    // 2026-09-26: An adapter with only a token overlay may list no target module.
209    let has_overlay = !trainable_token_indices.is_empty() || !modules_to_save.is_empty();
210    if target_modules.is_empty() && target_modules_pattern.is_none() && !has_overlay {
211        bail!("REJECT(target_modules): empty list — adapter targets nothing");
212    }
213
214    Ok(PeftAdapterConfig {
215        r: raw.r,
216        lora_alpha: raw.lora_alpha,
217        target_modules,
218        target_modules_pattern,
219        use_rslora,
220        layers_to_transform,
221        trainable_token_indices,
222        modules_to_save,
223        lora_embedding: false,
224    })
225}
226
227/// 2026-09-26: The accepted `modules_to_save` leaves (`embed_tokens`, `lm_head`, matched on
228/// the last `.`-segment), sorted and deduplicated. Any other leaf is an error.
229fn partition_modules_to_save(mods: Option<&[String]>) -> Result<Vec<String>> {
230    let Some(mods) = mods else {
231        return Ok(Vec::new());
232    };
233    let mut accepted = Vec::new();
234    for m in mods {
235        let leaf = m.rsplit('.').next().unwrap_or(m);
236        match leaf {
237            "embed_tokens" | "lm_head" => accepted.push(leaf.to_string()),
238            other => bail!(
239                "REJECT(modules_to_save): adapter saves full module '{other}'; only the \
240                 token-overlay subset {{embed_tokens, lm_head}} is supported"
241            ),
242        }
243    }
244    accepted.sort();
245    accepted.dedup();
246    Ok(accepted)
247}
248
249/// 2026-09-26: Parse `trainable_token_indices` into one list of ids, shared by the embedding
250/// and lm-head overlays.
251///
252/// Absent or null gives an empty list. A list `[id, …]` is taken as is. An object
253/// `{"embed_tokens": […], "lm_head": […]}` gives its non-null list, and the two lists must
254/// be equal. Errors on an id that is negative, above `u32::MAX`, repeated or out of
255/// ascending order, on another module key, and on differing lists.
256fn parse_trainable_tokens(v: &Option<serde_json::Value>) -> Result<Vec<u32>> {
257    fn parse_ids(arr: &[serde_json::Value]) -> Result<Vec<u32>> {
258        let mut ids = Vec::with_capacity(arr.len());
259        let mut seen = std::collections::HashSet::new();
260        let mut previous: Option<u32> = None;
261        for e in arr {
262            let n = e.as_u64().context(
263                "REJECT(trainable_token_indices): entries must be non-negative integers",
264            )?;
265            if n > u32::MAX as u64 {
266                bail!("REJECT(trainable_token_indices): id {n} exceeds u32 range");
267            }
268            let id = n as u32;
269            if !seen.insert(id) {
270                bail!("REJECT(trainable_token_indices): duplicate id {id}");
271            }
272            if let Some(prev) = previous
273                && id < prev
274            {
275                bail!(
276                    "REJECT(trainable_token_indices): ids must be ascending to preserve \
277                     trainable_tokens_delta row order; {id} follows {prev}"
278                );
279            }
280            previous = Some(id);
281            ids.push(id);
282        }
283        Ok(ids)
284    }
285
286    match v {
287        None | Some(serde_json::Value::Null) => Ok(Vec::new()),
288        Some(serde_json::Value::Array(arr)) => parse_ids(arr),
289        Some(serde_json::Value::Object(map)) => {
290            let mut shared: Option<Vec<u32>> = None;
291            for (module, val) in map {
292                if module != "embed_tokens" && module != "lm_head" {
293                    bail!(
294                        "REJECT(trainable_token_indices): unsupported module '{module}'; only \
295                         embed_tokens and lm_head are supported"
296                    );
297                }
298                let module_ids = match val {
299                    serde_json::Value::Array(arr) => parse_ids(arr)?,
300                    serde_json::Value::Null => continue,
301                    other => bail!(
302                        "REJECT(trainable_token_indices): dict value must be an array, got {other}"
303                    ),
304                };
305                if let Some(ref expected) = shared
306                    && expected != &module_ids
307                {
308                    bail!(
309                        "REJECT(trainable_token_indices): per-module token lists differ; \
310                         Metrale Engine requires one shared embed_tokens/lm_head order"
311                    );
312                }
313                shared = Some(module_ids);
314            }
315            Ok(shared.unwrap_or_default())
316        }
317        Some(other) => bail!(
318            "REJECT(trainable_token_indices): expected null, an array, or a per-module \
319             object, got {other}"
320        ),
321    }
322}
323
324fn parse_layers_to_transform(v: &Option<serde_json::Value>) -> Result<Option<Vec<usize>>> {
325    match v {
326        None | Some(serde_json::Value::Null) => Ok(None),
327        Some(serde_json::Value::Array(arr)) => {
328            let layers = arr
329                .iter()
330                .map(|e| {
331                    e.as_u64().map(|n| n as usize).context(
332                        "REJECT(layers_to_transform): entries must be non-negative integers",
333                    )
334                })
335                .collect::<Result<Vec<_>>>()?;
336            Ok(Some(layers))
337        }
338        Some(other) => bail!(
339            "REJECT(layers_to_transform): expected null or an array of layer indices, got {other}"
340        ),
341    }
342}
343
344fn parse_target_modules(v: &serde_json::Value) -> Result<(Vec<String>, Option<String>)> {
345    match v {
346        // 2026-09-26: `all-linear` is a PEFT keyword for every linear layer, which cannot be
347        // enumerated here: a fused or packed tensor stands for several linear layers.
348        serde_json::Value::String(s) if s == "all-linear" => bail!(
349            "REJECT(target_modules): string form '{s}' is unsupported — Metrale Engine cannot \
350             enumerate 'all linear' against fused/quantized layouts; re-export the \
351             adapter with an explicit module list or a regex"
352        ),
353        // 2026-09-26: Any other string is a regex pattern; see
354        // `PeftAdapterConfig::target_modules_pattern`.
355        serde_json::Value::String(s) => Ok((Vec::new(), Some(s.clone()))),
356        serde_json::Value::Array(arr) => {
357            let mods: Vec<String> = arr
358                .iter()
359                .map(|e| {
360                    e.as_str()
361                        .map(str::to_string)
362                        .context("REJECT(target_modules): entries must be strings")
363                })
364                .collect::<Result<_>>()?;
365            // 2026-09-26: The caller judges an empty list.
366            Ok((mods, None))
367        }
368        serde_json::Value::Null => Ok((Vec::new(), None)),
369        other => bail!("REJECT(target_modules): expected an array of module names, got {other}"),
370    }
371}
372
373/// 2026-09-26: `METRALE_LORA_ALLOW_PARTIAL` (`1` or `true`, any case), read once per process:
374/// load an adapter that names target modules the engine cannot apply, skipping those.
375///
376/// `metrale_model_layers::lora::env` re-exports this function; it is defined in this crate
377/// because `validate_target_module` reads it at parse time.
378///
379/// Off by default: a partly applied adapter cannot be told apart from a bad one.
380pub fn allow_partial_targets() -> bool {
381    static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
382    *V.get_or_init(|| {
383        std::env::var("METRALE_LORA_ALLOW_PARTIAL")
384            .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
385    })
386}
387
388/// 2026-09-26: The name-level check of one `target_modules` entry, a bare name (`"k_proj"`)
389/// or a full path (`"model.layers.3.self_attn.k_proj"`), on its last `.`-segment. Which
390/// layers it may target is checked per tensor by the weight loader.
391fn validate_target_module(entry: &str) -> Result<()> {
392    let leaf = entry.rsplit('.').next().unwrap_or(entry);
393    match leaf {
394        // 2026-09-26: The GDN / linear-attention inputs, fused and split spellings, and
395        // `conv1d` feed the recurrence and are rejected unless `allow_partial_targets()`.
396        // The block's `out_proj` is in the allow-list.
397        "in_proj_qkvz" | "in_proj_ba" | "in_proj_qkv" | "in_proj_z" | "in_proj_a" | "in_proj_b"
398        | "conv1d"
399            if !allow_partial_targets() =>
400        {
401            bail!(
402                "REJECT(gdn): target module '{leaf}' is a GDN/linear-attention projection; GDN \
403                 layers are unsupported in v0 (full-attention layers only). Set \
404                 METRALE_LORA_ALLOW_PARTIAL=1 to load the adapter anyway, applying only its \
405                 supported modules — it will then be PARTIALLY applied."
406            )
407        }
408        "in_proj_qkvz" | "in_proj_ba" | "in_proj_qkv" | "in_proj_z" | "in_proj_a" | "in_proj_b"
409        | "conv1d" => Ok(()),
410        "embed_tokens" | "lm_head" => {
411            bail!("REJECT(embedding): target module '{leaf}' is unsupported in v0")
412        }
413        // 2026-09-26: The MoE router (`mlp.gate`), not the dense `gate_proj`. Expert
414        // projections (`mlp.experts.N.gate_proj`) use the dense leaves. Whether router and
415        // expert deltas load is decided by `classify_key` and `METRALE_LORA_EXPERTS`.
416        "gate" => Ok(()),
417        m if PEFT_SUPPORTED_TARGET_MODULES.contains(&m) => Ok(()),
418        other => bail!(
419            "REJECT(unknown_module): target module '{other}' is not in the v0 allow-list \
420             {PEFT_SUPPORTED_TARGET_MODULES:?}"
421        ),
422    }
423}
424
425#[cfg(test)]
426#[path = "lora_tests.rs"]
427mod tests;