Adapt

0.2.0

On-device LoRA personalization for iOS and macOS. Trains, evaluates and rolls back adapters locally — no server, no Python.
asaptf/swift-adapt

What's New

0.2.0 — replay buffer, promotion gate, night scheduler, macros

2026-08-10T14:59:15Z

Milestones 2 through 5, plus the inference layer that was split out of milestone 1.

Adapters trained with 0.1.0 are not comparable to adapters trained with 0.2.0: both the prompt formatting and
the default set of adapted modules changed. Retrain rather than carry old versions forward.

Added

  • AdaptInferenceAdaptSession: generation with or without the active adapter, digest verification before a
    load, adapter hot-swap that does not reload the model, cancellation mid-generation, and top-p and
    repetition-penalty sampling.
  • AdaptData — SQLite replay buffer, privacy budget, TTL pruning with a durable prune log, and a scrubber
    pipeline (email, IBAN, card, phone) that runs before anything is stored.
  • AdaptEval — the promotion gate: a held-out set pinned to the lineage, paired per-example cross-entropy, a
    one-sided Wilcoxon signed-rank test at α = 0.05, a minimum-evidence floor, and a broken-pin outcome that is
    neither a pass nor a failure. Abstaining and refusing are distinct in the type system, not two readings of one
    boolean.
  • AdaptScheduleAdaptPipeline (prune → sample → train → eval → promote), thermal, battery and memory
    policy, backoff on refusal only, and BGProcessingTask registration.
  • AdaptMacros@Personalizable(task:), @Prompt, @Completion.
  • adapt-cli eval, and a script that seeds a seven-night registry by training seven adapters in seven separate
    processes, each resuming from the one before.
  • Examples: StyleMirror (macOS demo) and QuickReply (iOS skeleton).

Changed

  • Training and generation both go through the model's chat template. One formatter serves both paths, the
    convention is recorded in the adapter's metadata, and a session refuses an adapter trained under a different
    convention instead of generating subtly wrong output. On Qwen3-4B-4bit at 100 steps this moved the loss from
    9.63 to 1.49.
  • LoRA target modules are explicit, and the default adapts attention projections only. Adapting the MLP
    projections as well costs 7.3M parameters against 2.6M for no visible gain in style, and reaches a lower training
    loss largely by memorising more. This changes the shape of a default-configuration adapter.
  • EvalReport carries the gate's decision fields: the primary metric, its direction, and the example and
    supervised-token counts behind the number.
  • Per-module error enums (AdaptCoreError, AdaptRegistryError, AdaptTrainError, …) instead of one shared enum.

Fixed

Twelve findings from an external review, the ones that could lose data or mislead:

  • A checkpoint whose state file was not written last, so a crash mid-write could leave the registry unreadable.
  • Gradient accumulation that weighted micro-batches equally regardless of how many tokens each contained.
  • .noData returned when data merely ran short rather than out.
  • LoRA initialisation that ignored the configured seed, making "same seed ⇒ same run" false.
  • A missing digest verification before generate loads an adapter.
  • Lineage IDs used in path construction without validation.

Known limitations

  • On-device training has not been measured on a physical iPhone. The iOS build compiles and links and the pipeline
    runs in the simulator; step cost, thermal behaviour and the background window on real hardware are unmeasured.
  • Encrypted sync between devices (M6) is not built.
  • The demo app promotes through a provisional threshold, not AdaptEval's gate.
  • The demo's blind test never completed in the app: launching straight onto the screen raced the state load, so
    generation never started. Fixed after 0.2.0 — see Unreleased.
  • A rank-8 adapter over a corpus that is 20% Spanish and 20% Russian does not hold a non-English voice. The
    multilingual demo screen was cut rather than tuned.

Adapt

Adapt is a Swift library that lets an iOS or macOS app ship a language model that gradually gets better at its specific user, entirely on-device. The app collects training signal, Adapt trains a LoRA adapter on it locally, evaluates the result on-device, and promotes the new adapter only if it beats the one in use. No server, no Python, no data leaving the machine.

Training a real adapter on-device

The demo app during a real training pass — loss curve, tokens/sec, step count, time remaining, streamed from the loop in Sources/AdaptTrain. The faint line is raw per-step loss; at batch size 1 it swings across the whole axis, so the solid line is its smoothed trend and the card says so. If the model or the seeded registry is missing, the app falls back to a scripted engine and marks the run SCRIPTED in red — which engine is running changes what every number on screen means.

Why this exists

