Skip to content

feat(ruvllm): load PEFT LoRA adapters through LlmBackend - #1203

Merged
ruvnet merged 4 commits into
ruvnet:mainfrom
ohdearquant:feat/llm-backend-lora-adapters
Oct 24, 2026
Merged

ruvnet merged 4 commits into
ruvnet:mainfrom
ohdearquant:feat/llm-backend-lora-adapters

Conversation

@ohdearquant

Copy link
Copy Markdown
Contributor

Pull request opened by an AI agent on behalf of @ohdearquant.

Summary

This implements steps 1 to 3 of #1191: generation adapters on LlmBackend, with the macOS Lattice backend as the first implementation.

  • Trait. LlmBackend gains load_lora_adapter(path, scale), unload_lora_adapter() and active_lora_adapter(), plus a backends::AdapterInfo record. The three methods return Result<_, ModelAdapterError>, so RuvLLMError is unchanged. The default implementations return ModelAdapterError::UnsupportedBackend, so the other backends and any downstream implementors compile unchanged and report the operation as unsupported.
  • Lattice backend (lattice feature, macOS). The backend reads a PEFT SafeTensors adapter (file or directory) with adapter_config.json beside it. It validates it against the loaded Qwen 3.5 model, then installs it through the runtime's adapter hot-swap, with no model reload. The scale multiplies the adapter's alpha / rank. Supported: uniform-rank LoRA on the full-attention q_proj, k_proj, v_proj, o_proj and the dense MLP gate_proj, up_proj, down_proj. GDN and MoE layers, DoRA, rank-stabilized LoRA, saved bias or modules, and non-uniform rank or alpha patterns are rejected with a typed error.
  • State. A validation failure leaves the previous adapter (or none) active. An installation failure restores the previous adapter, and if that also fails the error says no adapter is active. Unloading restores base-model generation.
  • Base models load as before. The model's config.json is also read for adapter validation. An error from that read is reported only when an adapter is loaded; load_model reports only the base loader's own errors. ModelInfo::name keeps the path string the caller passed.
  • Configs written by current PEFT load as saved. PEFT 0.21.2 writes fields this code does not use (kasa_config, lora_ga_config, use_bdlora and others, all null or false) and often a null task_type. Unknown fields are accepted only when null, false, an empty object or an empty array. peft_version accepts any string, and task_type may be absent, null or CAUSAL_LM. Base-model identity is reported in AdapterInfo::declared_base_model / declared_revision rather than enforced, so callers can apply their own policy.
  • README. A new "Model LoRA Adapters" section describes the API, the accepted tensor names (base_model.model.model.layers.*), that a negative scale is accepted and inverts the adapter's update, and that adapter factors are kept on the host as f32 (about twice their on-disk size for F16 or BF16 factors, plus a temporary copy during installation). It also states that MicroLoRA and SONA operate on supplied embeddings rather than on generation.
  • CHANGELOG. An [Unreleased] / Added entry.

Depends on #1198

The first two commits are #1198 (lattice-inference 0.6.1 to 0.11.1, which provides the runtime adapter API, and its lockfile follow-up). Once #1198 merges, this pull request reduces to the last commit.

Checks

macOS (Apple silicon, Metal, rustc 1.93.0), --features lattice:

Command Result
cargo check --locked -p ruvllm@2.5.0 --features lattice --all-targets pass
cargo clippy --locked -p ruvllm@2.5.0 --features lattice --all-targets -- -W warnings pass, no warnings in the changed files
cargo test --locked -p ruvllm@2.5.0 --features lattice --lib validation_tests:: -- --test-threads=1 23 passed
cargo test --locked -p ruvllm@2.5.0 --features lattice --lib backends::lattice_backend::adapter_tests:: -- --test-threads=1 6 passed
cargo test --locked -p ruvllm@2.5.0 --features lattice --lib backends::tests:: -- --test-threads=1 7 passed
cargo test --locked -p ruvllm@2.5.0 --features lattice --lib backends::lattice_backend:: -- --test-threads=1 36 passed

