metrale_config/parsers/
lora.rs1use anyhow::{Context, Result, bail};
18use serde::Deserialize;
19
20pub 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 "out_proj",
36];
37
38#[derive(Debug, Clone)]
41pub struct PeftAdapterConfig {
42 pub r: usize,
44 pub lora_alpha: f64,
46 pub target_modules: Vec<String>,
49 pub target_modules_pattern: Option<String>,
54 pub use_rslora: bool,
57 pub layers_to_transform: Option<Vec<usize>>,
60 pub trainable_token_indices: Vec<u32>,
64 pub modules_to_save: Vec<String>,
68 pub lora_embedding: bool,
72}
73
74impl PeftAdapterConfig {
75 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#[derive(Deserialize)]
91struct RawPeftAdapterConfig {
92 #[serde(default)]
94 peft_type: Option<String>,
95 r: usize,
96 lora_alpha: f64,
97 #[serde(default)]
100 target_modules: serde_json::Value,
101 #[serde(default)]
103 use_rslora: Option<bool>,
104 #[serde(default)]
105 use_dora: bool,
106 #[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 #[serde(default)]
116 modules_to_save: Option<Vec<String>>,
117 #[serde(default)]
120 layers_to_transform: Option<serde_json::Value>,
121 #[serde(default)]
124 trainable_token_indices: Option<serde_json::Value>,
125 #[serde(default)]
128 target_parameters: Option<Vec<String>>,
129}
130
131pub 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 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
227fn 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
249fn 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 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 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 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
373pub 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
388fn validate_target_module(entry: &str) -> Result<()> {
392 let leaf = entry.rsplit('.').next().unwrap_or(entry);
393 match leaf {
394 "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 "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;