spark_model/lora/
overlay.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Token overlay (Feature 2): PEFT `trainable_tokens` / `modules_to_save`
4//! vocab-extension and embed/lm_head row replacement, ported from the NLLB
5//! `token_adapter` overlay to the main decoder (`TransformerModel`) path.
6//!
7//! This module is the PURE, GPU-free half: on-disk tensor classification
8//! ([`classify_overlay_key`]), the collection struct the loader fills
9//! ([`OverlayTensors`]), and the row-selection math ([`clamp_trainable_to_vocab`],
10//! [`build_override_set`], [`override_source`]) that decides which vocab rows an
11//! adapter overrides and where each row's replacement bytes come from. The GPU
12//! materialization (row-diff kernel, compact-row copy, device tables) lives in
13//! [`super::overlay_build`]; the forward hooks live in `model/token_overlay.rs`.
14//!
15//! Three on-disk mechanisms feed one runtime overlay:
16//! - `trainable_tokens`: `…token_adapter.base_layer.weight [R,h]` bf16 +
17//!   `…token_adapter.trainable_tokens_delta [T,h]` f32 (full row replacement,
18//!   PEFT `index_copy` semantics: `E[idx[k]] = delta[k]`, NOT `base+delta`).
19//! - `modules_to_save[embed_tokens|lm_head]`: a full `…embed_tokens.weight`
20//!   `[vocab,h]` replacement (same builder, no delta — the row-diff finds the
21//!   changed rows).
22//! - `lora_embedding_A/B`: classic low-rank embed LoRA — Tier-2, classified
23//!   here so the loader can NAME-reject it rather than mis-route it.
24
25use anyhow::{Result, bail};
26
27/// Rows whose max abs difference from the served embed table exceeds this are
28/// treated as "overridden" by a `modules_to_save`/baked base_layer. Clears
29/// bf16 rounding noise (matching rows land ≤0.05; a real differing row ≥1.3).
30pub const ROWDIFF_THRESH: f32 = 0.1;
31
32/// Which tied embedding an overlay tensor belongs to.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
34pub enum OverlayModule {
35    /// Input embedding table (`embed_tokens` / NLLB `shared`).
36    EmbedTokens,
37    /// Output projection (`lm_head`). Distinct buffer when untied.
38    LmHead,
39}
40
41/// The role an overlay tensor plays in [`build_overlay`](super::overlay_build::build_overlay).
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum OverlayTensorKind {
44    /// `token_adapter.base_layer.weight` — the adapter's own `[R,h]` table.
45    Base,
46    /// `token_adapter.trainable_tokens_delta` — `[T,h]` replacement rows.
47    Delta,
48    /// `modules_to_save` full weight (`embed_tokens.weight` / `lm_head.weight`).
49    FullSave,
50    /// Classic low-rank embedding LoRA A factor (Tier-2, load-rejected).
51    LoraEmbedA,
52    /// Classic low-rank embedding LoRA B factor (Tier-2, load-rejected).
53    LoraEmbedB,
54}
55
56/// A classified overlay tensor: its target embedding and its role.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub struct OverlayTensor {
59    pub module: OverlayModule,
60    pub kind: OverlayTensorKind,
61}
62
63/// Classify a PEFT adapter tensor as a token-overlay tensor, or `None` if it is
64/// an ordinary `lora_A/lora_B` weight (which [`super::classify_key`] handles).
65///
66/// Prefix-agnostic beyond the PEFT wrapper, matching `classify_key`: it accepts
67/// both `…model.embed_tokens.…` and the multimodal `…model.language_model.
68/// embed_tokens.…` spellings. Suffix order matters — the `token_adapter` /
69/// `lora_embedding` forms are checked before the bare `.weight`
70/// (`modules_to_save`) form so `…token_adapter.base_layer.weight` is not
71/// mis-read as a full save.
72pub fn classify_overlay_key(key: &str) -> Option<OverlayTensor> {
73    let stripped = key.strip_prefix("base_model.model.")?;
74    // Ordinary LoRA weights are never overlays.
75    if stripped.ends_with(".lora_A.weight") || stripped.ends_with(".lora_B.weight") {
76        return None;
77    }
78    let module = overlay_module_of(stripped)?;
79    let kind = if stripped.ends_with(".token_adapter.base_layer.weight") {
80        OverlayTensorKind::Base
81    } else if stripped.ends_with(".token_adapter.trainable_tokens_delta") {
82        OverlayTensorKind::Delta
83    } else if stripped.ends_with(".lora_embedding_A") {
84        OverlayTensorKind::LoraEmbedA
85    } else if stripped.ends_with(".lora_embedding_B") {
86        OverlayTensorKind::LoraEmbedB
87    } else if is_full_module_weight(stripped, module) {
88        OverlayTensorKind::FullSave
89    } else {
90        return None;
91    };
92    Some(OverlayTensor { module, kind })
93}
94
95/// Identify the tied-embedding module a (prefix-stripped) key targets. `lm_head`
96/// wins over `embed_tokens`/`shared` because the two never co-occur in one key.
97fn overlay_module_of(stripped: &str) -> Option<OverlayModule> {
98    if stripped.contains("lm_head") {
99        Some(OverlayModule::LmHead)
100    } else if stripped.contains("embed_tokens") || stripped.contains(".shared.") {
101        Some(OverlayModule::EmbedTokens)
102    } else {
103        None
104    }
105}
106
107/// True when `stripped` is exactly the module's own top-level weight
108/// (`…embed_tokens.weight` / `lm_head.weight`) — a `modules_to_save` full
109/// replacement — and NOT a per-layer weight that merely mentions the module.
110fn is_full_module_weight(stripped: &str, module: OverlayModule) -> bool {
111    // A modules_to_save tensor sits above the layer stack.
112    if stripped.contains(".layers.") {
113        return false;
114    }
115    let leaf_owner = match module {
116        OverlayModule::EmbedTokens => "embed_tokens.weight",
117        OverlayModule::LmHead => "lm_head.weight",
118    };
119    stripped.ends_with(leaf_owner)
120}
121
122/// Tensor names the loader has collected for one adapter, partitioned by
123/// (module, role). `Option<String>` = the safetensors key, filled at most once.
124#[derive(Debug, Default, Clone)]
125pub struct OverlayTensors {
126    pub embed_base: Option<String>,
127    pub embed_delta: Option<String>,
128    pub embed_full: Option<String>,
129    pub lmhead_base: Option<String>,
130    pub lmhead_delta: Option<String>,
131    pub lmhead_full: Option<String>,
132    /// Any classic `lora_embedding_A/B` tensor seen (Tier-2 — load-rejected).
133    pub lora_embedding_seen: bool,
134}
135
136impl OverlayTensors {
137    /// Record one classified overlay tensor. Duplicate roles are a hard error
138    /// (never silently overwrite — an ambiguous adapter must fail loudly).
139    pub fn insert(&mut self, t: OverlayTensor, name: &str) -> Result<()> {
140        use OverlayModule::*;
141        use OverlayTensorKind::*;
142        let slot = match (t.module, t.kind) {
143            (_, LoraEmbedA) | (_, LoraEmbedB) => {
144                self.lora_embedding_seen = true;
145                return Ok(());
146            }
147            (EmbedTokens, Base) => &mut self.embed_base,
148            (EmbedTokens, Delta) => &mut self.embed_delta,
149            (EmbedTokens, FullSave) => &mut self.embed_full,
150            (LmHead, Base) => &mut self.lmhead_base,
151            (LmHead, Delta) => &mut self.lmhead_delta,
152            (LmHead, FullSave) => &mut self.lmhead_full,
153        };
154        if slot.is_some() {
155            bail!(
156                "REJECT[duplicate-overlay-tensor]: two tensors map to {:?}/{:?}",
157                t.module,
158                t.kind
159            );
160        }
161        *slot = Some(name.to_string());
162        Ok(())
163    }
164
165    /// Any overlay tensor at all was collected.
166    pub fn is_empty(&self) -> bool {
167        self.embed_base.is_none()
168            && self.embed_delta.is_none()
169            && self.embed_full.is_none()
170            && self.lmhead_base.is_none()
171            && self.lmhead_delta.is_none()
172            && self.lmhead_full.is_none()
173            && !self.lora_embedding_seen
174    }
175}
176
177/// Feature 2 load gate, called from the loader once overlay tensors are
178/// collected. The device-side overlay apply is now WIRED (Stage-1
179/// [`super::overlay_build::stage_overlay_raw`] upload → Stage-2
180/// [`super::overlay_build::build_overlay`] row-diff/compact → the
181/// `embed_tokens` / `lm_head` forward hooks in `crate::model::token_overlay`),
182/// so `trainable_tokens` / `modules_to_save` `{embed_tokens, lm_head}` tensors
183/// are LOADED rather than rejected.
184///
185/// The ONLY remaining reject here is the classic low-rank embedding LoRA
186/// (`lora_embedding_A/B`): a distinct Tier-2 mechanism with no kernel yet, named
187/// so the adapter fails loudly rather than being silently mis-applied.
188pub fn reject_pending_overlay(overlay: &OverlayTensors) -> Result<()> {
189    if overlay.lora_embedding_seen {
190        bail!(
191            "REJECT[lora-embedding-unimplemented]: classic low-rank embedding LoRA \
192             (lora_embedding_A/B) is not yet supported on the decoder path"
193        );
194    }
195    Ok(())
196}
197
198/// Clamp `trainable` ids to the served vocab, preserving list order (the delta
199/// tensor's rows align positionally to it).
200///
201/// Returns `(kept_ids, skipped_extension_count)`:
202/// - `idx >= r` → hard error (id outside the adapter's own `[R,h]` embedding).
203/// - `vocab <= idx < r` → vocab-extension token the served tokenizer can't emit;
204///   dropped and counted (caller warns).
205/// - `idx < vocab` → kept, but a kept id smaller than a previously-kept id is a
206///   hard error: PEFT appends extension tokens as the largest indices with delta
207///   rows in the same order, so the kept prefix must stay positionally aligned to
208///   the delta rows after the extension tail is dropped.
209pub fn clamp_trainable_to_vocab(
210    trainable: &[u32],
211    r: usize,
212    vocab: usize,
213) -> Result<(Vec<u32>, usize)> {
214    let mut kept = Vec::new();
215    let mut skipped = 0usize;
216    let mut last_kept: Option<u32> = None;
217    for &idx in trainable {
218        let i = idx as usize;
219        if i >= r {
220            bail!("REJECT[trainable-index-out-of-adapter]: id {idx} >= adapter embedding rows {r}");
221        }
222        if i >= vocab {
223            skipped += 1;
224            continue;
225        }
226        if skipped > 0 {
227            bail!(
228                "REJECT[trainable-order]: served-vocab id {idx} appears after a skipped \
229                 vocab-extension id; extension ids must form one trailing suffix"
230            );
231        }
232        if let Some(prev) = last_kept
233            && idx <= prev
234        {
235            bail!(
236                "REJECT[trainable-order]: kept id {idx} follows id {prev}; \
237                 PEFT trainable-token order must be strictly ascending in the served-vocab prefix"
238            );
239        }
240        last_kept = Some(idx);
241        kept.push(idx);
242    }
243    Ok((kept, skipped))
244}
245
246/// Union of (rows that differ from the served base) and (trainable ids), sorted
247/// ascending and deduped. This is the final set of vocab rows the overlay
248/// replaces. `row_diff[i]` = row `i` of the adapter base differs from served.
249pub fn build_override_set(row_diff: &[bool], trainable: &[u32]) -> Vec<u32> {
250    let mut ids: Vec<u32> = row_diff
251        .iter()
252        .enumerate()
253        .filter_map(|(i, &d)| d.then_some(i as u32))
254        .collect();
255    ids.extend_from_slice(trainable);
256    ids.sort_unstable();
257    ids.dedup();
258    ids
259}
260
261/// Where an overridden id's replacement row comes from: `Some(k)` = trainable
262/// delta row `k` (delta WINS when an id is both trainable and baked-different);
263/// `None` = the adapter's baked `base_layer[id]`.
264pub fn override_source(id: u32, trainable: &[u32]) -> Option<usize> {
265    trainable.iter().position(|&t| t == id)
266}
267
268#[cfg(test)]
269#[path = "overlay_tests.rs"]
270mod tests;