The six adapter_tests run a small synthetic Qwen 3.5 model on Metal. They check that an adapter on down_proj, on the gated q_proj and on gate_proj each changes the logits and that unloading restores the base logits bit-for-bit; that a failed load leaves the base model and the active adapter unchanged; that a base model whose config.json the adapter validator rejects still loads, with the error reported at adapter load; and that ModelInfo::name keeps the caller's path string. For 27 individual checks (the unsupported defaults, the shape, rank and target validation, the rollback paths, the PEFT config rules, each projection's application, the deferred config error and the model name), removing that one check makes its test fail; each was removed and tested on its own. Changing the three adapter methods back to the crate-wide Result makes the test module fail to compile.

Linux x86_64 (rustc 1.98.0), default features:

Command Result
rustfmt --edition 2021 --check on the six changed Rust files pass
cargo check --locked -p ruvllm@2.5.0 --all-targets pass
cargo clippy --locked -p ruvllm@2.5.0 --all-targets -- -W warnings pass, no warnings in the changed files
cargo test --locked -p ruvllm@2.5.0 --lib backends::tests:: 7 passed

Real model: Qwen3.5-0.8B with an adapter trained and saved by PEFT 0.21.2 (r=8, alpha=16, all seven projections, 192 tensors, adapter_config.json unmodified, base_model_name_or_path set to the hub id). Through this API it loads, and greedy decoding of 12 tokens for "The three primary colors are" matches Hugging Face transformers with PEFT token for token (" the most commonly cited as the most common colors in the context"). After unload_lora_adapter the output equals the base model's, which also matches transformers (" red, green, and blue." ...).

API notes

  • The new public types ModelAdapterError and backends::AdapterInfo are #[non_exhaustive], so later variants or fields are not breaking changes.
  • AdapterInfo is not re-exported at the crate root, because lora and adapter_manager already define types with that name.

CI on main

On main, Tests (core-and-rest) and Tests (core-platform) currently fail (an ONNX link error in ruvector-typesafe-train and a read-latency assertion in mcp-brain-server, both tracked in #1178), and supply-chain fails on npm audit. None of the three failures is in ruvllm.

For the maintainer

  • No CI workflow enables the lattice feature, so the macOS tests above run only where someone runs them by hand. A macOS job running cargo test -p ruvllm --features lattice --lib backends:: would cover them.
  • The README's opening tagline and its comparison and feature tables describe MicroLoRA as per-request tuning of the model. As ruvllm: LoRA adapters never reach generation; add adapter loading to LlmBackend #1191 describes, MicroLoRA and SONA transform vectors the caller passes in and do not change generated text. The same tables list "5 pre-trained LoRA adapters"; the code defines five adapter configuration presets (RuvLtraAdapters::new) and ships no weights. I left that existing text unchanged and added only the one clarifying paragraph in the new section. I can update the existing wording too if you would like.

Refs #1191

Moves the optional lattice dependencies from 0.6.1 to 0.11.1 in
ruvector-core and ruvllm, with the lockfile resolved for the new
versions (lattice-fann 0.11.1 enters through the metal-gpu feature).

lattice-inference 0.11 exports GenerateConfig at the crate root and marks
it non_exhaustive, so the backend now imports it from there and builds it
from its default with the mapped fields assigned one by one.

Refs ruvnet#1196
Add load_lora_adapter, unload_lora_adapter and active_lora_adapter to
LlmBackend. They return ModelAdapterError, so RuvLLMError is unchanged,
and the default implementations return a typed unsupported error, so
other backends compile unchanged. The macOS Lattice backend validates a
PEFT SafeTensors adapter against the loaded Qwen 3.5 model and installs
it through the runtime's adapter hot-swap without reloading the model.
A failed load keeps the previous adapter, and unloading restores
base-model generation.

Refs ruvnet#1191
@ruvnet

ruvnet commented Oct 24, 2026

Copy link
Copy Markdown
Owner

Verified locally together with #1200/#1204/#1203 merged onto current main: cargo fmt --check, cargo check -p ruvllm --no-default-features, cargo test -p ruvllm --lib (1656 passed) and clippy -D warnings on the lib are clean; the e2e test target builds. Merging.

@ruvnet
ruvnet merged commit 1ef972b into ruvnet:main Oct 24, 2026
44 of 74 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants