Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@ Versioning follows [Semantic Versioning](https://semver.org/).
## [Unreleased]

### Added
- **LoRA Foundation** (v0.5, part 1 of 2). Pure-Swift Core layer
for HuggingFace LoRA adapter discovery — no engine integration
yet, no UI. `LocalAdapter` value type holds adapter metadata
parsed from PEFT's `adapter_config.json` (`base_model_name_or_path`,
`r`, `lora_alpha`, `target_modules`, `peft_type`). `AdapterStore`
actor scans `~/.mac-mlx/adapters/<name>/` for directories that
contain both `adapter_config.json` and `adapter_model.safetensors`
— best-effort, malformed configs / missing weights silently drop.
10 new unit tests cover decode + scan paths. Engine application
of adapter weights via `LoRATrain.convert` + `loadLoRAWeights`
and the parameters-inspector picker land in v0.5 part 2.
- **Prompt cache tiering** (v0.4.0 engine parity, part 1 of 3).
Successive chat turns on the same model now reuse the KV cache
when the new prompt extends the previous one — the shared prefix
Expand Down
54 changes: 54 additions & 0 deletions MacMLXCore/Sources/MacMLXCore/Managers/AdapterStore.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import Foundation

/// Scans `~/.mac-mlx/adapters/<name>/` for PEFT-format LoRA adapters.
///
/// Mirrors `ModelLibraryManager` shape but for adapters: a directory
/// is recognised when it contains both `adapter_config.json` and
/// `adapter_model.safetensors`. Bad / unreadable configs silently
/// drop — the scan must not blow up because of one malformed
/// directory.
public actor AdapterStore {
private let fileManager: FileManager

public init(fileManager: FileManager = .default) {
self.fileManager = fileManager
}

/// Enumerate adapters under `directory`. Sorted by `name`
/// (case-insensitive locale compare) for stable UI rendering.
public func scan(_ directory: URL) async throws -> [LocalAdapter] {
// If the adapters directory hasn't been created yet, treat as
// empty — the user simply hasn't downloaded any adapters. The
// GUI is responsible for offering to create the directory.
guard fileManager.fileExists(atPath: directory.path) else { return [] }

let contents = try fileManager.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: [.isDirectoryKey],
options: [.skipsHiddenFiles]
)

var results: [LocalAdapter] = []
for url in contents {
guard try url.resourceValues(forKeys: [.isDirectoryKey]).isDirectory == true,
!url.lastPathComponent.hasPrefix(".") else { continue }

let configURL = url.appendingPathComponent("adapter_config.json")
let weightsURL = url.appendingPathComponent("adapter_model.safetensors")
guard fileManager.fileExists(atPath: configURL.path),
fileManager.fileExists(atPath: weightsURL.path),
let data = try? Data(contentsOf: configURL),
let cfg = try? JSONDecoder().decode(LocalAdapter.PEFTConfig.self, from: data)
else { continue }

results.append(LocalAdapter(
name: url.lastPathComponent,
directory: url,
targetModel: cfg.baseModelNameOrPath,
rank: cfg.r,
targetModules: cfg.targetModules ?? []
))
}
return results.sorted { $0.name.localizedCompare($1.name) == .orderedAscending }
}
}
73 changes: 73 additions & 0 deletions MacMLXCore/Sources/MacMLXCore/Models/LocalAdapter.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import Foundation