Apple's Foundation Models framework accepts custom LoRA adapters, but you train them offline on a Mac with Apple's Python toolkit. MLX Swift can train LoRA on-device, but ships as example code. What's missing is the product around the training loop: collecting signal, running training when the device can afford it, deciding whether the new adapter is actually better, rolling back when it isn't. Python can't fill that gap — it doesn't run on iOS, and the work is mostly OS integration: background tasks, thermal state, battery, Keychain, CloudKit. Adapt is that layer, plus the training and inference under it.

Status

Version 0.2.0. Five of the six planned milestones are built and covered by tests; milestone 1 was also reviewed externally. Built and working today:

  • AdaptCore — shared types. Adapter metadata never contains user text, only counts, date ranges and metric values.
  • AdaptData — SQLite replay buffer with a privacy budget, TTL pruning, and a scrubber pipeline that strips email addresses, IBANs, card and phone numbers before anything reaches storage.
  • AdaptRegistry — versioned adapter store. Atomic promote and rollback (rollback is a pointer flip; weights are never rewritten), SHA-256 integrity digests. Crash-safe: a process killed mid-checkpoint leaves the store readable.
  • AdaptTrain — interruption-safe LoRA training over MLX. Checkpoints every N steps; interrupting and resuming reproduces the uninterrupted run's loss curve and final weights to within 1e-5. It implements its own AdamW, because MLX's optimizer state cannot be serialized through its public API and resume would otherwise be impossible.
  • AdaptInference — loads the active adapter, streams generation, and hot-swaps adapters without reloading the model.
  • AdaptEval — the promotion gate. A held-out set is pinned to the lineage and compared example by example rather than in aggregate, with a one-sided Wilcoxon signed-rank test at α = 0.05. "Not enough evidence" is a separate outcome from "worse", because collapsing the two either blocks good adapters or ships bad ones.
  • AdaptSchedule — the night pipeline (prune → sample → train → eval → promote), thermal and battery policy, a memory capability gate, and BGProcessingTask registration. Promotion goes through the gate, never a bare score comparison.
  • AdaptMacros@Personalizable(task:), @Prompt, @Completion.
  • adapt-clitrain, generate, eval, inspect, promote from the terminal.

Measured on an M5 Pro:

  • 212 tests in 44 suites, all offline — no network, no model downloads in the test suite.
  • Training Qwen3-4B-4bit, rank 8, 16 adapted layers, 100 steps: 15 s, 2.4 GB peak memory, a 10 MB adapter of 2.6M parameters.
  • The default adapts attention projections only. Adapting the MLP projections as well costs 7.3M parameters and 28 MB for no visible gain in style, and reaches a lower training loss largely by memorising more.
  • Adapter hot-swap: 8 ms for a rank-8 adapter.

Not built, and not claimed:

  • Encrypted CloudKit sync between devices (M6).
  • On-device training on a physical iPhone. The iOS build compiles and links, and the pipeline runs in the simulator, but step cost, thermal behaviour and the real background window are unmeasured on hardware.
  • The demo app still promotes through a provisional threshold rather than AdaptEval's gate. The gate is library code with its own tests; the sample app has not been moved onto it.

Known limitation: generation quality

Generation quality is usable but not finished.

A controlled experiment with pre-registered pass criteria failed on all three test prompts in two sampling configurations: a rank-8 adapter reproduced the target voice's opening and sign-off, then repeated itself. Adding a repetition penalty did not fix it — the model simply cycled through variants of the sign-off instead of repeating it exactly.

The cause was that training and generation both bypassed the model's chat template, so the model was continuing a document rather than answering a question. Both paths now go through one shared formatter, the convention used is recorded in the adapter's metadata, and a session refuses an adapter trained under a different convention rather than generating subtly wrong output. Measured after the change on Qwen3-4B-4bit at 100 steps: loss 9.63 → 1.49, and the base model produces a chat-conditioned reply instead of placeholder templates.

Qwen3's default chat template enables a reasoning trace, which is fine for a library and wrong for a side-by-side comparison. GenerationOptions.chatTemplateEnableThinking turns it off (CLI: --chat-template-enable-thinking false) by passing the enable_thinking variable through mlx-swift-lm's template context; the default follows the model's template so nothing changes silently.

One demo screen is still not ready. The blind test needs about 90 seconds to generate its candidates in the app, against 3.8 seconds measured for the same work in a smoke test; the gap is in the app's path rather than the library's, and it is not fixed yet. A second screen was cut rather than tuned: a rank-8 adapter over a corpus that is 20% Spanish and 20% Russian does not hold a non-English voice, and the sampling knobs changed which way it failed instead of fixing it, so the multilingual claim was retired.

