loraport 0.1.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
loraport/__init__.py ADDED
@@ -0,0 +1,30 @@
1
+ """loraport: convert LoRA adapters between MLX (mlx-lm) and PEFT and verify dW."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.3"
6
+
7
+ from .convert import ConvertResult, convert # noqa: E402
8
+ from .delta import Report, compare # noqa: E402
9
+ from .detect import load_adapter # noqa: E402
10
+ from .errors import AdapterFormatError, BaseConfigError, LoraportError, UnsupportedAdapterError # noqa: E402
11
+ from .formats.mlx import write_mlx as save_mlx # noqa: E402
12
+ from .formats.peft import write_peft as save_peft # noqa: E402
13
+ from .model import Adapter, LoraModule # noqa: E402
14
+
15
+ __all__ = [
16
+ "Adapter",
17
+ "AdapterFormatError",
18
+ "BaseConfigError",
19
+ "ConvertResult",
20
+ "LoraModule",
21
+ "LoraportError",
22
+ "Report",
23
+ "UnsupportedAdapterError",
24
+ "__version__",
25
+ "compare",
26
+ "convert",
27
+ "load_adapter",
28
+ "save_mlx",
29
+ "save_peft",
30
+ ]
loraport/basecfg.py ADDED
@@ -0,0 +1,92 @@
1
+ """Cross-check adapter tensor shapes against a base model ``config.json``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ from .errors import BaseConfigError
10
+ from .model import Adapter, layer_of, terminal
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class BaseDims:
15
+ num_layers: int
16
+ hidden: int
17
+ intermediate: int | None
18
+ heads: int
19
+ kv_heads: int
20
+ head_dim: int
21
+
22
+ def expected(self, term: str) -> tuple[int, int] | None:
23
+ """Expected ``(in, out)`` of a well-known Llama-style projection, else None."""
24
+ q_out = self.heads * self.head_dim
25
+ kv_out = self.kv_heads * self.head_dim
26
+ table = {
27
+ "q_proj": (self.hidden, q_out),
28
+ "k_proj": (self.hidden, kv_out),
29
+ "v_proj": (self.hidden, kv_out),
30
+ "o_proj": (q_out, self.hidden),
31
+ }
32
+ if self.intermediate is not None:
33
+ table |= {
34
+ "gate_proj": (self.hidden, self.intermediate),
35
+ "up_proj": (self.hidden, self.intermediate),
36
+ "down_proj": (self.intermediate, self.hidden),
37
+ }
38
+ return table.get(term)
39
+
40
+
41
+ def load_base_config(path: str | Path) -> BaseDims:
42
+ """Read ``config.json`` (a file, or a folder that contains it)."""
43
+ p = Path(path)
44
+ if p.is_dir():
45
+ p = p / "config.json"
46
+ try:
47
+ cfg = json.loads(p.read_text(encoding="utf-8"))
48
+ except (OSError, json.JSONDecodeError) as e:
49
+ raise BaseConfigError(f"{p}: cannot read base model config ({e})") from e
50
+ if isinstance(cfg.get("text_config"), dict): # multimodal wrappers
51
+ cfg = cfg["text_config"]
52
+ try:
53
+ hidden = int(cfg["hidden_size"])
54
+ heads = int(cfg["num_attention_heads"])
55
+ layers = int(cfg["num_hidden_layers"])
56
+ except (KeyError, TypeError, ValueError) as e:
57
+ raise BaseConfigError(
58
+ f"{p}: needs hidden_size, num_attention_heads and num_hidden_layers (missing or invalid: {e})"
59
+ ) from e
60
+ inter = cfg.get("intermediate_size")
61
+ return BaseDims(
62
+ layers,
63
+ hidden,
64
+ int(inter) if inter is not None else None,
65
+ heads,
66
+ int(cfg.get("num_key_value_heads") or heads),
67
+ int(cfg.get("head_dim") or hidden // heads),
68
+ )
69
+
70
+
71
+ def check_adapter(adapter: Adapter, dims: BaseDims) -> list[str]:
72
+ """Return human-readable problems (empty list means consistent)."""
73
+ problems: list[str] = []
74
+ for name in sorted(adapter.modules):
75
+ mod = adapter.modules[name]
76
+ layer = layer_of(name)
77
+ if layer is not None and not 0 <= layer < dims.num_layers:
78
+ problems.append(
79
+ f"{name}: layer {layer} is outside the base model's range 0..{dims.num_layers - 1} "
80
+ f"(num_hidden_layers={dims.num_layers})"
81
+ )
82
+ exp = dims.expected(terminal(name))
83
+ if exp is None or not mod.consistent:
84
+ continue
85
+ r = mod.rank
86
+ want_a, want_b = (r, exp[0]), (exp[1], r)
87
+ if tuple(mod.A.shape) != want_a or tuple(mod.B.shape) != want_b:
88
+ problems.append(
89
+ f"{name}: expected A{want_a} B{want_b} for the base model, "
90
+ f"got A{tuple(mod.A.shape)} B{tuple(mod.B.shape)}"
91
+ )
92
+ return problems
loraport/cli.py ADDED
@@ -0,0 +1,158 @@
1
+ """Command line interface: inspect, convert, verify, demo.
2
+
3
+ Exit codes: 0 success / verification passed, 1 verification failed or tensors
4
+ were dropped, 2 invalid input or unsupported adapter.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import json
11
+ import sys
12
+ from collections.abc import Sequence
13
+ from pathlib import Path
14
+
15
+ from . import __version__
16
+ from .convert import convert, ensure_supported
17
+ from .delta import compare
18
+ from .detect import load_adapter
19
+ from .errors import LoraportError, UnsupportedAdapterError
20
+ from .render import adapter_summary, render_inspect, render_report
21
+ from .sample import TRAP_NAMES, make_dora_adapter, make_mlx_adapter, make_trap, write_base_config
22
+
23
+
24
+ def _cmd_inspect(args: argparse.Namespace) -> int:
25
+ adapter = load_adapter(args.path)
26
+ if args.json:
27
+ print(json.dumps(adapter_summary(adapter, str(args.path)), indent=2))
28
+ else:
29
+ print(render_inspect(adapter, str(args.path)))
30
+ return 0
31
+
32
+
33
+ def _cmd_convert(args: argparse.Namespace) -> int:
34
+ res = convert(
35
+ args.src,
36
+ args.dst,
37
+ args.to,
38
+ base_config=args.base_config,
39
+ base_model_id=args.base_model_id,
40
+ verify=not args.no_verify,
41
+ rtol=args.rtol,
42
+ force=args.force,
43
+ )
44
+ print(f"wrote {args.to} adapter to {res.dst}")
45
+ for note in res.notes:
46
+ print(f"note: {note}")
47
+ if res.report is not None:
48
+ print(render_report(res.report, "source", "converted"))
49
+ else:
50
+ print("verification skipped (--no-verify)")
51
+ if res.dropped:
52
+ print(f" source tensors that were NOT carried over ({len(res.dropped)}):")
53
+ for line in res.dropped[:12]:
54
+ print(f" - {line}")
55
+ if res.ok:
56
+ print("next steps:")
57
+ for line in res.next_steps:
58
+ print(f" {line}")
59
+ return 0
60
+ print("conversion is NOT safe to use: see the problems above", file=sys.stderr)
61
+ return 1
62
+
63
+
64
+ def _cmd_verify(args: argparse.Namespace) -> int:
65
+ a, b = load_adapter(args.a), load_adapter(args.b)
66
+ ensure_supported(a)
67
+ ensure_supported(b)
68
+ report = compare(a, b, rtol=args.rtol)
69
+ if args.json:
70
+ print(json.dumps(report.to_dict(), indent=2))
71
+ else:
72
+ print(render_report(report, str(args.a), str(args.b)))
73
+ return 0 if report.ok else 1
74
+
75
+
76
+ def _headline(res_report) -> str: # type: ignore[no-untyped-def]
77
+ return res_report.headline() if res_report is not None else "not verified"
78
+
79
+
80
+ def _cmd_demo(args: argparse.Namespace) -> int:
81
+ out = Path(args.out)
82
+ print("loraport demo: synthetic practice adapters (mock), random numbers, not a trained model")
83
+ mlx_dir = make_mlx_adapter(out / "mlx")
84
+ base_cfg = write_base_config(out / "base")
85
+ print(f"1. made a practice MLX adapter in {mlx_dir} (rank 4, scale 20, last 2 of 4 blocks)")
86
+
87
+ # real conversion + real verification, both directions
88
+ to_peft = convert(mlx_dir, out / "peft", "peft", force=True, base_config=base_cfg)
89
+ print(f"2. MLX -> PEFT: {_headline(to_peft.report)}")
90
+ back = convert(out / "peft", out / "mlx_roundtrip", "mlx", force=True, base_config=base_cfg)
91
+ print(f"3. PEFT -> MLX: {_headline(back.report)}")
92
+
93
+ print("4. common mistakes, each compared with the original MLX adapter:")
94
+ reference = load_adapter(mlx_dir)
95
+ caught = 0
96
+ for kind in TRAP_NAMES:
97
+ rep = compare(reference, load_adapter(make_trap(kind, mlx_dir, out / "traps" / kind)))
98
+ caught += 0 if rep.ok else 1
99
+ print(f" {kind:<18} {'caught' if not rep.ok else 'MISSED'}: {rep.headline()}")
100
+ print(f"{caught}/{len(TRAP_NAMES)} traps caught")
101
+ # extra folder for README section 5 (a DoRA adapter that convert must refuse); prints nothing
102
+ make_dora_adapter(mlx_dir, out / "dora")
103
+ return 0 if to_peft.ok and back.ok and caught == len(TRAP_NAMES) else 1
104
+
105
+
106
+ def build_parser() -> argparse.ArgumentParser:
107
+ p = argparse.ArgumentParser(
108
+ prog="loraport",
109
+ description="Convert LoRA adapters between MLX (mlx-lm) and PEFT and verify the weight delta.",
110
+ )
111
+ p.add_argument("--version", action="version", version=f"loraport {__version__}")
112
+ sub = p.add_subparsers(dest="command", required=True)
113
+
114
+ sp = sub.add_parser("inspect", help="show format, rank, scale and layers of an adapter folder")
115
+ sp.add_argument("path", type=Path)
116
+ sp.add_argument("--json", action="store_true", help="machine-readable output")
117
+ sp.set_defaults(func=_cmd_inspect)
118
+
119
+ sp = sub.add_parser("convert", help="convert an adapter to the other format and verify it")
120
+ sp.add_argument("src", type=Path)
121
+ sp.add_argument("dst", type=Path)
122
+ sp.add_argument("--to", choices=("peft", "mlx"), required=True)
123
+ sp.add_argument("--base-config", type=Path, help="base model config.json (or its folder) for shape/layer checks")
124
+ sp.add_argument("--base-model-id", help="hub id written into the output config (e.g. Qwen/Qwen2.5-1.5B-Instruct)")
125
+ sp.add_argument("--rtol", type=float, default=1e-5, help="max relative dW error (default 1e-5)")
126
+ sp.add_argument("--no-verify", action="store_true", help="skip the automatic verification")
127
+ sp.add_argument("--force", action="store_true", help="overwrite adapter files in dst")
128
+ sp.set_defaults(func=_cmd_convert)
129
+
130
+ sp = sub.add_parser("verify", help="compare the dW of two adapters (any formats)")
131
+ sp.add_argument("a", type=Path, help="reference adapter")
132
+ sp.add_argument("b", type=Path, help="adapter to check")
133
+ sp.add_argument("--rtol", type=float, default=1e-5)
134
+ sp.add_argument("--json", action="store_true")
135
+ sp.set_defaults(func=_cmd_verify)
136
+
137
+ sp = sub.add_parser("demo", help="run the whole flow on synthetic practice adapters (mock)")
138
+ sp.add_argument("--out", type=Path, default=Path("loraport-demo"))
139
+ sp.set_defaults(func=_cmd_demo)
140
+ return p
141
+
142
+
143
+ def main(argv: Sequence[str] | None = None) -> int:
144
+ args = build_parser().parse_args(argv)
145
+ try:
146
+ return int(args.func(args))
147
+ except UnsupportedAdapterError as e:
148
+ print("error: refusing to convert an unsupported adapter:", file=sys.stderr)
149
+ for reason in e.reasons:
150
+ print(f" - {reason}", file=sys.stderr)
151
+ return 2
152
+ except LoraportError as e: # includes BaseConfigError, AdapterFormatError
153
+ print(f"error: {e}", file=sys.stderr)
154
+ return 2
155
+
156
+
157
+ if __name__ == "__main__":
158
+ sys.exit(main())
loraport/convert.py ADDED
@@ -0,0 +1,126 @@
1
+ """Convert between MLX and PEFT adapter folders, then verify the result."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ from .basecfg import check_adapter, load_base_config
10
+ from .delta import Report, compare
11
+ from .detect import load_adapter
12
+ from .errors import AdapterFormatError, BaseConfigError, LoraportError, UnsupportedAdapterError
13
+ from .formats.common import CONFIG_NAME
14
+ from .formats.mlx import WEIGHTS_NAME as MLX_WEIGHTS
15
+ from .formats.mlx import write_mlx
16
+ from .formats.peft import WEIGHTS_NAME as PEFT_WEIGHTS
17
+ from .formats.peft import write_peft
18
+ from .model import Adapter
19
+
20
+ Target = Literal["peft", "mlx"]
21
+
22
+
23
+ @dataclass
24
+ class ConvertResult:
25
+ dst: Path
26
+ adapter: Adapter # the source adapter as read
27
+ report: Report | None # None when verification was skipped
28
+ next_steps: list[str] = field(default_factory=list)
29
+ notes: list[str] = field(default_factory=list)
30
+ dropped: list[str] = field(default_factory=list) # source tensors that did not make it into the output
31
+
32
+ @property
33
+ def ok(self) -> bool:
34
+ """True only if nothing was dropped and (when verified) the report passed."""
35
+ return not self.dropped and (self.report is None or self.report.ok)
36
+
37
+
38
+ def ensure_supported(adapter: Adapter) -> None:
39
+ """Raise ``UnsupportedAdapterError`` if the adapter uses a feature we refuse to convert."""
40
+ if adapter.unsupported:
41
+ raise UnsupportedAdapterError(adapter.unsupported)
42
+
43
+
44
+ def _validate(adapter: Adapter) -> None:
45
+ ensure_supported(adapter)
46
+ if not adapter.modules:
47
+ raise AdapterFormatError("no LoRA modules found in the adapter; nothing to convert")
48
+ bad = []
49
+ for name, mod in sorted(adapter.modules.items()):
50
+ if not mod.consistent:
51
+ bad.append(f"{name}: A{tuple(mod.A.shape)} and B{tuple(mod.B.shape)} do not share the rank dimension")
52
+ elif mod.rank != adapter.rank:
53
+ bad.append(f"{name}: tensor rank {mod.rank} differs from the configured rank {adapter.rank}")
54
+ if bad:
55
+ raise AdapterFormatError(
56
+ "inconsistent adapter, refusing to convert (was lora_A/lora_B transposed or the config edited?): "
57
+ + "; ".join(bad[:5])
58
+ + (f" (+{len(bad) - 5} more)" if len(bad) > 5 else "")
59
+ )
60
+
61
+
62
+ def _next_steps(to: Target, dst: Path, base: str | None, num_layers_unknown: bool) -> list[str]:
63
+ base_ref = base or "<BASE_MODEL>"
64
+ if to == "peft":
65
+ return [
66
+ f"vLLM: vllm serve {base_ref} --enable-lora --lora-modules my-adapter={dst}",
67
+ f"llama.cpp: python convert_lora_to_gguf.py --base <BASE_MODEL_DIR> --outfile adapter.gguf {dst}",
68
+ ]
69
+ steps = [f'mlx-lm: mlx_lm.generate --model {base_ref} --adapter-path {dst} --prompt "Hello"']
70
+ if num_layers_unknown:
71
+ steps.append("num_layers=-1 was written (no --base-config); blocks without tensors keep dW=0.")
72
+ return steps
73
+
74
+
75
+ def convert(
76
+ src: str | Path,
77
+ dst: str | Path,
78
+ to: Target,
79
+ *,
80
+ base_config: str | Path | None = None,
81
+ base_model_id: str | None = None,
82
+ verify: bool = True,
83
+ rtol: float = 1e-5,
84
+ force: bool = False,
85
+ ) -> ConvertResult:
86
+ """Convert the adapter in ``src`` to ``to`` ("peft" or "mlx") inside ``dst``.
87
+
88
+ Raises ``UnsupportedAdapterError`` / ``AdapterFormatError`` / ``BaseConfigError``
89
+ before writing anything. Tensors the converter could not carry over are
90
+ never silent: they show up in ``ConvertResult.dropped`` and in the report.
91
+ """
92
+ if to not in ("peft", "mlx"):
93
+ raise LoraportError(f"unknown target format {to!r}; use 'peft' or 'mlx'")
94
+ adapter = load_adapter(src)
95
+ _validate(adapter)
96
+ if adapter.format == to:
97
+ raise AdapterFormatError(
98
+ f"{src} is already a {to} adapter; convert to the other format "
99
+ f"(--to {'mlx' if to == 'peft' else 'peft'}) or use `loraport verify`"
100
+ )
101
+ total_layers = None
102
+ if base_config is not None:
103
+ dims = load_base_config(base_config)
104
+ problems = check_adapter(adapter, dims)
105
+ if problems:
106
+ raise BaseConfigError(
107
+ "adapter does not fit the base model config:\n " + "\n ".join(problems), problems
108
+ )
109
+ total_layers = dims.num_layers
110
+
111
+ out = Path(dst)
112
+ if not force and any((out / n).exists() for n in (PEFT_WEIGHTS, MLX_WEIGHTS, CONFIG_NAME)):
113
+ raise LoraportError(f"{out} already contains adapter files; pass --force to overwrite them")
114
+
115
+ if to == "peft":
116
+ write_peft(adapter, out, base_model_id=base_model_id)
117
+ else:
118
+ write_mlx(adapter, out, total_layers=total_layers, base_model_id=base_model_id)
119
+
120
+ result_adapter = load_adapter(out)
121
+ dropped = list(adapter.key_issues)
122
+ report = compare(adapter, result_adapter, rtol=rtol) if verify else None
123
+ notes = list(adapter.warnings)
124
+ return ConvertResult(
125
+ out, adapter, report, _next_steps(to, out, base_model_id, to == "mlx" and total_layers is None), notes, dropped
126
+ )
loraport/delta.py ADDED
@@ -0,0 +1,129 @@
1
+ """Compare adapters by their weight delta dW = scale * B @ A without building dW.
2
+
3
+ For ``U = [s1*B1, -s2*B2]`` (out x (r1+r2)) and ``V = [A1; A2]`` we have
4
+ ``dW1 - dW2 = U @ V`` and ``||U V||_F^2 = trace((U^T U)(V V^T))``, which only
5
+ needs (r1+r2)-square matrices. Everything runs in float64. Squaring limits the
6
+ precision floor of a relative error to about 1e-8, far below any real mistake.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import math
12
+ from dataclasses import dataclass, field
13
+ from typing import Any
14
+
15
+ import numpy as np
16
+
17
+ from .model import Adapter, LoraModule
18
+
19
+ _TINY = 1e-30
20
+
21
+
22
+ def _sq_norm(U: np.ndarray, V: np.ndarray) -> float:
23
+ gram_u = U.T @ U
24
+ gram_v = V @ V.T
25
+ return max(float(np.sum(gram_u * gram_v)), 0.0) # both Gram matrices are symmetric
26
+
27
+
28
+ def delta_norm(m: LoraModule) -> float:
29
+ """Frobenius norm of dW."""
30
+ A = m.A.astype(np.float64)
31
+ B = m.B.astype(np.float64)
32
+ return math.sqrt(_sq_norm(m.scale * B, A))
33
+
34
+
35
+ def diff_norm(m1: LoraModule, m2: LoraModule) -> float:
36
+ """Frobenius norm of dW1 - dW2 (shapes must match)."""
37
+ U = np.concatenate([m1.scale * m1.B.astype(np.float64), -m2.scale * m2.B.astype(np.float64)], axis=1)
38
+ V = np.concatenate([m1.A.astype(np.float64), m2.A.astype(np.float64)], axis=0)
39
+ return math.sqrt(_sq_norm(U, V))
40
+
41
+
42
+ def dense_delta(m: LoraModule) -> np.ndarray:
43
+ """Materialise dW (float64). Only for tests and small modules."""
44
+ return m.scale * (m.B.astype(np.float64) @ m.A.astype(np.float64))
45
+
46
+
47
+ def _shapes(m: LoraModule) -> str:
48
+ return f"A{tuple(m.A.shape)} B{tuple(m.B.shape)}"
49
+
50
+
51
+ @dataclass
52
+ class Report:
53
+ """Result of comparing a reference adapter ``a`` with a candidate ``b``."""
54
+
55
+ ok: bool
56
+ rtol: float
57
+ n_reference: int
58
+ missing: list[str] = field(default_factory=list) # in a, absent in b
59
+ extra: list[str] = field(default_factory=list) # in b, absent in a
60
+ shape_mismatch: list[str] = field(default_factory=list)
61
+ key_issues: list[str] = field(default_factory=list)
62
+ per_module: dict[str, float] = field(default_factory=dict) # relative dW error
63
+ worst_rel_err: float = 0.0
64
+ worst_module: str | None = None
65
+
66
+ @property
67
+ def bad_modules(self) -> list[str]:
68
+ return [n for n, e in self.per_module.items() if not e <= self.rtol]
69
+
70
+ def headline(self) -> str:
71
+ good = self.n_reference - len(self.missing) - len(self.shape_mismatch) - len(self.bad_modules)
72
+ if self.ok:
73
+ return f"PASS {self.n_reference}/{self.n_reference} modules, worst rel err {self.worst_rel_err:.1e}"
74
+ return (
75
+ f"FAIL {max(good, 0)}/{self.n_reference} modules match "
76
+ f"(missing {len(self.missing)}, extra {len(self.extra)}, shape mismatch {len(self.shape_mismatch)}, "
77
+ f"dW off {len(self.bad_modules)}, ignored/dropped tensors {len(self.key_issues)})"
78
+ )
79
+
80
+ def to_dict(self) -> dict[str, Any]:
81
+ def num(x: float) -> float | None:
82
+ return x if math.isfinite(x) else None
83
+
84
+ return {
85
+ "ok": self.ok,
86
+ "rtol": self.rtol,
87
+ "modules_in_reference": self.n_reference,
88
+ "missing": self.missing,
89
+ "extra": self.extra,
90
+ "shape_mismatch": self.shape_mismatch,
91
+ "key_issues": self.key_issues,
92
+ "bad_modules": self.bad_modules,
93
+ "worst_rel_err": num(self.worst_rel_err),
94
+ "worst_module": self.worst_module,
95
+ "per_module": {k: num(v) for k, v in self.per_module.items()},
96
+ }
97
+
98
+
99
+ def compare(a: Adapter, b: Adapter, *, rtol: float = 1e-5) -> Report:
100
+ """Compare per-module dW of ``a`` (reference) and ``b``.
101
+
102
+ The report is ``ok`` only if no module is missing/extra, no shape differs,
103
+ no tensor was ignored or dropped on either side, and every relative error
104
+ ``||dW_a - dW_b|| / ||dW_a||`` is within ``rtol``.
105
+ """
106
+ missing = sorted(a.modules.keys() - b.modules.keys())
107
+ extra = sorted(b.modules.keys() - a.modules.keys())
108
+ shape_mismatch: list[str] = []
109
+ per_module: dict[str, float] = {}
110
+ for name in sorted(a.modules.keys() & b.modules.keys()):
111
+ ma, mb = a.modules[name], b.modules[name]
112
+ if not (ma.consistent and mb.consistent) or ma.A.shape != mb.A.shape or ma.B.shape != mb.B.shape:
113
+ shape_mismatch.append(f"{name}: {_shapes(ma)} vs {_shapes(mb)}")
114
+ continue
115
+ ref = delta_norm(ma)
116
+ diff = diff_norm(ma, mb)
117
+ if ref < _TINY:
118
+ per_module[name] = 0.0 if diff < _TINY else math.inf
119
+ else:
120
+ per_module[name] = diff / ref
121
+ issues = [f"a: {s}" for s in a.key_issues] + [f"b: {s}" for s in b.key_issues]
122
+ worst_module = max(per_module, key=lambda n: per_module[n], default=None)
123
+ worst = per_module[worst_module] if worst_module else 0.0
124
+ ok = (
125
+ len(a.modules) > 0
126
+ and not (missing or extra or shape_mismatch or issues)
127
+ and all(e <= rtol for e in per_module.values())
128
+ )
129
+ return Report(ok, rtol, len(a.modules), missing, extra, shape_mismatch, issues, per_module, worst, worst_module)
loraport/detect.py ADDED
@@ -0,0 +1,40 @@
1
+ """Decide whether a folder holds an MLX or a PEFT adapter and load it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ from .errors import AdapterFormatError
8
+ from .formats.mlx import WEIGHTS_NAME as MLX_WEIGHTS
9
+ from .formats.mlx import read_mlx
10
+ from .formats.peft import WEIGHTS_NAME as PEFT_WEIGHTS
11
+ from .formats.peft import read_peft
12
+ from .model import Adapter
13
+
14
+
15
+ def detect_format(path: Path) -> str:
16
+ """Return ``"mlx"`` or ``"peft"`` based on the weight file name in ``path``."""
17
+ if not path.exists():
18
+ raise AdapterFormatError(f"{path}: no such directory (check the path)")
19
+ if not path.is_dir():
20
+ raise AdapterFormatError(f"{path}: not a directory (pass the adapter folder, not a file)")
21
+ has_mlx = (path / MLX_WEIGHTS).is_file()
22
+ has_peft = (path / PEFT_WEIGHTS).is_file()
23
+ if has_mlx and has_peft:
24
+ raise AdapterFormatError(
25
+ f"{path}: contains both {MLX_WEIGHTS} and {PEFT_WEIGHTS}; keep one adapter per folder"
26
+ )
27
+ if has_mlx:
28
+ return "mlx"
29
+ if has_peft:
30
+ return "peft"
31
+ raise AdapterFormatError(
32
+ f"{path}: no adapter found; expected {MLX_WEIGHTS} (mlx-lm) or {PEFT_WEIGHTS} (PEFT) "
33
+ "together with adapter_config.json"
34
+ )
35
+
36
+
37
+ def load_adapter(path: str | Path) -> Adapter:
38
+ """Load an adapter folder in either format."""
39
+ p = Path(path)
40
+ return read_mlx(p) if detect_format(p) == "mlx" else read_peft(p)
loraport/errors.py ADDED
@@ -0,0 +1,29 @@
1
+ """Exceptions raised by loraport. The CLI maps every ``LoraportError`` to exit code 2."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Iterable
6
+
7
+
8
+ class LoraportError(Exception):
9
+ """Base class for all expected, user-fixable errors."""
10
+
11
+
12
+ class AdapterFormatError(LoraportError):
13
+ """The folder is not a readable MLX or PEFT LoRA adapter (or cannot be written)."""
14
+
15
+
16
+ class UnsupportedAdapterError(LoraportError):
17
+ """The adapter uses a feature loraport refuses to convert instead of silently breaking it."""
18
+
19
+ def __init__(self, reasons: Iterable[str]) -> None:
20
+ self.reasons: list[str] = list(reasons)
21
+ super().__init__("unsupported adapter: " + "; ".join(self.reasons))
22
+
23
+
24
+ class BaseConfigError(LoraportError):
25
+ """The base model ``config.json`` is unreadable or disagrees with the adapter tensors."""
26
+
27
+ def __init__(self, message: str, problems: Iterable[str] = ()) -> None:
28
+ self.problems: list[str] = list(problems)
29
+ super().__init__(message)
@@ -0,0 +1 @@
1
+ """Format readers and writers (``mlx``, ``peft``)."""
@@ -0,0 +1,56 @@
1
+ """Helpers shared by the MLX and PEFT readers/writers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ import numpy as np
10
+
11
+ from ..errors import AdapterFormatError
12
+ from ..model import LoraModule
13
+ from ..st_io import SafetensorsFile
14
+
15
+ CONFIG_NAME = "adapter_config.json"
16
+
17
+
18
+ def read_json(path: Path) -> dict[str, Any]:
19
+ try:
20
+ data = json.loads(path.read_text(encoding="utf-8"))
21
+ except FileNotFoundError as e:
22
+ raise AdapterFormatError(f"{path}: file not found") from e
23
+ except (OSError, json.JSONDecodeError) as e:
24
+ raise AdapterFormatError(f"{path}: cannot parse JSON ({e})") from e
25
+ if not isinstance(data, dict):
26
+ raise AdapterFormatError(f"{path}: expected a JSON object")
27
+ return data
28
+
29
+
30
+ def pair_modules(
31
+ st: SafetensorsFile,
32
+ a_keys: dict[str, str],
33
+ b_keys: dict[str, str],
34
+ scale: float,
35
+ transpose: bool,
36
+ issues: list[str],
37
+ a_leaf: str,
38
+ b_leaf: str,
39
+ ) -> dict[str, LoraModule]:
40
+ """Read A/B tensors that have a partner; report the ones that do not.
41
+
42
+ ``a_keys``/``b_keys`` map module name -> tensor key. When ``transpose`` is
43
+ true the tensors are in MLX layout and are transposed to ``(r,in)``/``(out,r)``.
44
+ """
45
+ modules: dict[str, LoraModule] = {}
46
+ for name in sorted(a_keys.keys() - b_keys.keys()):
47
+ issues.append(f"{a_keys[name]}: no matching {b_leaf} tensor, module {name!r} is incomplete and was skipped")
48
+ for name in sorted(b_keys.keys() - a_keys.keys()):
49
+ issues.append(f"{b_keys[name]}: no matching {a_leaf} tensor, module {name!r} is incomplete and was skipped")
50
+ for name in sorted(a_keys.keys() & b_keys.keys()):
51
+ a = st.read(a_keys[name])
52
+ b = st.read(b_keys[name])
53
+ if transpose:
54
+ a, b = a.T, b.T
55
+ modules[name] = LoraModule(np.ascontiguousarray(a), np.ascontiguousarray(b), scale)
56
+ return modules