/// One LoRA adapter directory present on the local filesystem.
///
/// Discovered by `AdapterStore.scan(_:)` via the presence of
/// `adapter_config.json` + `adapter_model.safetensors` (PEFT format).
/// `targetModel` is advisory — the engine layer applies the adapter
/// regardless and surfaces a clear typed error if the dimensions
/// don't fit the loaded base model.
public struct LocalAdapter: Codable, Hashable, Identifiable, Sendable {
public var id: String { name }
public let name: String
public let directory: URL
/// Base-model id from the adapter's config (e.g.
/// `mlx-community/Qwen3-8B-4bit`). Optional — older adapters
/// don't always carry it.
public let targetModel: String?
/// LoRA rank (`r` in PEFT config). Nil if absent / unparseable.
public let rank: Int?
/// Names of the linear layers the adapter touches (e.g.
/// `["q_proj", "v_proj"]`). Empty array if absent.
public let targetModules: [String]

public init(
name: String,
directory: URL,
targetModel: String?,
rank: Int?,
targetModules: [String]
) {
self.name = name
self.directory = directory
self.targetModel = targetModel
self.rank = rank
self.targetModules = targetModules
}

/// On-disk PEFT `adapter_config.json` shape.
///
/// Exposed publicly so `AdapterStore` and tests can decode it
/// without re-deriving the schema. Mirrors the subset of HF PEFT
/// fields we currently consume — extend when we start honouring
/// `lora_dropout`, `bias`, etc.
public struct PEFTConfig: Codable, Hashable, Sendable {
public let baseModelNameOrPath: String?
public let r: Int?
public let loraAlpha: Int?
public let targetModules: [String]?
public let peftType: String?

public init(
baseModelNameOrPath: String?,
r: Int?,
loraAlpha: Int?,
targetModules: [String]?,
peftType: String?
) {
self.baseModelNameOrPath = baseModelNameOrPath
self.r = r
self.loraAlpha = loraAlpha
self.targetModules = targetModules
self.peftType = peftType
}

private enum CodingKeys: String, CodingKey {
case baseModelNameOrPath = "base_model_name_or_path"
case r
case loraAlpha = "lora_alpha"
case targetModules = "target_modules"
case peftType = "peft_type"
}
}
}
121 changes: 121 additions & 0 deletions MacMLXCore/Tests/MacMLXCoreTests/Managers/AdapterStoreTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import Testing
import Foundation
@testable import MacMLXCore

/// Filesystem-backed: serialised so swift-testing's parallel executor
/// doesn't thrash on the temp directory (same rationale as the v0.4.1
/// VLM detection suite).
@Suite("AdapterStore", .serialized)
struct AdapterStoreTests {

@Test
func scanFindsAdapterWithPEFTConfig() async throws {
let temp = try TempDir()
try writeAdapter(
in: temp.url,
name: "qwen3-medical",
targetModel: "mlx-community/Qwen3-8B-4bit",
r: 8
)
let store = AdapterStore()
let found = try await store.scan(temp.url)
#expect(found.count == 1)
#expect(found[0].name == "qwen3-medical")
#expect(found[0].rank == 8)
#expect(found[0].targetModel == "mlx-community/Qwen3-8B-4bit")
#expect(found[0].targetModules == ["q_proj", "v_proj"])
}

@Test
func scanIgnoresDirsWithoutAdapterConfig() async throws {
let temp = try TempDir()
let stray = temp.url.appendingPathComponent("not-an-adapter")
try FileManager.default.createDirectory(at: stray, withIntermediateDirectories: true)
let store = AdapterStore()
let found = try await store.scan(temp.url)
#expect(found.isEmpty)
}

@Test
func scanRequiresAdapterModelSafetensors() async throws {
// Has config but no safetensors → not a usable adapter.
let temp = try TempDir()
let dir = temp.url.appendingPathComponent("config-only")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try Data(#"{"r":8,"target_modules":["q_proj"]}"#.utf8)
.write(to: dir.appendingPathComponent("adapter_config.json"))
let store = AdapterStore()
let found = try await store.scan(temp.url)
#expect(found.isEmpty)
}

@Test
func scanIgnoresMalformedConfigJSON() async throws {
let temp = try TempDir()
let dir = temp.url.appendingPathComponent("malformed")
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
try Data("{not json".utf8)
.write(to: dir.appendingPathComponent("adapter_config.json"))
try Data().write(to: dir.appendingPathComponent("adapter_model.safetensors"))
let store = AdapterStore()
let found = try await store.scan(temp.url)
#expect(found.isEmpty)
}

@Test
func scanReturnsEmptyForMissingDirectory() async throws {
let temp = try TempDir()
let neverExisted = temp.url.appendingPathComponent("does-not-exist")
let store = AdapterStore()
let found = try await store.scan(neverExisted)
#expect(found.isEmpty)
}

@Test
func scanSortsAdaptersByName() async throws {
let temp = try TempDir()
try writeAdapter(in: temp.url, name: "zeta", targetModel: nil, r: 4)
try writeAdapter(in: temp.url, name: "alpha", targetModel: nil, r: 4)
try writeAdapter(in: temp.url, name: "mu", targetModel: nil, r: 4)
let store = AdapterStore()
let found = try await store.scan(temp.url)
#expect(found.map(\.name) == ["alpha", "mu", "zeta"])
}

private func writeAdapter(in root: URL, name: String, targetModel: String?, r: Int) throws {
let dir = root.appendingPathComponent(name)
try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true)
let cfg: String
if let targetModel {
cfg = """
{
"base_model_name_or_path": "\(targetModel)",
"r": \(r),
"lora_alpha": \(r * 2),
"target_modules": ["q_proj", "v_proj"],
"peft_type": "LORA"
}
"""
} else {
cfg = """
{
"r": \(r),
"target_modules": ["q_proj", "v_proj"],
"peft_type": "LORA"
}
"""
}
try Data(cfg.utf8).write(to: dir.appendingPathComponent("adapter_config.json"))
try Data().write(to: dir.appendingPathComponent("adapter_model.safetensors"))
}
}