Two things are still open. Training 300 steps on 50 examples collapses the loss to 0.001 and bleeds training vocabulary into unrelated answers, so keep the step count low. AdaptEval measures held-out loss and refuses to promote a regression, but nothing stops a training run early on it. And a base model asked for an email reply writes about 500 characters where the adapter writes 60 — accurate, but it means a side-by-side comparison has to constrain both sides to the same length, or length alone gives the answer away.

Installation

dependencies: [
    .package(url: "https://github.com/asaptf/swift-adapt.git", from: "0.2.0"),
]

Each module is its own product, so an app that only generates text never links the training code:

.target(name: "YourApp", dependencies: [
    .product(name: "AdaptInference", package: "swift-adapt"),
    .product(name: "AdaptRegistry", package: "swift-adapt"),
])

Quickstart

git clone https://github.com/asaptf/swift-adapt.git && cd swift-adapt
swift build
swift test          # 212 tests, offline

# Train an adapter on 50 example replies in a distinctive voice
swift run -c release adapt-cli train \
  --data Tools/adapt-cli/Fixtures/nix-caldera-style.jsonl \
  --steps 100 \
  --model mlx-community/Qwen3-0.6B-4bit \
  --registry .build/demo-registry \
  --promote

# Compare base model against the adapter, side by side
swift run -c release adapt-cli generate \
  --prompt "Decline a meeting that conflicts with your watch." \
  --model mlx-community/Qwen3-0.6B-4bit \
  --registry .build/demo-registry

swift run adapt-cli inspect --registry .build/demo-registry

Library usage

let session = try await AdaptSession(
    model: .id("mlx-community/Qwen3-4B-4bit"),
    lineage: emailStyle,
    registry: registry,
    tokenizerLoader: tokenizerLoader   // you supply this — see below
)

for try await chunk in session.generate(prompt: draft) {
    print(chunk, terminator: "")
}

try await session.reload()   // picks up a newly promoted adapter; no model reload

The session loads the lineage's active adapter, verifies its digest, and falls back to the base model when there is no active version. Model download and tokenizer loading are injected rather than built in, which is why the library itself has no networking — adapt-cli supplies the Hugging Face implementations, and an app that ships weights in its bundle supplies neither.

Training data is JSONL, one object per line. prompt and completion are required; source and weight are optional.

Requirements

macOS 15+ or iOS 18+, Apple Silicon, Swift 6.3. The iOS device build is verified to compile and link (xcodebuild -destination 'generic/platform=iOS' -skipPackagePluginValidation); on-device training itself is not yet verified — see Examples/QuickReply/TESTING.md.

Dependencies: mlx-swift, mlx-swift-lm, swift-argument-parser. The library modules pull in no networking; the CLI adds Hugging Face packages for model download. That separation is deliberate — privacy is structural, not a setting. An app that links only the library has nothing that could send data anywhere.

Design

Three rules the code enforces rather than promises. An adapter that is worse than the current one is never shipped: promotion through the pipeline requires a measured win on a held-out set pinned to the lineage — the registry's own promote stays available for tools and tests that need to bypass it — and rollback is always a pointer flip away. Training runs only when the user won't notice; the scheduler checks thermal state, battery and available memory before it starts, and the training core can be interrupted at any step and resumed without drift. And Adapt is not tied to one model — anything MLX can load works.

Demo

Examples/StyleMirror is a macOS app built for a live five-minute demo, running on the real library. Its version history comes from scripts/seed-demo-registry.sh, which trains seven adapters in seven separate processes, each resuming from the one before.

Seeding it produced something the demo now leads with. The measured held-out loss over those seven nights went 4.06 → 3.70 → 3.38 → 3.42 → 3.19 → 3.12 → 3.34: night seven came out worse than night six, on ordinary mail, and became the active adapter anyway — because promotion is manual until the evaluation gate exists. That is this project's own "never degrade" promise failing for want of the mechanism that enforces it.

The gate refusing the adapter that regressed

QuickReply on iOS: capture writes to the buffer, the pipeline runs all five stages

Examples/QuickReply is the iOS side — a developer skeleton, not a designed app, with its training stage stubbed so the target builds without bundling a multi-gigabyte model. It does show the machinery running on iOS: capturing a reply writes to the real SQLite buffer, and the nightly pipeline completes prune → sample → train → eval → promote. The BGTaskSchedulerErrorDomain error 3 on screen is the simulator declining background-task registration; that path needs a physical device.

The full walkthrough, including the screens not shown here, is in docs/demo.md.

License

Apache-2.0. See LICENSE.

Description

  • Swift Tools 6.0.0
View More Packages from this Author

Dependencies

Last updated: Mon Aug 31 2026 10:35:28 GMT-0900 (Hawaii-Aleutian Daylight Time)