rtx-csm: Phase 12.5 — self-describing LoRA adapters

Trained adapters now embed (rank, alpha, target_modules, crate_version)
as JSON in safetensors __metadata__["rtx_csm_lora"]. apply_lora_adapter
reads it at load time so users no longer have to remember matching
--lora-rank/--lora-alpha/--extended-lora flags from training.

LoraAdapterMetadata::is_extended() heuristic: target_modules contains
any MLP path or output_proj or k_proj. Handles both the canonical
extended() preset and future custom configs that overlap it.

apply_lora_adapter rank/alpha/extended params became Option<_>
(None = use file metadata, Some = override). Both callers updated.
save_lora_adapter_with_metadata is the new path used by both trainers;
the plain save_lora_adapter still exists for the metadata-less case
(per-stage curriculum snapshots).

safetensors dep bumped 0.4 → 0.7 to match candle 0.9's transitive pin
so candle's Tensor: View impl is in scope for serialize_to_file
(candle's own save wrapper hardcodes the metadata arg to None).

Backward compat: pre-12.5 adapters load fine when explicit CLI flags
are passed; auto-detect path is skipped silently.

Verified end-to-end: trained adapter saved with metadata,
`generate --lora <path>` (no other flags) auto-detected
rank=8 alpha=16 extended=true and applied. Older metadata-less adapter
still loaded with explicit flags. 3 new unit tests; lib suite 99/99.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
osobh
2026-04-27 16:29:25 -07:00
co-authored by Claude Opus 4.7
parent 9d4eabc773
commit 32a811904a
6 changed files with 235 additions and 54 deletions
+6 -2
View File
@@ -68,8 +68,12 @@ voice_activity_detector = { version = "0.2", optional = true }
unicode-normalization = "0.1" unicode-normalization = "0.1"
regex = "1" regex = "1"
# Weight loading # Weight loading. Matches candle 0.9's transitive pin so candle's
safetensors = "0.4" # `Tensor: View` impl applies — letting us call
# `safetensors::serialize_to_file(&map, &Some(metadata), path)` directly
# (candle's `save` wrapper doesn't expose the metadata parameter, which we
# need for Phase 12.5 self-describing LoRA adapters).
safetensors = "0.7"
# Errors / logging / serde # Errors / logging / serde
anyhow.workspace = true anyhow.workspace = true
@@ -127,16 +127,16 @@ struct Cli {
/// but the server hasn't been audited for that combo). /// but the server hasn't been audited for that combo).
#[arg(long)] #[arg(long)]
lora: Option<PathBuf>, lora: Option<PathBuf>,
/// LoRA rank — must match training. Default 8. /// LoRA rank override. When omitted, auto-detected from adapter metadata
#[arg(long, default_value_t = 8)] /// (Phase 12.5); falls back to 8 for older adapters.
lora_rank: usize, #[arg(long)]
/// LoRA alpha — must match training. Default 16. lora_rank: Option<usize>,
#[arg(long, default_value_t = 16.0)] /// LoRA alpha override. Auto-detected from metadata when omitted.
lora_alpha: f32, #[arg(long)]
/// Set when the adapter was trained with Phase 12.1 extended coverage lora_alpha: Option<f32>,
/// (q+k+v+output_proj + MLP). Adapters trained with the classic q+v /// Force extended LoRA coverage (q+k+v+output_proj + MLP). Auto-detected
/// recipe load fine without this flag; setting it on a q+v adapter /// from metadata when omitted; setting it on a classic q+v adapter just
/// just allocates extra unused B=0 slots. /// allocates extra unused B=0 slots.
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]
extended_lora: bool, extended_lora: bool,
@@ -541,12 +541,13 @@ async fn main() -> Result<()> {
}; };
if let Some(lora_path) = cli.lora.as_ref() { if let Some(lora_path) = cli.lora.as_ref() {
let extended_override = if cli.extended_lora { Some(true) } else { None };
rtx_csm::training::apply_lora_adapter( rtx_csm::training::apply_lora_adapter(
&mut generator, &mut generator,
lora_path, lora_path,
cli.lora_rank, cli.lora_rank,
cli.lora_alpha, cli.lora_alpha,
cli.extended_lora, extended_override,
&device, &device,
)?; )?;
} }
+17 -11
View File
@@ -92,18 +92,20 @@ struct Cli {
#[arg(long)] #[arg(long)]
lora: Option<std::path::PathBuf>, lora: Option<std::path::PathBuf>,
/// LoRA rank — must match training. Defaults to the value from training defaults. /// LoRA rank override. When omitted, auto-detected from the adapter
#[arg(long, default_value_t = 8)] /// file's embedded metadata (Phase 12.5); falls back to 8 for older
lora_rank: usize, /// adapters without metadata.
#[arg(long)]
lora_rank: Option<usize>,
/// LoRA alpha — must match training. /// LoRA alpha override. When omitted, auto-detected from the adapter's
#[arg(long, default_value_t = 16.0)] /// embedded metadata; falls back to 16.0 for older adapters.
lora_alpha: f32, #[arg(long)]
lora_alpha: Option<f32>,
/// Set when the adapter was trained with Phase 12.1 extended coverage /// Force extended LoRA coverage (q+k+v+output_proj + MLP). When omitted,
/// (q+k+v+output_proj + MLP). Adapters trained with the classic q+v /// auto-detected from the adapter's embedded metadata. Setting this on a
/// recipe load fine without this; setting it on a q+v adapter just /// classic q+v adapter just allocates extra unused B=0 slots.
/// allocates extra unused B=0 slots.
#[arg(long, default_value_t = false)] #[arg(long, default_value_t = false)]
extended_lora: bool, extended_lora: bool,
@@ -153,12 +155,16 @@ fn main() -> Result<()> {
}; };
if let Some(lora_path) = cli.lora.as_ref() { if let Some(lora_path) = cli.lora.as_ref() {
// `--extended-lora` is a force flag (no way to clap-distinguish "off"
// from "unset" on a bare bool). When the user passes it we override
// to true; otherwise we let the file metadata or the default decide.
let extended_override = if cli.extended_lora { Some(true) } else { None };
rtx_csm::training::apply_lora_adapter( rtx_csm::training::apply_lora_adapter(
&mut generator, &mut generator,
lora_path, lora_path,
cli.lora_rank, cli.lora_rank,
cli.lora_alpha, cli.lora_alpha,
cli.extended_lora, extended_override,
&device, &device,
)?; )?;
} }
+8 -6
View File
@@ -15,7 +15,10 @@ use anyhow::Result;
use candle_nn::VarMap; use candle_nn::VarMap;
use clap::Parser; use clap::Parser;
use rtx_csm::lora::LoraConfig; use rtx_csm::lora::LoraConfig;
use rtx_csm::training::{save_lora_adapter, Trainer, TrainingConfig, TrainingDataset}; use rtx_csm::training::{
save_lora_adapter_with_metadata, LoraAdapterMetadata, Trainer, TrainingConfig,
TrainingDataset,
};
use rtx_csm::Generator; use rtx_csm::Generator;
use std::path::PathBuf; use std::path::PathBuf;
@@ -153,15 +156,14 @@ fn main() -> Result<()> {
); );
} }
save_lora_adapter(&vm, &cli.output)?; let metadata = LoraAdapterMetadata::from_lora_config(&lora_cfg);
save_lora_adapter_with_metadata(&vm, &cli.output, &metadata)?;
println!("\n✓ trained LoRA adapter saved to {}", cli.output.display()); println!("\n✓ trained LoRA adapter saved to {}", cli.output.display());
println!( println!(
"Use it at inference: examples/generate --lora {} \\\n\ "Use it at inference: examples/generate --lora {} \\\n\
\t--lora-rank {} --lora-alpha {}{}", \t--text \"...\" --out /tmp/out.wav\n\
(rank/alpha/extended auto-detected from embedded metadata)",
cli.output.display(), cli.output.display(),
cli.rank,
cli.alpha,
if cli.extended_lora { " --extended-lora" } else { "" },
); );
Ok(()) Ok(())
} }
@@ -28,7 +28,8 @@ use candle_nn::VarMap;
use clap::Parser; use clap::Parser;
use rtx_csm::lora::LoraConfig; use rtx_csm::lora::LoraConfig;
use rtx_csm::training::{ use rtx_csm::training::{
save_lora_adapter, CurriculumStage, CurriculumTrainer, TrainingConfig, TrainingDataset, save_lora_adapter_with_metadata, CurriculumStage, CurriculumTrainer, LoraAdapterMetadata,
TrainingConfig, TrainingDataset,
}; };
use rtx_csm::Generator; use rtx_csm::Generator;
use std::path::PathBuf; use std::path::PathBuf;
@@ -181,15 +182,17 @@ fn main() -> Result<()> {
); );
} }
save_lora_adapter(&vm, &cli.output)?; let metadata = LoraAdapterMetadata::from_lora_config(&lora_cfg);
save_lora_adapter_with_metadata(&vm, &cli.output, &metadata)?;
println!( println!(
"\n✓ trained emotional LoRA adapter saved to {}", "\n✓ trained emotional LoRA adapter saved to {} (with embedded metadata)",
cli.output.display() cli.output.display()
); );
println!( println!(
"Apply at inference time via the same adapter loader. Re-use the same\n\ "Apply at inference: examples/generate --lora {} --text \"...\" \\\n\
emotion tags at inference (Phase 12.2 --emotion-hint flag) for the\n\ \t--emotion-hint \"[your_tag]\" --out /tmp/out.wav\n\
tag→prosody mapping the curriculum just trained." (rank/alpha/extended auto-detected from embedded metadata)",
cli.output.display(),
); );
Ok(()) Ok(())
} }
+183 -18
View File
@@ -514,15 +514,84 @@ fn clip_grads(
Ok(()) Ok(())
} }
/// Self-describing metadata embedded in safetensors at save time so the
/// adapter file knows its own LoRA hyperparameters. Lets `apply_lora_adapter`
/// auto-configure at inference without the user having to remember matching
/// `--lora-rank` / `--lora-alpha` / `--extended-lora` flags.
///
/// Stored under the safetensors `__metadata__` map as a single JSON-encoded
/// string under key `rtx_csm_lora`. Backward compat: adapters without this
/// key fall back to caller-supplied defaults (rank=8, alpha=16, extended=false).
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct LoraAdapterMetadata {
pub rank: usize,
pub alpha: f32,
/// Snapshot of `LoraConfig.target_modules` at training time. Reconstructs
/// `extended()` vs `default()` reliably when the user adds new presets.
pub target_modules: Vec<String>,
/// rtx-csm crate version that produced the adapter (just for diagnostics
/// — no compatibility logic gates on this today).
#[serde(default)]
pub crate_version: Option<String>,
}
const LORA_METADATA_KEY: &str = "rtx_csm_lora";
impl LoraAdapterMetadata {
pub fn from_lora_config(cfg: &crate::lora::LoraConfig) -> Self {
Self {
rank: cfg.rank,
alpha: cfg.alpha,
target_modules: cfg.target_modules.clone(),
crate_version: Some(env!("CARGO_PKG_VERSION").to_string()),
}
}
/// Heuristic: was the adapter trained with Phase 12.1 extended coverage?
/// True if any MLP module is in `target_modules`. The exact preset name
/// isn't stored — only the resolved patterns — so we infer from content.
pub fn is_extended(&self) -> bool {
self.target_modules
.iter()
.any(|m| m.contains("mlp.") || m.contains("output_proj") || m == "k_proj")
}
}
/// Read just the LoRA self-description from a safetensors header, without
/// loading any tensor data. Returns `None` when the file has no
/// `rtx_csm_lora` metadata key (older adapters trained before Phase 12.5).
pub fn read_lora_adapter_metadata<P: AsRef<Path>>(
path: P,
) -> Result<Option<LoraAdapterMetadata>> {
let data = std::fs::read(path.as_ref())?;
let (_n, meta) = safetensors::SafeTensors::read_metadata(&data)
.map_err(|e| CsmError::Other(anyhow::anyhow!("read safetensors header: {e}")))?;
let map = match meta.metadata() {
Some(m) => m,
None => return Ok(None),
};
let json = match map.get(LORA_METADATA_KEY) {
Some(s) => s,
None => return Ok(None),
};
let parsed: LoraAdapterMetadata = serde_json::from_str(json).map_err(|e| {
CsmError::Other(anyhow::anyhow!("parse {LORA_METADATA_KEY}: {e}"))
})?;
Ok(Some(parsed))
}
/// One-shot inference-time LoRA loader: inject adapters with the matching /// One-shot inference-time LoRA loader: inject adapters with the matching
/// shape, populate from a safetensors file, and refresh the model's tensor /// shape, populate from a safetensors file, and refresh the model's tensor
/// handles so the next forward pass picks up the trained weights. /// handles so the next forward pass picks up the trained weights.
/// ///
/// Pass `extended = true` when the adapter was trained with Phase 12.1's /// Each parameter accepts an explicit override; when `None`, the value is
/// extended coverage (q+k+v+output_proj + MLP); otherwise it loads the /// auto-detected from the adapter file's embedded `LoraAdapterMetadata`
/// classic q+v recipe. Adapters trained with the narrow recipe still load /// (Phase 12.5). For files without metadata (older adapters), pass explicit
/// cleanly when `extended = true` — the unmatched k/o/MLP slots are /// values: defaults are rank=8, alpha=16, extended=false.
/// initialized B=0 and stay no-ops, at the cost of a small extra VarMap. ///
/// Adapters trained with the narrow q+v recipe still load cleanly when
/// `extended = true` — the unmatched k/o/MLP slots are initialized B=0 and
/// stay no-ops, at the cost of a small extra VarMap.
/// ///
/// Returns the `VarMap` so callers can keep it alive (the model holds plain /// Returns the `VarMap` so callers can keep it alive (the model holds plain
/// tensor handles refreshed from this VarMap; dropping it doesn't break a /// tensor handles refreshed from this VarMap; dropping it doesn't break a
@@ -533,24 +602,46 @@ fn clip_grads(
pub fn apply_lora_adapter<P: AsRef<Path>>( pub fn apply_lora_adapter<P: AsRef<Path>>(
generator: &mut Generator, generator: &mut Generator,
path: P, path: P,
rank: usize, rank: Option<usize>,
alpha: f32, alpha: Option<f32>,
extended: bool, extended: Option<bool>,
device: &candle_core::Device, device: &candle_core::Device,
) -> Result<VarMap> { ) -> Result<VarMap> {
let base = if extended { let path = path.as_ref();
let meta = read_lora_adapter_metadata(path)?;
if meta.is_some() {
tracing::info!(
"adapter has embedded metadata; CLI rank/alpha/extended flags act as overrides"
);
}
let resolved_rank = rank
.or_else(|| meta.as_ref().map(|m| m.rank))
.unwrap_or(8);
let resolved_alpha = alpha
.or_else(|| meta.as_ref().map(|m| m.alpha))
.unwrap_or(16.0);
let resolved_extended = extended
.or_else(|| meta.as_ref().map(|m| m.is_extended()))
.unwrap_or(false);
let base = if resolved_extended {
crate::lora::LoraConfig::extended() crate::lora::LoraConfig::extended()
} else { } else {
crate::lora::LoraConfig::default() crate::lora::LoraConfig::default()
}; };
let cfg = crate::lora::LoraConfig { rank, alpha, ..base }; let cfg = crate::lora::LoraConfig {
rank: resolved_rank,
alpha: resolved_alpha,
..base
};
let vm = VarMap::new(); let vm = VarMap::new();
generator generator
.model .model
.inner .inner
.add_lora_to_backbone(&cfg, &vm) .add_lora_to_backbone(&cfg, &vm)
.map_err(|e| CsmError::Other(anyhow::anyhow!("add_lora_to_backbone: {e}")))?; .map_err(|e| CsmError::Other(anyhow::anyhow!("add_lora_to_backbone: {e}")))?;
load_lora_adapter(&vm, path.as_ref(), device)?; load_lora_adapter(&vm, path, device)?;
generator generator
.model .model
.inner .inner
@@ -558,17 +649,39 @@ pub fn apply_lora_adapter<P: AsRef<Path>>(
.map_err(|e| CsmError::Other(anyhow::anyhow!("refresh_lora: {e}")))?; .map_err(|e| CsmError::Other(anyhow::anyhow!("refresh_lora: {e}")))?;
tracing::info!( tracing::info!(
"applied LoRA adapter from {} (rank={} alpha={} extended={})", "applied LoRA adapter from {} (rank={} alpha={} extended={})",
path.as_ref().display(), path.display(),
rank, resolved_rank,
alpha, resolved_alpha,
extended, resolved_extended,
); );
Ok(vm) Ok(vm)
} }
/// Save the LoRA adapter parameters (A,B for every layer) as a safetensors /// Save the LoRA adapter parameters (A,B for every layer) as a safetensors
/// file. Reload via `load_lora_adapter`. /// file. Reload via `load_lora_adapter`. No embedded metadata — callers that
/// know the LoRA hyperparameters should prefer
/// [`save_lora_adapter_with_metadata`] so the trained adapter is
/// self-describing at inference time.
pub fn save_lora_adapter<P: AsRef<Path>>(vm: &VarMap, out: P) -> Result<()> { pub fn save_lora_adapter<P: AsRef<Path>>(vm: &VarMap, out: P) -> Result<()> {
save_lora_adapter_inner(vm, out, None)
}
/// Save with embedded `LoraAdapterMetadata` (Phase 12.5). The metadata is
/// written into the safetensors header under `__metadata__["rtx_csm_lora"]`
/// as a JSON string; `apply_lora_adapter` will auto-pick it up at inference.
pub fn save_lora_adapter_with_metadata<P: AsRef<Path>>(
vm: &VarMap,
out: P,
metadata: &LoraAdapterMetadata,
) -> Result<()> {
save_lora_adapter_inner(vm, out, Some(metadata))
}
fn save_lora_adapter_inner<P: AsRef<Path>>(
vm: &VarMap,
out: P,
metadata: Option<&LoraAdapterMetadata>,
) -> Result<()> {
use std::collections::HashMap; use std::collections::HashMap;
let vars = vm.data().lock().unwrap(); let vars = vm.data().lock().unwrap();
let mut tensors: HashMap<String, Tensor> = HashMap::new(); let mut tensors: HashMap<String, Tensor> = HashMap::new();
@@ -576,9 +689,26 @@ pub fn save_lora_adapter<P: AsRef<Path>>(vm: &VarMap, out: P) -> Result<()> {
tensors.insert(name.clone(), var.as_tensor().clone()); tensors.insert(name.clone(), var.as_tensor().clone());
} }
drop(vars); drop(vars);
candle_core::safetensors::save(&tensors, out.as_ref()) let n_tensors = tensors.len();
let header_meta = match metadata {
Some(m) => {
let json = serde_json::to_string(m).map_err(|e| {
CsmError::Other(anyhow::anyhow!("serialize {LORA_METADATA_KEY}: {e}"))
})?;
let mut map = HashMap::new();
map.insert(LORA_METADATA_KEY.to_string(), json);
Some(map)
}
None => None,
};
safetensors::serialize_to_file(&tensors, header_meta.clone(), out.as_ref())
.map_err(|e| CsmError::Other(anyhow::anyhow!("safetensors save: {e}")))?; .map_err(|e| CsmError::Other(anyhow::anyhow!("safetensors save: {e}")))?;
tracing::info!("saved {} LoRA tensors → {}", tensors.len(), out.as_ref().display()); tracing::info!(
"saved {n_tensors} LoRA tensors → {}{}",
out.as_ref().display(),
if header_meta.is_some() { " (with metadata)" } else { "" }
);
Ok(()) Ok(())
} }
@@ -604,3 +734,38 @@ pub fn load_lora_adapter<P: AsRef<Path>>(
tracing::info!("loaded {count} LoRA tensors from {}", path.as_ref().display()); tracing::info!("loaded {count} LoRA tensors from {}", path.as_ref().display());
Ok(()) Ok(())
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn metadata_is_extended_picks_up_extended_target_modules() {
let cfg = crate::lora::LoraConfig::extended();
let m = LoraAdapterMetadata::from_lora_config(&cfg);
assert!(m.is_extended());
}
#[test]
fn metadata_is_extended_false_for_classic_qv() {
let cfg = crate::lora::LoraConfig::default();
let m = LoraAdapterMetadata::from_lora_config(&cfg);
assert!(!m.is_extended());
}
#[test]
fn metadata_round_trips_through_json() {
let m = LoraAdapterMetadata {
rank: 16,
alpha: 32.0,
target_modules: vec!["q_proj".into(), "v_proj".into()],
crate_version: Some("9.9.9".into()),
};
let s = serde_json::to_string(&m).unwrap();
let back: LoraAdapterMetadata = serde_json::from_str(&s).unwrap();
assert_eq!(back.rank, 16);
assert_eq!(back.alpha, 32.0);
assert_eq!(back.target_modules.len(), 2);
assert!(!back.is_extended());
}
}