export: expose --adapter-mix-method (linear|svd); update CLI reference (audit-08 N4)
- SHA
706aae74eb2211d53fcd8ee44f7976c2a65de985- Parents
-
7cb08b9 - Tree
fde4826
706aae7
706aae74eb2211d53fcd8ee44f7976c2a65de9857cb08b9
fde4826| Status | File | + | - |
|---|---|---|---|
| M |
docs/cli/reference.md
|
2 | 1 |
| M |
src/dlm/cli/commands.py
|
25 | 1 |
| M |
src/dlm/export/weighted_merge.py
|
20 | 2 |
| M |
tests/unit/cli/test_export_adapter_flag.py
|
21 | 0 |
docs/cli/reference.mdmodified@@ -110,7 +110,8 @@ dlm export <path> [--quant Q] [--merged [--dequantize]] | ||
| 110 | 110 | | `--draft TAG` | auto | Override the speculative-decoding draft model. | |
| 111 | 111 | | `--no-draft` | false | Disable speculative decoding. Mutex with `--draft`. | |
| 112 | 112 | | `--adapter NAME` | None | Export a single named adapter from `training.adapters`. Rejected on single-adapter documents. Mutex with `--adapter-mix`. | |
| 113 | -| `--adapter-mix SPEC` | None | Weighted composition like `knowledge:1.0,tone:0.5`. Produces one Ollama model by merging the named adapters linearly at export time. LoRA-only; QLoRA sources require `--dequantize --merged`. Mutex with `--adapter`. | | |
| 113 | +| `--adapter-mix SPEC` | None | Weighted composition like `knowledge:1.0,tone:0.5`. Produces one Ollama model by merging the named adapters at export time. LoRA-only; QLoRA sources require `--dequantize --merged`. Mutex with `--adapter`. | | |
| 114 | +| `--adapter-mix-method` | `linear` | PEFT merge strategy: `linear` (default; fast weighted sum) or `svd` (higher fidelity, heavier compute). Only meaningful with `--adapter-mix`. | | |
| 114 | 115 | |
| 115 | 116 | ### `dlm pack` |
| 116 | 117 | |
src/dlm/cli/commands.pymodified@@ -576,6 +576,18 @@ def export_cmd( | ||
| 576 | 576 | ), |
| 577 | 577 | ), |
| 578 | 578 | ] = None, |
| 579 | + adapter_mix_method: Annotated[ | |
| 580 | + str, | |
| 581 | + typer.Option( | |
| 582 | + "--adapter-mix-method", | |
| 583 | + help=( | |
| 584 | + "PEFT combination strategy for --adapter-mix. `linear` " | |
| 585 | + "(default) sums LoRA deltas; `svd` recomposes via SVD " | |
| 586 | + "(higher fidelity, heavier compute). Only meaningful " | |
| 587 | + "with --adapter-mix." | |
| 588 | + ), | |
| 589 | + ), | |
| 590 | + ] = "linear", | |
| 579 | 591 | verbose: Annotated[ |
| 580 | 592 | bool, |
| 581 | 593 | typer.Option("--verbose", help="Log each subprocess command as it launches."), |
@@ -651,6 +663,12 @@ def export_cmd( | ||
| 651 | 663 | "documents (this doc does not declare `training.adapters`)." |
| 652 | 664 | ) |
| 653 | 665 | raise typer.Exit(code=2) |
| 666 | + if adapter_mix_method not in ("linear", "svd"): | |
| 667 | + console.print( | |
| 668 | + f"[red]export:[/red] --adapter-mix-method must be " | |
| 669 | + f"`linear` or `svd`, got {adapter_mix_method!r}." | |
| 670 | + ) | |
| 671 | + raise typer.Exit(code=2) | |
| 654 | 672 | try: |
| 655 | 673 | entries = parse_mix_spec(adapter_mix) |
| 656 | 674 | validate_mix_against_declared(entries, set(adapters_declared)) |
@@ -718,7 +736,13 @@ def export_cmd( | ||
| 718 | 736 | base_model = AutoModelForCausalLM.from_pretrained( |
| 719 | 737 | str(cached.path), revision=spec.revision |
| 720 | 738 | ) |
| 721 | - merged = build_weighted_merged(base_model, store, spec, entries_typed) | |
| 739 | + merged = build_weighted_merged( | |
| 740 | + base_model, | |
| 741 | + store, | |
| 742 | + spec, | |
| 743 | + entries_typed, | |
| 744 | + combination_type=adapter_mix_method, # type: ignore[arg-type] | |
| 745 | + ) | |
| 722 | 746 | merge_dir = store.cache_dir_for( |
| 723 | 747 | "_export_merged_" + "_".join(n for n, _ in mix_entries) |
| 724 | 748 | ) |
src/dlm/export/weighted_merge.pymodified@@ -25,7 +25,7 @@ from __future__ import annotations | ||
| 25 | 25 | |
| 26 | 26 | import re |
| 27 | 27 | from dataclasses import dataclass |
| 28 | -from typing import TYPE_CHECKING, Any, Final | |
| 28 | +from typing import TYPE_CHECKING, Any, Final, Literal | |
| 29 | 29 | |
| 30 | 30 | from dlm.export.errors import ExportError |
| 31 | 31 | |
@@ -131,17 +131,35 @@ def validate_mix_against_declared( | ||
| 131 | 131 | ) |
| 132 | 132 | |
| 133 | 133 | |
| 134 | +CombinationType = Literal["linear", "svd"] | |
| 135 | +"""Allowed `add_weighted_adapter.combination_type` values (audit-08 N4). | |
| 136 | + | |
| 137 | +- `linear` (default): weighted sum of LoRA deltas. Fast; exact for | |
| 138 | + same-rank adapters; approximate for mixed ranks. | |
| 139 | +- `svd`: SVD-based recomposition. Higher fidelity across mixed ranks | |
| 140 | + at meaningful compute cost. | |
| 141 | + | |
| 142 | +PEFT exposes more (`cat`, `ties`, `dare_linear`, ...); we gate to the | |
| 143 | +two the project has tested end-to-end. Extending is a one-line | |
| 144 | +`Literal` bump once a new combination is validated. | |
| 145 | +""" | |
| 146 | + | |
| 147 | + | |
| 134 | 148 | def build_weighted_merged( # pragma: no cover - heavy path |
| 135 | 149 | base_model: Any, |
| 136 | 150 | store: StorePath, |
| 137 | 151 | spec: BaseModelSpec, # noqa: ARG001 # reserved for future use |
| 138 | 152 | entries: list[MixEntry], |
| 153 | + *, | |
| 154 | + combination_type: CombinationType = "linear", | |
| 139 | 155 | ) -> Any: |
| 140 | 156 | """Load each adapter, combine them via `add_weighted_adapter`. |
| 141 | 157 | |
| 142 | 158 | Returns a `PeftModel` with adapter `_export_merged` active. The |
| 143 | 159 | caller writes the merged adapter to an ephemeral directory and |
| 144 | 160 | hands it to the existing GGUF pipeline. |
| 161 | + | |
| 162 | + `combination_type` controls PEFT's merge strategy (audit-08 N4). | |
| 145 | 163 | """ |
| 146 | 164 | from peft import PeftModel |
| 147 | 165 | |
@@ -163,7 +181,7 @@ def build_weighted_merged( # pragma: no cover - heavy path | ||
| 163 | 181 | adapters=[e.name for e in entries], |
| 164 | 182 | weights=[e.weight for e in entries], |
| 165 | 183 | adapter_name=_MERGED_ADAPTER_NAME, |
| 166 | - combination_type="linear", | |
| 184 | + combination_type=combination_type, | |
| 167 | 185 | ) |
| 168 | 186 | model.set_adapter(_MERGED_ADAPTER_NAME) |
| 169 | 187 | return model |
tests/unit/cli/test_export_adapter_flag.pymodified@@ -162,3 +162,24 @@ class TestExportAdapterMixValidation: | ||
| 162 | 162 | text = _joined(result) |
| 163 | 163 | assert "not declared" in text |
| 164 | 164 | assert "ghost" in text |
| 165 | + | |
| 166 | + def test_unknown_mix_method_rejected(self, tmp_path: Path) -> None: | |
| 167 | + """Audit-08 N4: --adapter-mix-method accepts only linear|svd.""" | |
| 168 | + doc = _init_multi(tmp_path) | |
| 169 | + runner = CliRunner() | |
| 170 | + result = runner.invoke( | |
| 171 | + app, | |
| 172 | + [ | |
| 173 | + "--home", | |
| 174 | + str(tmp_path / "home"), | |
| 175 | + "export", | |
| 176 | + str(doc), | |
| 177 | + "--adapter-mix", | |
| 178 | + "knowledge:1.0,tone:0.5", | |
| 179 | + "--adapter-mix-method", | |
| 180 | + "dare_linear", # supported by PEFT but not exposed here | |
| 181 | + "--skip-ollama", | |
| 182 | + ], | |
| 183 | + ) | |
| 184 | + assert result.exit_code == 2 | |
| 185 | + assert "linear` or `svd`" in _joined(result) | |