metrale_model_layers/lora/
env.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: LoRA environment switches (`METRALE_LORA_*`), the
4//! full-attention layer list, and `validate_peft_config`, the adapter-config
5//! check that needs `--max-lora-rank`.
6//!
7//! Owner: model-layers (lora).
8//! Invariants: none beyond the types.
9
10use anyhow::{Result, bail};
11use metrale_config::{LayerType, ModelConfig, PeftAdapterConfig};
12
13use super::LoraModule;
14
15/// 2026-09-25: `METRALE_LORA_EAGER` (`1` or `true`, any case), from the
16/// process-wide `ModelLevers::get()`. The model reads its own copy,
17/// `levers.lora_eager`, and runs decode and verify without CUDA graphs while an
18/// adapter is loaded.
19pub fn lora_eager_env() -> bool {
20    crate::layers::ops::ModelLevers::get().lora_eager
21}
22
23/// 2026-09-25: `METRALE_LORA_ROTATE` (`1` or `true`, any case), from the
24/// process-wide `ModelLevers::get()`. It, or `lora_peer_env`, sets the model's
25/// `lora_rotatable`, which permits runtime adapter rotation and swap whatever
26/// the number of resident adapters. It does not make decode eager; only
27/// `METRALE_LORA_EAGER` does. `met serve` refuses `--lora-stageable-disk`
28/// unless this or a peer is set.
29pub fn lora_rotate_env() -> bool {
30    crate::layers::ops::ModelLevers::get().lora_rotate
31}
32
33/// 2026-09-25: `METRALE_LORA_PEER`, the host:port of the weight peer that
34/// stages adapters, when set and non-empty. Setting it also sets the model's
35/// `lora_rotatable` (see `lora_rotate_env`).
36pub fn lora_peer_env() -> Option<String> {
37    std::env::var("METRALE_LORA_PEER")
38        .ok()
39        .filter(|s| !s.is_empty())
40}
41
42/// 2026-09-25: `METRALE_LORA_EXPERTS` (`1` or `true`, any case), read once:
43/// load routed-expert and router LoRA deltas. Unset, an adapter with expert or
44/// router tensors is refused at load (`expert_pack.rs`).
45pub fn lora_experts_env() -> bool {
46    static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
47    *V.get_or_init(|| {
48        std::env::var("METRALE_LORA_EXPERTS")
49            .is_ok_and(|v| v == "1" || v.eq_ignore_ascii_case("true"))
50    })
51}
52
53/// 2026-09-25: The padded rank of the expert and router LoRA pools,
54/// `METRALE_LORA_EXPERT_RANK` (a positive integer; otherwise 16), read once.
55/// It is separate from `--max-lora-rank`, which sizes the slot pool. An
56/// adapter whose `r` exceeds it is refused at load (`expert_pack.rs`).
57pub fn max_lora_expert_rank() -> usize {
58    static V: std::sync::OnceLock<usize> = std::sync::OnceLock::new();
59    *V.get_or_init(|| {
60        std::env::var("METRALE_LORA_EXPERT_RANK")
61            .ok()
62            .and_then(|v| v.parse().ok())
63            .filter(|&r: &usize| r > 0)
64            .unwrap_or(16)
65    })
66}
67
68/// 2026-09-25: `METRALE_LORA_PREFILL_BGMV=1`, read once: the attention QKV
69/// prefill applies LoRA with the per-row `apply_lora_bgmv` instead of
70/// `apply_lora_delta` when the call has a slot buffer and a route
71/// (`layers/qwen3_attention/prefill/paged_qkv.rs`).
72pub fn prefill_bgmv_forced() -> bool {
73    static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
74    *V.get_or_init(|| std::env::var("METRALE_LORA_PREFILL_BGMV").as_deref() == Ok("1"))
75}
76
77/// 2026-09-25: `METRALE_LORA_NO_BATCH_VERIFY=1`, read once: while an adapter
78/// is loaded, cross-sequence batched verify is not eligible (model-engine
79/// `trait_impl/verify_e.rs`).
80pub fn no_batch_verify() -> bool {
81    static V: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
82    *V.get_or_init(|| std::env::var("METRALE_LORA_NO_BATCH_VERIFY").as_deref() == Ok("1"))
83}
84
85pub fn full_attention_layers(cfg: &ModelConfig) -> Vec<usize> {
86    (0..cfg.num_hidden_layers)
87        .filter(|&i| cfg.layer_type(i) == LayerType::FullAttention)
88        .collect()
89}
90
91/// 2026-09-25: Adapter-config checks that need `--max-lora-rank`: the rank
92/// fits the pool, and every target module is one `LoraModule` or the router
93/// (`gate`). The parse-time checks are in `metrale_config::parse_peft_adapter_config`.
94pub fn validate_peft_config(peft: &PeftAdapterConfig, max_lora_rank: usize) -> Result<()> {
95    if peft.r > max_lora_rank {
96        bail!(
97            "REJECT[rank-exceeds-pool]: r={} > --max-lora-rank={}",
98            peft.r,
99            max_lora_rank
100        );
101    }
102    let mut unsupported: Vec<&str> = Vec::new();
103    for t in &peft.target_modules {
104        let last = t.rsplit('.').next().unwrap_or(t);
105        // 2026-09-25: `gate` is the MoE router, not `gate_proj`. Expert
106        // projections use the dense leaf names, which `LoraModule::ALL` lists.
107        let ok = last == "gate" || LoraModule::ALL.iter().any(|m| m.peft_name() == last);
108        if !ok {
109            unsupported.push(t.as_str());
110        }
111    }
112    if !unsupported.is_empty() {
113        if !allow_partial_targets() {
114            bail!(
115                "REJECT[unsupported-target]: target_modules {unsupported:?} \
116                 (allowed: q_proj k_proj v_proj o_proj gate_proj up_proj down_proj gate). \
117                 Set METRALE_LORA_ALLOW_PARTIAL=1 to load anyway, applying only the \
118                 supported modules — the adapter will then be PARTIALLY applied and \
119                 will not reproduce its training behaviour."
120            );
121        }
122        tracing::warn!(
123            "LoRA PARTIAL LOAD (METRALE_LORA_ALLOW_PARTIAL=1): target_modules \
124             {unsupported:?} are NOT supported and will be SKIPPED. Their \
125             trained deltas will not be applied; output will differ from the \
126             adapter's intent. Supported: q_proj k_proj v_proj o_proj \
127             gate_proj up_proj down_proj gate."
128        );
129    }
130    Ok(())
131}
132
133/// 2026-09-25: `METRALE_LORA_ALLOW_PARTIAL` (`1` or `true`, any case): load an
134/// adapter that names target modules the engine cannot apply, skipping those.
135/// Re-exported from metrale-config, whose parse-time check reads the same
136/// switch, so both checks see one value.
137pub use metrale_config::allow_partial_targets;