private struct TempDir {
let url: URL
init() throws {
let base = FileManager.default.temporaryDirectory
.appendingPathComponent("macmlx-adapter-tests-\(UUID().uuidString)", isDirectory: true)
try FileManager.default.createDirectory(at: base, withIntermediateDirectories: true)
self.url = base
}
}
63 changes: 63 additions & 0 deletions MacMLXCore/Tests/MacMLXCoreTests/Models/LocalAdapterTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import Testing
import Foundation
@testable import MacMLXCore

@Suite("LocalAdapter")
struct LocalAdapterTests {

@Test
func decodesPEFTAdapterConfig() throws {
let json = """
{
"base_model_name_or_path": "mlx-community/Qwen3-8B-4bit",
"r": 8,
"lora_alpha": 16,
"target_modules": ["q_proj", "v_proj"],
"peft_type": "LORA"
}
"""
let cfg = try JSONDecoder().decode(LocalAdapter.PEFTConfig.self, from: Data(json.utf8))
#expect(cfg.baseModelNameOrPath == "mlx-community/Qwen3-8B-4bit")
#expect(cfg.r == 8)
#expect(cfg.loraAlpha == 16)
#expect(cfg.targetModules == ["q_proj", "v_proj"])
#expect(cfg.peftType == "LORA")
}

@Test
func decodesAdapterConfigMissingOptionalFields() throws {
let json = #"{"r": 4}"#
let cfg = try JSONDecoder().decode(LocalAdapter.PEFTConfig.self, from: Data(json.utf8))
#expect(cfg.r == 4)
#expect(cfg.baseModelNameOrPath == nil)
#expect(cfg.loraAlpha == nil)
#expect(cfg.targetModules == nil)
#expect(cfg.peftType == nil)
}

@Test
func roundTripsThroughJSON() throws {
let original = LocalAdapter(
name: "qwen3-medical-lora",
directory: URL(fileURLWithPath: "/tmp/medical"),
targetModel: "mlx-community/Qwen3-8B-4bit",
rank: 8,
targetModules: ["q_proj", "v_proj"]
)
let data = try JSONEncoder().encode(original)
let back = try JSONDecoder().decode(LocalAdapter.self, from: data)
#expect(back == original)
}

@Test
func idMirrorsName() {
let a = LocalAdapter(
name: "x",
directory: URL(fileURLWithPath: "/tmp/x"),
targetModel: nil,
rank: nil,
targetModules: []
)
#expect(a.id == "x")
}
}
Loading