Toolchain and asset compatibility: when your build artifacts stop working¶
Part 17 · Migration from pre-iOS 27 · Reference 06
Version floor. This guide assumes you have already shipped or exported something against the
26.x generation and are moving to iOS/iPadOS/macOS 27 and Xcode 27. The specific floors
that matter: Core AI is 27.0-only on every platform, with no 26.x back-deployment; coreai-torch
0.4.1 is the first release whose assets survive iOS/macOS 27 beta 2 and later; coreai-core
is currently pinned at 1.0.0b3 with converter 0.4.3; AOT compilation needs Xcode 27 plus the separately-downloaded Metal
Toolchain, and xcrun coreai-build runs on macOS 27 hosts; mlx-swift-lm main is 3.x
and breaks 2.x call sites; apple/foundation-models-utilities has shipped only prerelease tags.
Everything in this guide that carries a number was measured on beta software, and every number
says who measured it.
⚠️ SILENT FAILURE — this guide is nothing but silent failures. The rest of Part 17 is about source-level migration: renamed types, withdrawn capabilities,
catchclauses that stop firing. This guide is about the other half, the half nobody warns you about — your build artifacts have compatibility constraints that are independent of your source. Not one of the defects below is visible in a diff. Two of them do not produce a log line. One of them produces a successful compile that a device then refuses to load. And the single most expensive one produces a working, loadable, correct model that is 2.2× slower than the one you measured last week, from a byte-identical command.
What this covers¶
Version-stamp and validate the exact model artifact you ship. The incident comparisons show why converter, OS, toolchain, architecture, and compiled cache identity matter independently, and how to recover from incompatible assets.
What this does not cover¶
Related references: 17.2, 17.3, 17.4, Part 8, Part 9, Part 7 reference 02, Part 15 reference 01.
What you need¶
- A shell, and the ability to run
findandpython3 -cover the tree where your.aimodelbundles live. §3's audit is four lines and you should run it before you read §4. - Xcode 27 plus the Metal Toolchain component if you AOT-compile.
xcrun coreai-buildis a macOS 27 host tool — and the component is not optional decoration:coreai-buildlives in the Metal Toolchain, not in Xcode-beta.app (resolved 2026-07-31 — the app bundle carries only theaimodelcstub, which is why our 2026-07-29 check without the optional component could not resolvecoreai-build); see §7. - For the §3 recovery: the ability to create two isolated Python virtual environments with
different
coreai-corewheel generations in them. This is not optional; §3.6 explains why. - A real device for anything in §5, §6 or §7. Specialization is per-hardware and per-OS. A
Simulator result is not a result — and the
CoreAIframework is absent from the iOS Simulator SDK entirely (§7). - Somewhere to put an artifact archive. §10 is not useful retroactively.
Contents¶
- The thesis: your artifacts are not a pure function of your recipe
- The artifact inventory: five classes, five invalidation rules
- Incident 1 — the
coreai-torch0.4.0 IR-location break - 3.1 What it looks like - 3.2 The root cause, and why deep models fire it - 3.3 ⚠️ Why it is vicious:inspectstill works - 3.4 The negative list: four things that do not fix it - 3.5 The audit: theproducerfingerprint - 3.6 The fix, and the chicken-and-egg inside it - 3.7 The full recovery recipe - 3.8 What cannot be recovered - 3.9 Preventing a recurrence:Mode.RELEASE - Incident 2 — the macOS 26 → 27 export-lowering regression - 4.1 The A/B - 4.2 The op-level forensics - 4.3 The mechanism: one wheel, two native stacks - 4.4 The decisive negative result - 4.5 ⚠️ The lesson: the export host's OS version is an input to the model's performance - 4.6 The detection recipe - 4.7 The operational rule - 4.8 🔴 Status unknown
- Specialization artifacts are tied to the device and the OS version
- Bookmarks stop resolving — into a silent
else coreai-build compileexits 0 for architectures a device will rejectmetadata.json'scompressionfield records the request, not the result- Package-level migrations
- 9.1
mlx-swift-lm2.x → 3.x - 9.2foundation-models-utilities: the dependency line that resolves to nothing - 9.3apple/coreai-models#123 — the ecosystem chasing the same rename - The artifact provenance checklist
- Triage: what to check, in what order
- Sources and evidence ledger
1. The thesis: your artifacts are not a pure function of your recipe¶
Software engineers carry a strong intuition, learned from decades of build systems, that a build is
a function: same inputs, same outputs. Deterministic builds are a thing people work hard to achieve
and mostly get. Reproducible container images are a thing. Package.resolved exists so that a
swift build on your machine and a swift build in CI produce the same program.
On-device model conversion does not work like that, and the 26 → 27 transition is where a lot of teams discovered it.
Three separate facts, each of which is verified below, combine into the problem:
- The conversion toolchain writes version-sensitive metadata into the asset.
coreai-torch0.4.0 embedded PyTorch stack traces as MLIRfusedlocations. That was fine on iOS/macOS 27 beta 1 and became a hard load failure on beta 2 — a change in the consumer, not the producer, retroactively invalidating artifacts that were already published (§3). - The conversion toolchain consults the running OS when it lowers. The decision to fold
dequantisation into a native quantised-
Linearop, versus emitting explicit dequantise-then-matmul chains, is made by pass code that queries the platform underneath it. Upgrade the export box's OS and the same command emits a different program (§4). - The device-side artifact is regenerated on every OS update, and none of it is under your control. Specialization output is "tied to that device's hardware and OS version", and Apple documents the invalidation as unconditional (§5).
Put together: your .aimodel bundle, your .aimodelc compiled variant, and the device-side
specialized asset each have an independent compatibility window, and none of those windows is
described anywhere in your source tree.
✅ VERIFIED — the framing is Apple's too, at least for the last of the three. Apple, Managing model specialization and caching, verbatim: "When you load a
.aimodelfile withAIModel, Core AI performs specialization, the process of optimizing the model for the current device's hardware. The.aimodelfile contains your model in a portable format that works across Apple devices. Before the model can run, Core AI specializes it for the current device, producing executable code tied to that device's hardware and OS version."
Read the phrase "portable format that works across Apple devices" carefully. It is a claim about
devices, not about toolchain versions. Nothing in Apple's documentation claims that a
.aimodel produced by one converter release loads on a runtime from a later release, and §3 is the
case where it did not.
The two shapes of the problem¶
It is worth separating them, because the diagnostics are completely different.
| Hard incompatibility (§3) | Silent behaviour drift (§4) | |
|---|---|---|
| Symptom | Load fails, compile fails | Nothing fails |
| Diagnostic | An error string you can grep for | A benchmark, or nothing |
| Blast radius | Everything built with the bad producer | Everything built on the wrong host OS |
| How you find out | Users' crash reports, or your CI | A user says the app "feels slower" |
| How long it takes to fix | Hours per model | You do not know it needs fixing |
| Which is worse | This one. |
A hard incompatibility is a bad week. Silent behaviour drift is a bad quarter, because there is no moment at which anyone is forced to look. This is the reason §10 exists.
A note on evidence¶
Incident 1 combines Apple's converter issue #37/release notes and repair recipe in #44 with community operational forensics. Incident 2 is a community A/B from 2026-06-11 on one Mac and iPhone running beta software; its magnitude requires validation on the intended artifact and hardware. Keep those distinctions when using the recovery procedures. See the shared evidence conventions.
2. The artifact inventory: five classes, five invalidation rules¶
Before the incidents, an inventory. Most projects can name the first two entries in this table and are surprised by the rest.
| # | Artifact | Produced by | Where it lives | Invalidated by |
|---|---|---|---|---|
| 1 | .aimodel — portable source asset (a directory) |
coreai-torch → AIProgram.save_asset() |
Your build output; your CDN; the app bundle | The producer version, when a newer runtime rejects its IR (§3). Also the export host OS, in the sense that its contents differ (§4) |
| 2 | .aimodelc — AOT-compiled, per-architecture (also a directory) |
xcrun coreai-build compile |
Background Assets; the app bundle | The target architecture — and the compiler exits 0 whether or not you chose right (§7). Also toolchain generation: beta-2-or-earlier .aimodelc need a beta-3 recompile |
| 3 | Specialized cache entry | Core AI, on device | AIModelCache — location undocumented |
Every OS update, unconditionally. Also source-asset change/delete and storage pressure, depending on policy (§5) |
| 4 | Bookmark Data |
AIModel.bookmarkData |
Your UserDefaults, your database |
Whatever invalidates #3 — and it fails by returning nil, not throwing (§6) |
| 5 | metadata.json — the bundle manifest |
coreai_models.export.bundle (or you) |
Next to the .aimodel inside the bundle directory |
Nothing invalidates it, which is the problem: it records requests, and can outlive their truth (§8) |
Two structural facts about #1 and #2 that trip people up constantly and are worth stating once:
✅ VERIFIED —
.aimodelis a directory, not a file, and so is.aimodelc.apple/coreai-modelstreats it as a directory throughout, and its overwrite path callsshutil.rmtreeon it (historical caller behavior; b3save_assetitself replaces an existing destination).AIProgram.save_asset(path)writes a bundle containingmain.mlirb(the IR bytecode),main.hash, andmetadata.json. A compiled.aimodelccontainsmain-<arch>.mlirbplus amain-<arch>-delegatesdirectory.Consequences you will meet:
cpneeds-R; a file-oriented checksum script silently checksums nothing; and "delete the model" meansremoveItem(at:)on a directory URL.✅ VERIFIED — Apple,
AIModel.init(contentsOf:options:)parameter documentation:modelURLis "The URL of a.aimodelor.aimodelcfile". One initializer takes both. That is convenient and it is also why a mis-targeted.aimodelcproduces a runtime failure rather than a compile-time one — the type system cannot help you here (§7).
The one that surprises people¶
Class #3 is the one most teams have never thought about as an artifact at all, because it is produced on the user's device and they never see it. But it behaves exactly like a build output: it is expensive to produce, it is version-locked, and it is thrown away on a schedule you do not control.
✅ VERIFIED — Apple, Managing model specialization and caching, on
AIModelCache.Policy, verbatim: "Regardless of policy, the system always purges assets when the OS updates, as specialized assets are OS-version specific." The same sentence is repeated onPolicy.PurgeConditions: "The system always purges assets on OS update regardless of these conditions."
And the cost of regenerating it is not small:
Community-measured —
apple/coreai-models' own official Qwen3-4B iOS ANE preset, a 3 GB.aimodelc, on iPhone 17 Pro / iOS 27 beta: cold load 194 s, warm load 0.46 s. Same source, same protocol (512-token prompt / 1024-token generation, 5 trials). Measured by the author of thecoreai-model-zoocommunity repository; attribute as community-measured, on beta software.
194 seconds is what an OS update costs one of your users, once, for one model, if you have not planned for it. §5 is how you plan for it.
How the classes chain¶
PyTorch checkpoint
│
│ coreai-torch TorchConverter ← producer version is recorded (§3)
│ TorchConverter.to_coreai() rewrite ← lowering consults the HOST OS (§4)
│ AIProgram.save_asset()
▼
MyModel.aimodel/ ← class 1: portable source (a directory)
├── main.mlirb (IR bytecode)
├── main.hash
└── metadata.json ("producer": "coreai-core 1.0.0b3", …)
│
│ xcrun coreai-build compile --architecture h18p …
│ ← exits 0 for ANY arch you name (§7)
▼
MyModel.h18p.aimodelc/ ← class 2: per-architecture AOT output
├── main-h18p.mlirb
└── main-h18p-delegates/
│
│ on device: AIModel(contentsOf:) or AIModel.specialize(…)
▼
a cache entry in AIModelCache ← class 3: device + OS-version locked (§5)
│
│ model.bookmarkData
▼
Data in UserDefaults ← class 4: resolves to nil when class 3 dies (§6)
Every arrow in that diagram is a place where a version mismatch can be introduced, and only the first one produces an error message you would recognise.
3. Incident 1 — the coreai-torch 0.4.0 IR-location break¶
Attribution. The root cause and the repair are Apple's, from
apple/coreai-torchissue #37, the v0.4.1 release notes, and issue #44. The operational forensics — the negative list, theproduceraudit, the two-wheel recovery — are community-documented by the maintainer of a ~60-model community model zoo, published asknowledge/coreai-torch-041-ir-incident.mdin thecoreai-model-zoorepository, dated 2026-07-18 with an update on 2026-07-21. Quotations below are from that document and from the GitHub threads. Nothing here is a measurement of ours. ✅ Both GitHub issues are now closed as completed — #37 on 2026-07-13, #44 on 2026-07-24 (checked viagh, 2026-07-29) — consistent with the incident being resolved by the 0.4.1 toolchain and the repair recipe below.
3.1 What it looks like¶
The community forensics document opens with the scope claim, verbatim:
Community-documented — "Every
.aimodelconverted withcoreai-torch0.4.0 stops loading on iOS/macOS 27 beta 2 and later. It runs on beta 1."
On beta 2 and later, both the runtime load path (AIModel.load in Python, AIModel(contentsOf:)
in Swift) and the host compile path (xcrun coreai-build compile) abort with the same three
lines:
error: expected AICode versioned location, got: loc(fused<...>)
error: Failed to convert to versioned IR
LLVM ERROR: cannot unwrap empty `odiec_module_t`
The full, unabbreviated form, reproduced from the reporter's issue on apple/coreai-torch, shows
what the offending location actually contains — and it is immediately recognisable as your own model:
loc(fused<{call_stack = ["PixelShuffle$1", "Upsampler$1", "Sequential$19", "EDSR$1"],
identifiers = ["pixel_shuffle"]}>[...]): error: expected AICode versioned location, got: loc(fused<...>)
loc(...): error: Failed to convert to versioned IR
LLVM ERROR: cannot unwrap empty `odiec_module_t`
Three things to notice about that error, because they determine how you will experience the failure:
LLVM ERROR:is a hard abort, not a Swift throw. In the Python runtime it takes the process down. Incoreai-buildyou get a non-zero exit and no output. There is nocatchthat helps you.- The
call_stacknames your PyTorch module hierarchy.EDSR$1→Sequential$19→Upsampler$1→PixelShuffle$1. That is the fingerprint of the bug: the location is a nested fused location built from a torch stack trace. - It fires at compile/convert time on the consumer side, which is why an asset that shipped perfectly happily in June started failing in July with no change on your side at all.
The same error text also showed up in apple/coreai-models issue #77, on a FLUX export running on
iPadOS beta 3, where the maintainer's answer was blunt:
Community-documented, quoting an
apple/coreai-modelsmaintainer (@stikves), verbatim: "LLVM ERROR: cannot unwrap empty odiec_module_t— Yes, Beta 3 needs new exports and clean re-compile"
3.2 The root cause, and why deep models fire it¶
Apple's own statement, from issue #37 and the v0.4.1 release notes, as quoted in the community forensics:
Root cause (Apple), verbatim: "0.4.0 baked PyTorch stack traces into the IR as MLIR
fusedlocations; the beta-2 compiler no longer parses that nested form. It fires on deep module hierarchies."
You can see exactly where that came from in the shipping coreai-torch source, which makes this one
of the rare cases where an incident's cause is readable in a public repository.
✅ VERIFIED —
apple/coreai-torchat0.4.1(coreai_torch/converter.py:110-138), the converter's debug-recorder factory:include_stack_trace = mode == TorchConverter.Mode.DEBUG debug_config = _DebugInfoRecorder.Config( include_stack_trace=include_stack_trace, verify_debuginfo_locations=_get_verify_debuginfo_locations_enabled(), ) return _DebugInfoRecorder(config=debug_config)and the constructor default (
converter.py:140):
Mode.DEBUG is the default. The Mode docstring, verbatim from the same file:
✅ VERIFIED —
coreai_torch/converter.py,TorchConverter.Modedocstring: "Controls the level of debug information embedded in the converted asset.RELEASE: Lightweight mode that records only operation IDs without stack traces.DEBUG: Includes full torch stack traces for comprehensive source mapping and debugging."
So the default conversion mode embeds full torch stack traces, on purpose — that is what makes the
Core AI Debugger's "trace this op back to your Python source" feature work. On 0.4.0 those traces
were serialised in a nested fused location form that beta 2's IR reader stopped accepting.
⚠️ Note what this means about the shape of the defect. It was not a bug in your model, and it was not a bug in the weights. The payload — the actual program, the actual tensors — was completely fine the whole time. What broke was metadata that exists only to make debugging nicer. That is why the repair in §3.6 is cheap, and it is also why the failure feels so disproportionate when you hit it.
🔴 GAP — the exact IR-format change is not public. Nobody outside Apple has published the before/after grammar for AICode versioned locations, and
coreai-core's MLIR reader is a binary wheel, not source. We can tell you what the reader rejects (nestedfused<...>with acall_stackattribute) and what it accepts (locations carrying acoreaioperation ID), becausestrip_debug_info's source shows what it writes — but not the formal rule. What would resolve it: an Apple statement, or thecoreai-coreMLIR dialect definition. Safe default meanwhile: do not attempt to hand-editmain.mlirb. The only supported repairs are re-conversion andstrip_debug_info.
3.3 ⚠️ Why it is vicious: inspect still works¶
This is the part that costs teams a day, and it is worth its own callout.
⚠️ SILENT FAILURE (inverted) — a tool that succeeds on a broken asset.
xcrun coreai-build inspectreads a 0.4.0 asset perfectly. Function signatures print. Inputs, outputs and states print. Weight ops and dtypes print. Nothing warns. The community forensics states the consequence in one sentence, verbatim:"
coreai-build inspectstill reads the asset fine, which makes it look recoverable. It isn't."And in the GitHub thread, the same reporter spelled out the reasoning that misleads you:
"function signatures, inputs/outputs and states all print correctly. So the payload itself isn't corrupt; only the location metadata is in the pre-0.4.1 form."
That reasoning is correct and the conclusion people draw from it — "so I can repack it" — is wrong, because every repacking path preserves the locations (§3.4). The takeaway for your tooling:
coreai-build inspectsucceeding is not evidence that a model will load or compile. If you have a CI step that "validates" assets by inspecting them, that step validated nothing during this incident. Replace it with a load, and preferably with a load-and-generate (§3.7).
The general form of this lesson is worth carrying beyond this one incident: in this stack,
read-only introspection paths and execution paths do not share a code path. AIModelAsset exists
precisely so you can inspect without specializing — Apple says so explicitly:
✅ VERIFIED — Apple,
AIModelAssetoverview: "Use a model asset to inspect a model's structure and metadata without specializing it for a specific device. This lets you query model information without performing specialization, which is an expensive operation." And onisValid(at:), verbatim: "This checks that: the URL is a file URL; the extension is one of the known model asset extensions; the model contains either a source program or a derived artifact."
Read isValid(at:)'s three checks again. It is a file-shape check. It does not compile anything,
it does not specialize anything, and it will happily return true for an asset that cannot be
loaded. It is a good cheap pre-flight against a truncated download; it is not a health check.
3.4 The negative list: four things that do not fix it¶
The community document publishes an unusually valuable negative list — four repair strategies that a competent engineer would try, in the order they would try them, all of which fail. All four are stated as verified in the source document.
Community-documented — "Things that do NOT work (all verified)", verbatim:
coreai-build package— "re-emits the asset (producer bumps) but leaves IR locations untouched; compile fails identically."- Pinning
coreai-coreback to1.0.0b1— "the gate is OS-side, not in the wheel."- Re-AOT with the beta-3 toolchain — "dies at the same op."
coreai-build inspectstill reads the asset fine — "which makes it look recoverable. It isn't."
Take them one at a time, because each one teaches something.
coreai-build package bumps the producer stamp without repairing the IR. This is the most
dangerous of the four, because it changes the exact field you would audit on. The reporter
observed the producer stamp updating to coreai-build-3600.75.3 while the compile failed
identically. So if you repack first and audit second, your audit (§3.5) will tell you the tree is
clean and it will be wrong.
⚠️ Order matters: audit before you repack. Run the
produceraudit in §3.5 on the tree as it stands today.coreai-build packagedestroys the evidence you need.
Wheel-pinning does not help the runtime. This is the finding that everybody's instinct reaches for — "the old wheel produced it, so the old wheel must consume it" — and it is wrong for the runtime path, because on 27 beta 2+ the loader is the OS framework, not the wheel. Hold that thought; §3.6 contains a precise, important correction to it for the authoring path.
Re-AOT does not launder the asset. coreai-build compile consumes the same IR the runtime does,
through the same versioned-IR conversion. There is no "recompile it and the problem goes away" move
here, unlike some binary-compatibility situations where a rebuild is the whole fix.
And inspect succeeds — covered above.
There is one more thing that does not work, and it is not on the list because it is not a repair attempt at all — it is a packaging landmine that silently un-does a correct fix:
⚠️ SILENT FAILURE — the shadowing
egg-info. Community-documented, verbatim: "Never run python with the coreai-torch clone as cwd: itscoreai_torch.egg-info(0.4.0) shadows the installed 0.4.1 viasys.path[0], so exports silently use 0.4.0."This one produces no error at any point. You upgrade the wheel, you re-export, the export succeeds, and you have produced a fresh batch of broken assets. There is no message. The only way you find out is by auditing the output (§3.5) or by trying to load it.
Defend against it structurally, not by remembering: run every export from a directory that is not a checkout of
coreai-torch, and assert the version at the top of your export script:import coreai_torch from packaging.version import Version assert Version(coreai_torch.__version__) >= Version("0.4.1"), ( f"coreai-torch {coreai_torch.__version__} produces assets that will not load on " f"iOS/macOS 27 beta 2+. Check for a shadowing coreai_torch.egg-info in cwd: " f"{coreai_torch.__file__}" )Printing
coreai_torch.__file__in the failure message is the part that actually saves you — it shows you immediately whether you are importing fromsite-packagesor from a checkout.✅ VERIFIED —
coreai_torch/__version__.pyin the shipping repo reads__version__ = "0.4.1", andcoreai_torch/__init__.pyre-exports it, socoreai_torch.__version__is a real, readable attribute. Theegg-infoshadowing behaviour is community-documented, not Apple-stated, but it is ordinary Pythonsys.pathsemantics.
3.5 The audit: the producer fingerprint¶
Here is the good news in an otherwise unpleasant section. You can identify every affected asset in your tree with a single field, without loading anything, without a device, and without guessing from timestamps.
Community-documented, verbatim: "A 0.4.1-converted
.aimodel/metadata.jsoncarries aproducerfield; a 0.4.0 one does not:0.4.1 (good): {"producer": "coreai-core 1.0.0b2", "assetVersion": "2.0", "creationDate": ...} 0.4.0 (dead): {"assetVersion": "2.0"}Audit any tree by that field alone — no dates, no guessing."
And the caveat that will otherwise give you a false clean bill of health:
Community-documented, verbatim: "
.aimodelcbundles always carry aproducer(thecoreai-build-<ver>string), so for those use the source.aimodel's producer, not the compiled one."
That caveat is the whole game if you ship compiled assets. Your .aimodelc will look fine —
coreai-build stamps its own producer on everything it emits, including things it compiled from a
broken source. The compiled artifact's producer field tells you which compiler ran, not which
converter produced the IR it consumed.
🟡 RECONSTRUCTED — the
metadata.jsonschema for an.aimodel. The three keys quoted above (producer,assetVersion,creationDate) come from the community forensics document, which is reporting what it observed on disk.coreai-coreis a binary wheel and its writer is not public, so we cannot verify the full key set or their exact spellings against source. What is verified is thatAIProgram.save_asset(path)writes a directory containingmain.mlirb,main.hashandmetadata.json— that structure is attested by multiple independent community readers.⚠️ Do not confuse this file with the bundle
metadata.json.apple/coreai-modelswrites a differentmetadata.json, one level up, describing a whole bundle (tokenizer, asset roles, compression preset). Its schema is fully verified and appears in §8. A bundle directory therefore contains two files calledmetadata.jsonat different depths, with different schemas, and only the inner one hasproducer. This is a genuine trap for an audit script.
The audit, as a shell one-liner you can run right now:
# Every .aimodel in the tree, and whether its metadata.json declares a producer.
# Run this BEFORE you repack anything (see §3.4).
find . -type d -name '*.aimodel' -print0 \
| while IFS= read -r -d '' asset; do
meta="$asset/metadata.json"
if [ ! -f "$meta" ]; then
printf 'NO-METADATA %s\n' "$asset"
elif grep -q '"producer"' "$meta"; then
printf 'OK %s %s\n' \
"$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("producer"))' "$meta")" \
"$asset"
else
printf 'SUSPECT (no producer field) %s\n' "$asset"
fi
done
A slightly more careful Python version, which also walks inside bundle directories, distinguishes
the two metadata.json schemas, and refuses to be fooled by a compiled asset:
#!/usr/bin/env python3
"""Audit a tree of Core AI assets for the coreai-torch 0.4.0 producer fingerprint.
Usage: python3 audit_producers.py <root> [<root> ...]
Exit status is 1 if any SUSPECT asset was found, so you can wire this into CI.
Caveats encoded here (see guide 17.6 §3.5):
* `.aimodel` and `.aimodelc` are DIRECTORIES.
* A bundle directory has its own metadata.json with a different schema; only the
per-asset one carries `producer`.
* `.aimodelc` always carries a producer, so its producer proves nothing about the
source IR. We report it but never treat it as evidence of health.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
SUSPECT = "SUSPECT"
OK = "OK"
UNKNOWN = "UNKNOWN"
def read_producer(asset_dir: Path) -> str | None:
"""Return the `producer` string from an asset's own metadata.json, or None."""
meta = asset_dir / "metadata.json"
if not meta.is_file():
return None
try:
blob = json.loads(meta.read_text())
except (json.JSONDecodeError, UnicodeDecodeError):
return None
# A *bundle* metadata.json has "metadata_version"/"assets"; it is not an asset
# manifest and never carries a producer. Do not let it masquerade as one.
if "metadata_version" in blob and "assets" in blob:
return None
producer = blob.get("producer")
return producer if isinstance(producer, str) else None
def classify(asset_dir: Path) -> tuple[str, str]:
producer = read_producer(asset_dir)
if asset_dir.suffix == ".aimodelc":
# Compiled assets ALWAYS carry a producer (the coreai-build-<ver> string),
# so this tells you which compiler ran, not which converter made the IR.
return UNKNOWN, producer or "(none)"
if producer is None:
return SUSPECT, "(no producer field — pre-0.4.1 converter)"
return OK, producer
def main(roots: list[str]) -> int:
found_suspect = False
for root in roots:
for asset in sorted(Path(root).rglob("*")):
if not asset.is_dir():
continue
if asset.suffix not in {".aimodel", ".aimodelc"}:
continue
verdict, detail = classify(asset)
print(f"{verdict:<8} {detail:<34} {asset}")
found_suspect |= verdict == SUSPECT
if found_suspect:
print(
"\nSUSPECT assets were produced by a converter older than coreai-torch 0.4.1 "
"and will fail to load on iOS/macOS 27 beta 2 and later. See guide 17.6 §3.6.",
file=sys.stderr,
)
return 1 if found_suspect else 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv[1:] or ["."]))
Two notes on using it:
UNKNOWNfor.aimodelcis deliberate and correct. If your tree only contains compiled assets, this script cannot answer the question, and it says so rather than lying. Go find the source.aimodelthat produced them — which is one more argument for §10's archive.- Run it against your CDN listing, not just your build tree. Published assets are the ones that
matter. In the community incident, the recovery loop's step 4 was literally "Verify the uploaded
produceriscoreai-core 1.0.0b2" — after upload, on the served copy, because the served copy is what users get.
3.6 The fix, and the chicken-and-egg inside it¶
Apple's first guidance was re-convert from PyTorch with 0.4.1+. That works, and if you have the original checkpoints and the original recipes it is the cleanest answer. It is also expensive: for a large model it is a download, an export, and a re-verification, per model.
Three days after the incident, an Apple maintainer posted a cheaper repair on issue #44:
Apple maintainer (@cymbalrush) on
apple/coreai-torch#44, verbatim: "Thank you for reporting the issue. Could you try usingstrip_debug_infoto remove debugging metadata? This should prevent the compiler failure. After stripping the debug information, make sure to save the updated asset."
with this snippet:
from coreai_torch.debugging.debug_info import strip_debug_info
from coreai.authoring import AIModelAsset
from pathlib import Path
asset = AIModelAsset.load(Path("model.aimodel"))
coreai_program = asset.program
strip_debug_info(coreai_program)
coreai_program.save_asset(Path("model_stripped.aimodel"))
The insight behind it, from the community write-up: "the broken assets are healthy except the debug locations, and those can simply be stripped."
And the result, which is the number that makes this repair worth doing:
Community-measured, verbatim: "Verified here on 40 zoo bundles: weights byte-identical, minutes per model, stripped assets load clean on beta 3."
Weights byte-identical. That is what lets you skip re-running a full quality gate on the output — you are not re-quantising, you are not re-tracing, you are rewriting location metadata. (You should still run a load and generate check; see §3.7.)
What strip_debug_info actually does¶
This is one of the few places in this stack where you can read the repair.
✅ VERIFIED —
apple/coreai-torchat0.4.1,coreai_torch/debugging/debug_info.py:539. The signature is:def strip_debug_info(program: AIProgram) -> None: """Strip debugging information from all operations in the program. This is useful for reducing asset size when full debug traces are no longer needed. Args: program: The AIProgram to strip debug info from. Modified in place. """The body walks the MLIR module and replaces every location: it builds one shared
Location.file(filename="", line=0, col=0, …), sets the module operation's location, then walks all nested operations assigning fresh sequentialcoreaioperation IDs (operation_id = 0, incrementing), and finally updates every block argument's location to match its operation's.So the repair is not "delete the locations" — it is "replace stack-trace locations with ID-only locations", which is exactly the
Mode.RELEASEshape (§3.9).⚠️ A trap in the published snippet —
strip_debug_inforeturnsNone. The signature is-> Noneand the docstring says "Modified in place." The maintainer's snippet on issue #44 is written as a bare statement, which is correct — but the community write-up of the thread notes that a reader could reasonably transcribe it as an assignment. If you write:coreai_program = strip_debug_info(coreai_program) # ← WRONG: rebinds to None coreai_program.save_asset(out) # AttributeError: 'NoneType' …you get an
AttributeErrorone line later. That is at least loud rather than silent, but it will cost you ten minutes if you do not know the return type. Call it as a statement:Our research corpus previously listed this return value as unverified; it is now verified from the shipping source cited above.
⚠️ The chicken-and-egg¶
Here is the part that turns a five-line fix into an afternoon, and it is the most instructive detail in the whole incident.
⚠️ On a beta-2-or-later machine, the repair snippet cannot even load the asset. Community-documented, verbatim: "on a beta 2+ machine the snippet above cannot even load the asset (the authoring bytecode reader in
coreai-core1.0.0b2 wheels runs the same versioned-IR conversion and aborts)."
So AIModelAsset.load(path) — the first line of the fix — hits the same
expected AICode versioned location abort that you are trying to repair.
And now the correction that makes sense of the whole thing. Recall from §3.4 that pinning
coreai-core back to 1.0.0b1 "does not help, the gate is OS-side". The community document
corrects its own earlier finding, and the correction is precise:
Community-documented self-correction, verbatim: "The earlier 'pinning
coreai-coreback to1.0.0b1does not help, the gate is OS-side' finding in this doc was about the RUNTIME load path; for the AUTHORING parse the gate is in the wheel, not the OS."
There are two gates and they look like one.
| Path | Where the version gate lives | Can you pin around it? | |
|---|---|---|---|
| Runtime load | AIModel.load / AIModel(contentsOf:) / coreai-build compile |
The OS (from 27 beta 2) | No. The wheel is not in the loop |
| Authoring parse | AIModelAsset.load(...), AIProgram._load_bytecode(...) |
The wheel (coreai-core's bundled MLIR) |
Yes. A 1.0.0b1 wheel parses the old fused locations fine |
That asymmetry is what makes the recovery possible at all. You cannot make a 27-beta-2 device load a
0.4.0 asset. You can make a 1.0.0b1 Python environment read one — and reading it is all the
repair needs.
The consequence for your machine setup is concrete and slightly annoying:
- You need a venv with
coreai-core 1.0.0b1(and thecoreai-torch 0.4.0that pairs with it) to parse the broken asset. - You need a venv with
coreai-core 1.0.0b2(andcoreai-torch 0.4.1) to re-stamp it, so the output carriesproducer: coreai-core 1.0.0b2and passes your own audit (§3.5). strip_debug_infodoes not exist in 0.4.0, so the b1 venv has to run a vendored copy of the 0.4.1 function. The community write-up notes this needs "two helper signatures adapting" — the 0.4.1 implementation calls internal helpers that the 0.4.0 module tree does not expose under the same names.
3.7 The full recovery recipe¶
Two paths. Take path A if you can; path B is for assets you cannot rebuild.
Path A — re-convert from source (Apple's original guidance)¶
Use this when you still have the checkpoint and the recorded export command. It is more work per model and it re-establishes a clean provenance chain, which is worth something.
The environment the community recovery used, verbatim from the forensics document:
Community-documented environment, verbatim: "
coreai-torch0.4.1+,coreai-core1.0.0b2,coreai-opt0.2.1, on the pinnedtorch==2.9.0(do NOT letuvbump torch to 2.11 — it breaks torchvision with a circular import and every export dies at load). Xcode 27 Beta 3 (27A5218g) for AOT —xcrun coreai-build→3600.75.3."⚠️
coreai-torchallows a torch version it has not validated, and only warns. ✅ VERIFIED —coreai_torch/__init__.py:32-39in the shipping repo:_TORCH_MAX_VERSION = "2.13.0" if _Version(_torch_version) > _Version(_TORCH_MAX_VERSION): _warnings.warn( f"coreai-torch has only been validated with torch<={_TORCH_MAX_VERSION}; " f"found torch {_torch_version}. Some functionality may not work as expected.", stacklevel=2, )The runtime dependency is declared as
torch>=2.8.0with no upper bound (pyproject.toml), while thetestextra pinstorch==2.13.0. So a fresh resolve can hand you a torch the converter has never been tested against, and all you get is aUserWarningthat your log filter probably swallows. Pin torch explicitly in your export environment. The community recovery pinned2.9.0; the repo's own test matrix uses2.13.0. Either is defensible; drifting is not.
The recovery loop, per model, community-documented verbatim and lightly annotated:
1. Export with 0.4.1 using the model's recorded ship command
(models/<model>/recipe.toml, or the model card).
← this is why §10 exists: you need the recorded command
2. Gate: coreai_gate.py <bundle> <hf-id> → PASS
← a behavioural check, not a hash check
3. Upload. Big files (10–40 GB) need HF_HUB_DISABLE_PROGRESS_BARS=1, retries, and
background — a flaky link kills one-shot uploads mid-file.
4. Verify the uploaded `producer` is `coreai-core 1.0.0b2`.
← audit the SERVED copy, not the local one
5. For catalog-served models, re-pin catalog.json `revision` — the catalog is fetched
remotely and revision-pinned, so upload alone does not reach users; the re-pin
commit does.
6. Free disk: delete the local export and the source weights once upload is verified.
Step 5 deserves a highlight, because it generalises well beyond this incident:
⚠️ Publishing a fixed artifact does not ship it. Community-documented, verbatim: "the catalog is fetched remotely and revision-pinned, so upload alone does not reach users; the re-pin commit does."
If your app resolves models through a manifest that pins a content revision — and it should, see Part 15 reference 01 — then fixing the artifact is step n−1. Bumping the pin is step n. During an incident, under pressure, this is exactly the step that gets forgotten, and the symptom is "we fixed it and users still report the bug."
On step 2 — why a hash comparison would not have worked. The same community repository
documents, separately, that Core AI conversion is not byte-deterministic: main.mlirb differs by
a handful of bytes run-to-run, and a published 1.19 GB bundle differed by 492 bytes between two
conversions of the same input. Community-measured; attribute as such. The operational consequence is
important and counter-intuitive:
You cannot verify a re-conversion by comparing hashes with the old artifact. Verification has to be behavioural — load the bundle, generate with a deterministic prompt, and compare tokens against a reference. The community gate does exactly this: greedy decode, token-for-token, against a reference implementation, with a PASS defined as an exact match or a first divergence only where the top-2 logit margin is under 0.1 (a genuine numerical tie).
A hash is still worth recording (§10) — not to prove two builds are equivalent, but to identify which bytes a given user has.
Path B — repair in place with strip_debug_info¶
Use this when re-conversion is impossible or uneconomic: upstream weights are gone, the recipe is lost, the model is 40 GB, or you have forty of them.
The three-step recipe, community-documented verbatim, with the two-venv structure from §3.6:
- "Isolated venv with
coreai-torch 0.4.0+coreai-core 1.0.0b1— the b1 wheel's bundled MLIR parses the old fused locations fine."- "
AIProgram._load_bytecode(bundle/main.mlirb)→ vendored 0.4.1strip_debug_info(0.4.0 lacks it; two helper signatures need adapting) →save_asset."- "Re-load + re-save with the b2 wheel (now parses fine) to get a proper
producer: coreai-core 1.0.0b2fingerprint, then probe + publish."
As a driver you can adapt. This is a reconstruction of a described procedure, not a transcription
of a published script — the community driver scripts (strip_b1.py / strip_sweep.py) are not in
our corpus. Every API call in it is individually attested; the assembly is ours.
#!/usr/bin/env python3
"""Stage 1 of the 0.4.0 asset repair. RUN THIS IN THE b1 VENV.
Environment: coreai-torch 0.4.0 + coreai-core 1.0.0b1.
The b1 wheel's bundled MLIR still parses the pre-0.4.1 nested `fused` locations,
which is the ONLY reason this stage can read the asset at all (guide 17.6 §3.6).
Usage: python3 strip_stage1.py <in.aimodel> <out.aimodel>
"""
from __future__ import annotations
import sys
from pathlib import Path
from coreai.authoring import AIProgram
# `strip_debug_info` does not exist in coreai-torch 0.4.0. Vendor the 0.4.1
# implementation next to this script (see coreai_torch/debugging/debug_info.py:539)
# and adapt its two internal helper imports to what 0.4.0 exposes.
from vendored_strip_debug_info import strip_debug_info # noqa: F401 (local vendored copy)
def main(src: Path, dst: Path) -> int:
bytecode = src / "main.mlirb"
if not bytecode.is_file():
print(f"not an .aimodel directory (no main.mlirb): {src}", file=sys.stderr)
return 2
# Read the IR directly. AIModelAsset.load() would work on b1 too, but going
# through the bytecode keeps this stage independent of the asset manifest,
# which is exactly the part we are about to rewrite.
program = AIProgram._load_bytecode(bytecode)
# In place. Returns None. See §3.6.
strip_debug_info(program)
program.save_asset(dst)
print(f"stage 1 ok: {src} -> {dst}")
return 0
if __name__ == "__main__":
raise SystemExit(main(Path(sys.argv[1]), Path(sys.argv[2])))
#!/usr/bin/env python3
"""Stage 2 of the 0.4.0 asset repair. RUN THIS IN THE b2 VENV.
Environment: coreai-torch 0.4.1 + coreai-core 1.0.0b2.
Re-reads the stage-1 output (which now parses fine, because the locations are
ID-only) and re-saves it so the asset carries a current producer stamp — which is
what makes the §3.5 audit report OK afterwards.
Usage: python3 strip_stage2.py <stage1.aimodel> <final.aimodel>
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from coreai.authoring import AIModelAsset
def main(src: Path, dst: Path) -> int:
asset = AIModelAsset.load(src)
asset.program.save_asset(dst)
meta = json.loads((dst / "metadata.json").read_text())
producer = meta.get("producer")
if not producer:
print(
f"stage 2 produced an asset with NO producer field: {dst}\n"
f"the b2 wheel is not what you think it is — check `pip show coreai-core`",
file=sys.stderr,
)
return 1
print(f"stage 2 ok: {dst} producer={producer!r}")
return 0
if __name__ == "__main__":
raise SystemExit(main(Path(sys.argv[1]), Path(sys.argv[2])))
🟡 RECONSTRUCTED —
AIProgram._load_bytecodeandAIModelAsset.load. Both are named in the community forensics and in the Apple maintainer's snippet respectively, and both are used as shown. Neither appears in Apple's published documentation:coreai-coreis a binary wheel and Apple's documented Python surface does not include the authoring API. The leading underscore on_load_bytecodemarks it private —coreai-torch's own documentation carries a warning that underscore-prefixedcoreai-coreAPI "may move or change without notice acrosscoreai-corereleases" (✅ verified,docs/guides/custom-op-lowering.ipynb). Use it for a one-off recovery, not in a pipeline you maintain.
Then verify. Do not skip this because "the weights are byte-identical". They are — but a repair that silently produced an empty program would also produce byte-identical weights, because there would be no weights to differ. Load the repaired asset and generate something:
# Cheapest real check: does it load and does it speak?
# (llm-runner ships in apple/coreai-models' Swift package.)
swift run -c release llm-runner --model out/MyModel-repaired --prompt "The capital of France is"
Use a deterministic prompt. The community gate document is explicit about why:
Community-documented, verbatim: "Use a deterministic prompt ('The capital of France is'); open-ended prompts hit ties everywhere and aren't gate material."
3.8 What cannot be recovered¶
Two categories, both worth knowing before you plan a recovery.
1. Compiled .aimodelc assets cannot be stripped.
Community-documented, verbatim: "
.aimodelc(compiled) artifacts cannot be stripped — those need re-export + AOT recompile."
Which makes sense: strip_debug_info operates on an AIProgram — the authoring-level IR — and a
compiled asset is past that stage. If you ship .aimodelc, your recovery is necessarily
source-.aimodel → repair or re-convert → recompile.
And there is an independent reason your compiled assets may need a recompile anyway:
Community-documented, verbatim: "Beta 2 or earlier
.aimodelcalso need a beta-3 recompile per Apple 181264112, but that is a separate issue from the 0.4.0 conversion break — do not conflate them."This matters during triage. If you have both problems you will fix one, re-test, still see failures, and conclude the fix did not work. They are two problems. Fix the source-asset producer first (§3.5–3.7), then recompile every
.aimodelcon a current toolchain.
2. Models whose upstream weights have disappeared.
The community recovery has one permanent casualty, and it is worth stating because it is the failure mode nobody plans for:
Community-documented, verbatim: one model "was retired instead of recovered, because Microsoft removed its upstream weights on 2026-06-30, so it cannot be rebuilt."
A dated, concrete instance of an upstream checkpoint vanishing and permanently un-reproducing a
downstream artifact. If your build depends on from_pretrained(<some org>/<some model>) at export
time, you do not control your inputs. §10's checklist includes archiving the source checkpoint
hash for exactly this reason — and if the licence permits, archiving the checkpoint itself.
3.9 Preventing a recurrence: Mode.RELEASE¶
The whole incident is downstream of a default. TorchConverter defaults to Mode.DEBUG, which
embeds full torch stack traces in every asset you convert.
✅ VERIFIED —
coreai_torch/converter.py, the constructor docstring, verbatim: "mode: Controls the level of debug information embedded in the converted asset. UseTorchConverter.Mode.RELEASEfor lightweight operation-ID-only tracking, orTorchConverter.Mode.DEBUG(default) for full torch stack traces. Callcoreai_torch.debugging.debug_info.strip_debug_infoto remove debug metadata from an already-converted program."Note also:
modeis keyword-only (def __init__(self, *, mode=Mode.DEBUG)), and it is undocumented in the repo's owndocs/api/TorchConverter.md, which shows a bareTorchConverter(). So the flag that would have prevented this incident is present in the source, mentioned in a docstring, and absent from the API reference.
For anything you intend to ship:
from coreai_torch import TorchConverter
# Shipping conversion: operation IDs only, no torch stack traces in the asset.
converter = TorchConverter(mode=TorchConverter.Mode.RELEASE) # keyword-only
and for a debugging conversion of the same model, keep the default — that is what makes the Core AI Debugger able to point at your Python source. The two are not mutually exclusive; convert twice.
⚠️
Mode.RELEASEis a prophylactic, not a fix. It would have avoided this specific incident, because the rejected construct was the stack-trace location form. It does not make your assets version-proof in general — the versioned-IR format itself can change again, and there is no compatibility promise anywhere that says otherwise. TreatRELEASEas "ship less metadata, present less surface", not as "immune".
A reasonable default policy for a team, stated as three rules:
- Ship
RELEASE-mode assets. Debug-mode assets are for your laptop. - Record the converter version in your provenance file (§10) so that when a producer-side
compatibility break happens, the audit is a
jqquery rather than an archaeology project. - Have a "load it" step in CI, not an "inspect it" step (§3.3).
4. Incident 2 — the macOS 26 → 27 export-lowering regression¶
Attribution. Everything in this section is community-measured by a single engineer, on one M4 Max and one iPhone 17 Pro, on beta software, and published in a public repository. The primary forensics document (
methodology/coreai-export-lowering.mdin theapple-silicon-llm-benchrepository) is not in our corpus; what we have are the author's own summaries of it in two repositories we did read, plus the A/B tables. Apple has made no statement about this. The author himself calls it "plausibly a 27-beta regression in quantized-Linear legalization. Worth an Apple feedback with this document attached." Treat magnitudes as indicative and the mechanism as well-argued rather than proven.
If §3 was a bad week, this is the one that can cost you a quarter, because there is no error, no warning, no log line, and no moment at which anybody is forced to look.
4.1 The A/B¶
The author's own summary, verbatim, and it is worth reading slowly because every clause is a control:
Community-measured, verbatim: "
coreai.llm.export qwen3-0.6bproduced a 1,116 tok/s artifact when this repo's Mac numbers were first taken, and a ~500 tok/s artifact two days later — same command, same registry preset, same source checkout, same wheel versions, same machine. The only environment change in between was the macOS 26 → 27 beta upgrade. Benchmark the artifact you ship, pin the artifact, and don't assume a re-export reproduces it."
Count the controls in that sentence: same command, same preset, same source checkout, same wheel versions, same machine. The only variable is the host OS.
The A/B table, all three rows measured on the same day with the same llm-benchmark binary
at -p 128 -g 256 -n 3:
| Artifact | Exported | Host OS at export | Decode tok/s | Prefill tok/s |
|---|---|---|---|---|
qwen3_0_6b_dynamic (original) |
2026-06-09 | macOS 26 | 1,116 | 17,350 |
qwen3_0_6b_4bit_dynamic (re-export) |
2026-06-11 | macOS 27 beta | 500 | 6,667 |
re-export from pristine upstream main @ 0c1055f |
2026-06-11 | macOS 27 beta | 504 | 6,676 |
Community-measured, M4 Max, macOS 26 vs macOS 27 beta,
llm-benchmarkat 128-token prompt / 256-token generation, 3 trials. The third row is the control that rules out "his checkout drifted": a clean upstream checkout at a named commit reproduces the slow artifact to within 1%.
And it reproduces on device, which is the part that makes it a shipping concern rather than a
laptop curiosity. Both artifacts AOT-compiled with --architecture h18p, GPU, on the same
iPhone 17 Pro, same 512-prompt/1024-generation protocol:
| Artifact | Prompt tok/s | Decode tok/s (run 1 / run 2) | Load cold / warm | Footprint |
|---|---|---|---|---|
| macOS-27β export | 1,519 | 57.2 / 52.5 | 1.14 s / 0.07 s | 0.47 GB |
| macOS-26 export | 5,807 | 115.1 / 90.4 | 0.90 s / 0.066 s | 0.22 GB |
Community-measured, iPhone 17 Pro / iOS 27 beta. The author's summary of this row, verbatim: "~2× decode, 3.8× prefill, half the memory, from the export environment alone."
So the headline is not one number, it is three:
- ~2.2× decode on Mac, ~2× decode on iPhone
- ~2.6× prefill on Mac, ~3.8× prefill on iPhone
- ~2× memory footprint on iPhone (0.47 GB vs 0.22 GB)
and the artifact file sizes are nearly identical — 327 MB versus 320 MB, both squarely in the 4-bit storage class. Nothing about looking at the artifact tells you which one you have.
⚠️ The size-dependence is what makes this hard to catch. Community-measured, verbatim: "The effect is size-dependent: at 8B both artifact generations measure ~94 tok/s (bandwidth-bound); at 0.6B the lowering dominates (2.2×). Small-model numbers are the canary."
Read that as a testing instruction. If your benchmark suite only exercises your largest model — which is the natural choice, because it is the one users notice — you will not see this regression at all, because at 8B the model is bandwidth-bound and the compute path barely matters. Keep a small model in the suite specifically as a canary for lowering changes.
4.2 The op-level forensics¶
The author did not stop at the numbers. He compared the two artifacts' program text and found the
difference, using nothing more exotic than strings.
Community-measured, verbatim: - Fast artifact: "plain
Linear$Ncomposites, zero quantization ops in the program text, yet 327 MB (4-bit-sized) → 4-bit weights consumed natively by the runtime's Linear kernels (quantized-matmul path)." - Slow artifact: "ParametrizedLinear$Ncomposites + 141×constexpr_blockwise_shift_scaleops → explicit dequantize-then-matmul."And the one-line conclusion: "Same 4-bit storage class (327 vs 320 MB); the compute path differs 2.2×."
This is a clean, legible mechanism, and it is the kind of thing you can check yourself in ten seconds (§4.6). Two program shapes for the same weights:
FAST (macOS 26 export) SLOW (macOS 27β export)
────────────────────── ───────────────────────
Linear$0 ParametrizedLinear$0
Linear$1 └─ constexpr_blockwise_shift_scale ← dequantise
Linear$2 └─ matmul (dense fp16)
… ParametrizedLinear$1
(no quantization ops in the └─ constexpr_blockwise_shift_scale
program text at all — the └─ matmul
runtime's Linear kernel …
consumes 4-bit weights (141 × constexpr_blockwise_shift_scale
natively) across the program)
constexpr_blockwise_shift_scale is a real, documented Core AI op, which is what makes the
fingerprint reliable:
✅ VERIFIED —
apple/coreai-torchmaps the torch-side custom opcoreai::constexpr_blockwise_shift_scale(input, scale, zero_point?, minval?, input_dtype?, output_dtype?)to the Core AI opcoreai.blockwise_shift_scale(coreai_torch/_custom_to_core.py's_custom_to_core_resolver, and the op table indocs/api/).apple/coreai-optimizationdocuments the same op as the scale-application half of its quantised-LUT lowerings. So the op is supposed to exist — its presence is not a bug. What changed is whether the compiler folds it into the Linear composite or leaves it standing.
And on where in the pipeline the fold happens, the author is careful and explicit about what he does and does not know:
Historical community measurement (0.4.1/b2), verbatim: "
quantize_pytorch_model→coreai-optPT2EQuantizer… it ALWAYS emits the parametrized/dequant form. The fast artifact's plain-Linear$N-no-dequant form must therefore be produced LATER, by the compiler folding dequant into the Linear composites duringprog.optimize()(coreai-pre-compilation-rewrite) / serialization."
That is a deduction, not a direct observation of the pass — but it is a sound one, because it is grounded in the fact that the quantiser's output is the same in both cases. The divergence happens downstream of the quantiser, in optimisation/serialisation.
4.3 The mechanism: one wheel, two native stacks¶
Here is the architectural fact that explains how the host OS can possibly influence a Python package's output.
Community-measured, verbatim: "The
coreai-corewheel ships TWO complete native stacks and picks one at import time (coreai/runtime/__init__.py): macOS < 27 → the wheel-bundled local stack (_coreai_runtime.so); macOS ≥ 27 + wheel install → the OS framework (_coreai_runtime_os.so). Env overrides exist:USE_LOCAL_COREAI/USE_OS_COREAI. The compiler bindings (_coreaiIR) ride the same switch."
Take a moment with that last sentence. It is the load-bearing one.
If only the runtime rode the switch, this would be an unremarkable design: the wheel uses the OS's inference engine when there is one, and falls back to a bundled copy when there is not. Sensible. But the compiler bindings ride the same switch, which means:
macOS 26 host macOS 27 host
───────────── ─────────────
import coreai import coreai
└─ _coreai_runtime.so (in wheel) └─ _coreai_runtime_os.so (OS framework)
└─ _coreaiIR (wheel's) └─ _coreaiIR (OS's)
│ │
▼ ▼
to_coreai() runs the WHEEL'S to_coreai() runs the OS'S
pre-compilation rewrite pre-compilation rewrite
│ │
▼ ▼
fold dequant into Linear emit explicit dequant ops
Your pip freeze output no longer describes your compiler. Two machines with byte-identical
uv.lock files, byte-identical Package.resolved equivalents, byte-identical everything a
dependency manager can see, will run different compilers if they run different macOS versions.
The wheel versions pinned in the investigation, so you can see how tightly controlled it was:
coreai-core 1.0.0b1 / coreai-torch 0.4.0 / torch 2.9.0.
⚠️ This also means the
coreai-coreversion pin in yourpyproject.tomlis doing less than you think. ✅ VERIFIED —apple/coreai-torch'spyproject.tomldeclarescoreai-core==1.0.0b2, an exact pin, which is unusually strict for a Python dependency and clearly deliberate. It pins the Python package. It does not, and cannot, pin the OS framework that package will dispatch to on a macOS 27 host.
4.4 The decisive negative result¶
The obvious next question is: fine, so force the local stack. The author asked it and answered it.
Community-measured, verbatim — the decisive negative result: "re-exporting on macOS 27β with
USE_LOCAL_COREAI=1— i.e. the byte-identical frozen wheel compiler that produced the fast artifact on macOS 26 — STILL yields the dequant-style artifact… Same pass code, different OS underneath, different lowering ⇒ the fold decision consults the running OS (capability/target queries under the pass), not just the stack's own code."
This is a genuinely good experiment and it is worth appreciating why. It rules out the boring explanation. If the difference were "the OS ships a newer compiler with a regression", then forcing the old compiler would restore the old behaviour. It does not. The old compiler, running on the new OS, makes the new decision.
Which means the fold decision is not a property of the pass code. It is a property of the pass code plus the platform it queries — target capabilities, delegate availability, something in that family. And you cannot pin your way out of a query against the running system.
The practical consequence, stated as a rule:
There is no environment variable, no wheel pin, no lockfile and no container that makes a macOS 27 host produce the macOS 26 artifact. The only known way to produce the macOS 26 artifact is to run the export on macOS 26.
The community repository states the same conclusion from the artifact side, in its published model catalogue:
Community-documented, verbatim (
official/README.md, "Why artifacts and not just recipes?"): "The same export command can produce a 2.2× slower artifact across an OS upgrade (macOS 26 → 27β changed the quantization lowering…). An.aimodelis a build artifact: these are the exact, hash-stamped bundles behind the published numbers. The Qwen3-0.6B repo includes the macOS-26-era artifact that current toolchains can no longer reproduce."
"Can no longer reproduce" is doing a lot of work in that sentence. That artifact is now, in the most literal sense, irreplaceable — the machine that could make another one has been upgraded.
4.5 ⚠️ The lesson: the export host's OS version is an input to the model's performance¶
⚠️ ⚠️ THE LESSON OF THIS GUIDE
The export host's OS version is an input to the model's performance.¶
The cited community regression produced numerically correct artifacts with different device speed and memory use. Its mechanism is the author's inference, not an Apple-confirmed cause or a current stable-runtime finding. Record and pin host OS alongside compiler, packages, and quantization settings. Benchmark the exact shipped bytes on target hardware after every re-export or toolchain change, and preserve those bytes and hashes when they prove a regression. Treat build-host upgrades as measured changes with a rollback path.
The author's own list of consequences, verbatim, is a tighter statement of the same thing:
Community-measured, verbatim: 1. "An
.aimodelis a build artifact, not a pure function of the recipe. Treat it like a compiled binary: version-stamp it, keep it, benchmark exactly what ships." 2. "Numbers carry artifact date + OS in anENV.md." 3. "The effect is size-dependent: at 8B both artifact generations measure ~94 tok/s (bandwidth-bound); at 0.6B the lowering dominates (2.2×). Small-model numbers are the canary." 4. "If you have a macOS-26-era artifact, keep it — as of the 27 beta we know no recipe flag that re-produces the native-quantized lowering."
4.6 The detection recipe¶
You can tell which lowering you have in about ten seconds, without a device and without a benchmark. The op names live in the program text.
# Which lowering did this artifact get?
# FAST = native quantized-Linear → few/no constexpr_blockwise_shift_scale, plain Linear$N
# SLOW = explicit dequant chain → many constexpr_blockwise_shift_scale, ParametrizedLinear$N
ASSET=exports/qwen3_0_6b_dynamic.aimodel
echo "constexpr_blockwise_shift_scale: $(strings "$ASSET/main.mlirb" | grep -c constexpr_blockwise_shift_scale)"
echo "ParametrizedLinear: $(strings "$ASSET/main.mlirb" | grep -c 'ParametrizedLinear')"
echo "plain Linear: $(strings "$ASSET/main.mlirb" | grep -c 'Linear\$')"
Interpreting it, using the community A/B as the calibration:
| Signal | Fast artifact (macOS 26) | Slow artifact (macOS 27β) |
|---|---|---|
constexpr_blockwise_shift_scale count |
0 | 141 |
| Composite names | Linear$N |
ParametrizedLinear$N |
| Artifact size | 327 MB | 320 MB |
| Compute path | quantised matmul, native | dequantise → dense matmul |
Community-measured, Qwen3-0.6B 4-bit dynamic export. The counts are that model's; yours will differ with layer count. What generalises is the shape: near-zero versus one-per-linear-layer, and
Linear$NversusParametrizedLinear$N.
As a script you can run over a whole tree and wire into CI as a regression detector:
#!/usr/bin/env bash
# lowering-check.sh — classify Core AI assets by quantisation lowering.
#
# Emits one line per asset. A NON-ZERO dequant-op count on a 4-bit asset means you
# got the explicit dequantize-then-matmul lowering, which community measurement puts
# at ~2.2x slower and ~2x the memory for small models. See guide 17.6 §4.
#
# Usage: ./lowering-check.sh <root>
set -euo pipefail
root="${1:-.}"
printf '%-8s %-8s %-10s %s\n' 'DEQUANT' 'PARAM' 'SIZE' 'ASSET'
find "$root" -type d -name '*.aimodel' -print0 \
| while IFS= read -r -d '' asset; do
ir=""
for candidate in "$asset"/main.mlirb "$asset"/main-*.mlirb; do
[ -f "$candidate" ] && ir="$candidate" && break
done
if [ -z "$ir" ]; then
printf '%-8s %-8s %-10s %s\n' '-' '-' '-' "$asset (no IR bytecode found)"
continue
fi
dequant=$(strings "$ir" | grep -c 'constexpr_blockwise_shift_scale' || true)
param=$(strings "$ir" | grep -c 'ParametrizedLinear' || true)
size=$(du -sh "$asset" | cut -f1)
printf '%-8s %-8s %-10s %s\n' "$dequant" "$param" "$size" "$asset"
done
Wire the output into a comparison against a recorded baseline (§10) rather than against a threshold. "Zero dequant ops" is not universally the right answer — a model with no quantisation at all has zero too, and some architectures legitimately use the op. The signal is a change, not an absolute.
🔴 GAP —
stringsis a heuristic, and there is no supported way to dump Core AI IR.main.mlirbis MLIR bytecode.stringsfinds the op names because they are stored as literals, but it cannot tell you where they are, whether they are reachable, or what the program actually looks like. There is no publiccoreai-build dump-irandxcrun coreai-build inspectprints function signatures and weight dtypes, not the op graph. What would resolve it: a documented IR-printing path, or a stable Python API for walkingAIProgram._mlir_module(currently private, and explicitly warned as unstable). Safe default meanwhile: use the counts as a fingerprint for change detection, never as a correctness assertion. Record them alongside a real benchmark number, and treat a change in either as a signal to investigate.
4.7 The operational rule¶
Two rules, and a third that is really a consequence.
Rule 1 — pin your export machine.
Nominate one machine (or one image) as the export host, record its OS build in your provenance file (§10), and change it deliberately. The following historical 0.4.1/b2 incident configuration illustrates that policy; a current 0.4.3/b3 host must record its own measured topology and package profile:
# build/export-host.yaml — checked in, reviewed like any other build config
export_host:
os: "macOS 26.4" # NOT "latest". Pinned. Changing this is a reviewed change.
os_build: "25E5xxx" # exact build, because point releases matter (see §5)
xcode: "27.0 beta 3 (27A5218g)"
coreai_build: "3600.75.3" # xcrun coreai-build --version
python: "3.11"
wheels:
coreai-torch: "0.4.1"
coreai-core: "1.0.0b2"
coreai-opt: "0.2.1"
torch: "2.9.0" # explicit; the package's own bound is >=2.8.0 with NO ceiling
policy: >
Upgrading `os` requires: (a) a re-export of the canary model, (b) an on-device
A/B against the previous artifact, (c) a recorded decision. See guide 17.6 §4.
⚠️ The tension you are buying into. Pinning an export host to an older OS conflicts with other things you need — §3.7's recovery required Xcode 27 beta 3 for AOT, and Apple's guidance during the beta 2/3 window was that older
.aimodelcneed recompiling on current tooling. You may genuinely need two machines, or one machine and a documented, reversible upgrade procedure. That is annoying and it is the honest answer; there is no configuration that makes the tension go away. What you must not do is let the choice be made by an automatic update.
Rule 2 — benchmark the artifact you will ship.
Not a fresh export of the same recipe. The exact bytes. On the exact device class. With a protocol you write down, because protocol swings dwarf a lot of real effects:
Community-measured, verbatim: "Protocols matter: the same artifact measures 115 (512 prompt / 1024 generation) and ~184 (128 prompt / 128 generation)." — a 1.6× swing from the measurement protocol alone, on one artifact, on one device.
And on the same page: "the drop on run 2 is thermal, not cache state", with a decode number falling from 115.1 to 90.4 tok/s between two back-to-back launches.
Part 15 reference 02 is the guide for doing this properly. The one-line version: a tok/s number without a stated protocol, thermal state and device is not a number.
Rule 3 (consequence) — keep the artifact forever.
If the export host is an input, and you cannot always reproduce a given host, then the artifact is your only durable record of a build. Archive it. §10.
4.8 🔴 Status unknown¶
🔴 GAP — we do not know whether this regression is still live.
What is unknown: whether a current (late-July 2026) macOS 27 beta still emits the explicit dequantise lowering for the same recipe, or whether the native quantised-
Linearfold has been restored.Why we do not know: the forensics are dated 2026-06-11. It is 2026-07-29. Several macOS 27 and Xcode 27 betas have shipped in between. Nobody in our corpus re-ran the experiment — the author moved on to other work, and there is no Apple statement, no Feedback number published for it, and no issue in any of the public
apple/coreai-*repositories that matches it (re-checked 2026-07-29 via GitHub issue search forconstexpr_blockwise_shift_scaleand the export-lowering symptoms — still none). (Contrast §3, which has two public issues, an Apple root cause, and a maintainer's repair.)What would resolve it, exactly: on a machine running a current macOS 27 beta, with
coreai-torch 0.4.1/coreai-core 1.0.0b2:uv run coreai.llm.export qwen3-0.6b --platform macOS --output-name qwen3_0_6b_lowering_probe strings exports/qwen3_0_6b_lowering_probe.aimodel/main.mlirb \ | grep -c constexpr_blockwise_shift_scaleA count near zero means the fold is back. A count near the layer count (141 for this model) means it is not. That is a five-minute experiment and it settles the question. Then benchmark both artifacts on device to confirm the count actually predicts the speed.
Safe default until someone runs it: - Assume it is live. Pin the export host, benchmark what you ship, keep your artifacts. Those three practices cost you very little if the regression is fixed, and save you a quarter if it is not. - Do not re-export a shipping model "just to refresh it" without benchmarking the output. This is the specific mistake the incident is made of. - Do not delete a macOS-26-era artifact on the theory that you can rebuild it. As of the last published measurement, you cannot. - Do not quote either set of numbers as current. If you cite them, cite them with the date.
5. Specialization artifacts are tied to the device and the OS version¶
The third artifact class from §2 is the one that lives on your user's device, and it is the one your migration planning most likely omits entirely — because it is not in your repository, not in your CI, and not in your app bundle.
The rule, and its unconditionality¶
✅ VERIFIED — Apple, Managing model specialization and caching, listing the three ways a cached specialization is lost, verbatim: - OS update — "Specialized assets are tied to the OS version. The system always invalidates assets on OS update, regardless of policy." - Source model change — "If the source
.aimodelfile is modified or deleted, cached assets derived from it become invalid." - Storage pressure — "The system can reclaim space by deleting assets marked as purgeable."
The second and third are controlled by AIModelCache.Policy.PurgeConditions
(.sourceAssetChangedOrDeleted, .storagePressure). Nothing controls the first.
✅ VERIFIED —
AIModelCache.Policyoverview NOTE, verbatim: "Regardless of policy, the system always purges assets when the OS updates, as specialized assets are OS-version specific." And on.persistent, verbatim: "This policy ensures the system does not purge specialized assets until the next OS update."
So .persistent does not mean permanent. It means "until the next OS update."
Why this belongs in a migration guide¶
Because the 26 → 27 transition is, from the cache's point of view, one enormous invalidation event — and then every 27.x point release is another one.
Concretely, for an app that shipped on 26.x with Core ML and is adopting Core AI on 27:
- Every user who updates to 27 pays first-load cost for every model, once. That is expected.
- Every user who takes 27.1, 27.2, 27.3 pays it again. That is the part teams do not plan for, because they think of first-load as a first-launch-after-install cost.
.persistentdoes not help. It helps against storage pressure and source-file changes. Not against this.- Your bookmarks die too (§6), which is the mechanism by which a well-designed delete-the-source-asset optimisation turns into a re-download.
And the cost is not small. Two community-measured points, both on beta software, attributed to the
coreai-model-zoo author:
| Asset | Device | Cold (first) load | Warm load |
|---|---|---|---|
Qwen3-4B iOS ANE preset, 3 GB .aimodelc |
iPhone 17 Pro | 194 s | 0.46 s |
GPU monolith, .aimodelc vs uncompiled .aimodel (post cache-wipe) |
Mac | 4.9 s vs 19.2 s | 0.0 s both |
| 0.8B GPU model, cold specialization | iPhone | ~4.8 s | — |
| 2.3 GB model, cold specialization | iPhone | ~29 s | — |
Note the second row: AOT compilation makes cold load about 4× faster, and the OS cache serves
.aimodelc too, so warm load is effectively free either way. AOT is a genuine mitigation for
re-specialization cost — but it is a reduction, not a removal:
✅ VERIFIED — Apple, Managing model specialization and caching, verbatim: "The
specializemethod differs from ahead-of-time compilation. With ahead-of-time compilation, most of the heavy computation happens on your Mac at build time, so on-device specialization finishes faster. Withspecialize, the full specialization process runs on the person's device. You are controlling when specialization happens, not reducing the work it does."
Making the invalidation visible before your user does¶
AIModelCache.model(for:options:) is the primitive for this, and its defining property is that it
never specializes:
✅ VERIFIED — Apple,
AIModelCache.model(for:options:)discussion, verbatim: "If this cache holds a specialized asset from previously specializing the model atmodelURLwith the specifiedoptions, this method loads and returns the model. This method never performs specialization." Signature:final func model(for modelURL: URL, options: SpecializationOptions) throws -> AIModel?—throwsbut notasync, because nothing expensive happens. Now also ✅ SDK-verified verbatim (notes/sdk-interfaces/CoreAIDelegates-27.0-macos.swiftinterface:35-38, captured 2026-07-29, recaptured 2026-08-20 from the Xcode 27.0 beta) — and note the module:AIModelCacheis declared inCoreAIDelegates, the SubFramework thatimport CoreAIre-exports, not in the (empty)CoreAICachemodule. ThePolicystatics (.default,.persistent) and bothPurgeConditions(.storagePressure,.sourceAssetChangedOrDeleted) are in the same interface (:47-60), exactly as the article describes them.
So a launch-time sweep is cheap, and comparing it against what you saw last launch tells you an invalidation happened:
import CoreAI
import Foundation
import OSLog
@available(iOS 27.0, macOS 27.0, visionOS 27.0, tvOS 27.0, watchOS 27.0, *)
@Observable
final class SpecializationWatch {
private let log = Logger(subsystem: "com.example.app", category: "coreai.cache")
private let defaults: UserDefaults
private let installedModels: () -> [URL]
/// Models that have no cache entry right now and will pay a specialization
/// cost on their next load.
private(set) var needingPreparation: [URL] = []
/// True when models that WERE ready at last launch are not ready now — i.e. an
/// OS update or a storage purge happened while we were not running.
private(set) var invalidationDetected = false
init(defaults: UserDefaults = .standard, installedModels: @escaping () -> [URL]) {
self.defaults = defaults
self.installedModels = installedModels
}
func refresh() {
let models = installedModels()
let notReady = models.filter { url in
// One source of truth for options — mismatched options mean a cache MISS
// and a second multi-GB entry. See Part 7 reference 02 §4.
let options = ModelSpecialization.options(for: url)
return (try? AIModelCache.default.model(for: url, options: options)) == nil
}
let previouslyReady = Set(defaults.stringArray(forKey: Key.readyModels) ?? [])
let nowReady = Set(models.map(\.lastPathComponent))
.subtracting(notReady.map(\.lastPathComponent))
// Something that was ready before is not ready now.
let lost = previouslyReady.subtracting(nowReady)
// Corroborate with the OS build, so we can tell "OS update" from "storage purge"
// in telemetry. These need different messages to the user.
let previousOSBuild = defaults.string(forKey: Key.osBuild)
let currentOSBuild = Self.osBuildVersion
if !lost.isEmpty {
invalidationDetected = true
if previousOSBuild != currentOSBuild {
log.notice("""
Cache invalidated by OS update \
(\(previousOSBuild ?? "?", privacy: .public) -> \
\(currentOSBuild, privacy: .public)): \
\(lost.count, privacy: .public) model(s) need re-specialization.
""")
} else {
log.notice("""
Cache entries disappeared with no OS change — storage purge or \
source-asset change: \(lost.count, privacy: .public) model(s).
""")
}
}
needingPreparation = notReady
defaults.set(Array(nowReady), forKey: Key.readyModels)
defaults.set(currentOSBuild, forKey: Key.osBuild)
}
/// The OS build string, e.g. "26A5353q". `kern.osversion` is the build, not the
/// marketing version — which is what you want, because point releases invalidate too.
static var osBuildVersion: String {
var size = 0
sysctlbyname("kern.osversion", nil, &size, nil, 0)
var value = [CChar](repeating: 0, count: size)
sysctlbyname("kern.osversion", &value, &size, nil, 0)
return String(cString: value)
}
private enum Key {
static let readyModels = "coreai.readyModels"
static let osBuild = "coreai.lastSeenOSBuild"
}
}
🟡 RECONSTRUCTED — the
AIModelCache.model(for:options:)call and its semantics are ✅ verified from Apple's reference page (quoted above) and the declaration is ✅ SDK-verified (CoreAIDelegates-27.0:35-38), as is the fact thatSpecializationOptionsparticipates in the cache key.ModelSpecialization.options(for:)is the single-source-of-truth helper pattern from Part 7 reference 02 §4; it is app code, not API.sysctlbyname("kern.osversion", …)is ordinary Darwin, unrelated to Core AI. The@Observablewrapper is a design choice.
Two design notes on that code, both of which matter more than they look:
- It distinguishes "OS update" from "storage purge". These have different user-facing messages. An OS update is expected and explicable ("iOS 27.2 updated your device, so we need to re-prepare the model — this takes about a minute"). A storage purge means the device is short on space, and the honest message is different. Collapsing them loses the ability to say the right thing.
- It keys on
kern.osversion, the build string, not the marketing version. 27.1 and 27.1.1 are different builds and Apple's rule is about OS update, not about major version. UsingProcessInfo.processInfo.operatingSystemVersionwould miss point-release invalidations.
What to do with the signal¶
The Apple-prescribed shape for the first time is a dedicated first-run experience:
✅ VERIFIED — WWDC26 session 326, verbatim: "I could kick it off at launch or run it in the background but that feels wasteful if the user isn't even interested in this feature yet. I think a better idea is to create a dedicated first-run experience, where I can move this work to happen while the user is learning about the feature for the first time. This keeps model loading and specialization out of the interactive flow." And from session 324: "It is recommended you avoid having model specialization occur within user interactive flows."
For the re-specialization after an OS update, that advice does not transfer cleanly, because there is no first-run experience to hide behind — the user already knows about the feature and expects it to work. The realistic options, in rough order of how well they behave:
| Approach | When it fits | Watch out for |
|---|---|---|
| Re-specialize proactively on first launch after a detected invalidation, behind a small non-blocking status | The feature is central; the model is < ~1 GB | Specialization has no progress reporting and no documented cancellation contract — an indeterminate spinner is the honest UI |
| Re-specialize on next feature entry, with explanatory copy | The feature is occasional | The user hits a delay they did not hit last week; the copy has to explain why |
| Background it and gate the feature until ready | Large models, or several | You are guessing when the user will want it |
| Do nothing; let it happen inline | Small models only, where cold load is a second or two | At 194 s this is a bug report |
⚠️ There is no progress reporting for specialization.
AIModel.specializeandAIModel.init(contentsOf:)are plainasync throwscalls that return when they are done. NoProgress, noAsyncSequenceof stages, and no documented cancellation contract. Design the copy for an indeterminate wait: "This happens once after a system update, and can take a few minutes for large models" is honest. A progress bar stuck at 50% for three minutes is not.🔴 GAP — cancellation. Whether cancelling the enclosing
Taskstops specialization, and what state the cache is left in, is undocumented and untested by anyone in our corpus. What would resolve it: startspecializeon a large model on a device, cancel after ten seconds, then probecache.model(for:options:)and measure the container size. Safe default meanwhile: treat specialization as uncancellable. Do not attach it to a view's.task { }on a screen the user can swipe away.
6. Bookmarks stop resolving — into a silent else¶
Bookmarks are the API that lets you delete the multi-gigabyte source .aimodel and keep running off
the cached specialization. They are a genuinely good idea and they are the correct answer to
"I am now holding two copies of a 3 GB model."
They are also the fourth artifact class from §2, and they inherit every invalidation rule from the third — plus one of their own.
The workflow, and the API¶
var bookmarkData: Data { get } // on AIModel
init?(resolvingBookmark bookmark: Data) throws // on AIModel
static func deleteEntry(referencedBy bookmark: Data) throws // on AIModelCache
✅ VERIFIED — all three quoted from Apple's Core AI reference pages; all three now also ✅ SDK-verified verbatim (
CoreAIDelegates-27.0-macos.swiftinterface:14-20, 41— the bookmark pair is aCoreAIDelegatesextension on theCoreAIRuntimeAIModelclass, which is why it comes along with plainimport CoreAI).bookmarkDatadiscussion, verbatim: "The data returned can be stored and later resolved to re-create a model withinit?(resolvingBookmark:). It contains information about the cache and entry backing the model."
The full loop is covered in Part 7 reference 02 §9. What matters here, in a migration context, is the failure shape.
⚠️ The silent failure¶
⚠️ SILENT FAILURE —
AIModel(resolvingBookmark:)returnsnilon the OS-update path.The initializer is both failable and throwing, and the two failure modes are different in a way that is easy to get backwards:
Situation Result Bookmark data is malformed — not produced by AIModel.bookmarkDatathrows Bookmark is well-formed but stale — the entry was purged, deleted, or invalidated by an OS update returns nilBookmark resolves returns the AIModel, pinning the entry✅ VERIFIED — Apple's return documentation, verbatim: "If the bookmark data can be resolved, the resulting
AIModelpins and references the cache entry as the model that generated the bookmark data. If it cannot be resolved due to the specialized asset entry no longer being present nil is returned." And the discussion: "Resolving bookmark data involves checking it is a valid bookmark, validating the associated cache and cache entry it references exists, and returning aAIModelconstructed with that specialized asset contained within that entry. If any of these steps fail, nil is returned." The NOTE adds: "If the bookmark data is malformed due to not being sourced fromAIModel.bookmarkDataan error is thrown."The common case — the one that fires for every user after every OS update — is the
nilcase. Not the throw. There is no error object, no diagnostic, noErroryou can log the domain of. It is anOptionalthat came back empty.Which means it lands in whatever you wrote on the
elseside of anif let, six months ago, when you were thinking about the happy path. In practice that is very often areturn nil, aassertionFailure, or — worst — nothing at all, because the author reasoned "the bookmark is always there, we just saved it."
Apple's own sample code for this workflow, quoted verbatim, has the shape and even labels the branch:
See the canonical example.
✅ VERIFIED — reproduced verbatim from Managing model specialization and caching, comments included. Note that Apple's comment on the
nilpath says exactly the right thing — "or was invalidated by an OS update" — and that the code does nothing on that path except fall through. Falling through to a re-download is correct behaviour. Falling through silently is what you have to fix.
What "fix it" means¶
Three things, none of them clever:
- Do not collapse the two failure modes.
try? AIModel(resolvingBookmark: data)turns a programmer error (a garbage blob inUserDefaults) into the same value as a routine OS update. You will want to know which one is happening in the field. - Log the
nilpath. Not as an error — it is not one — but as a signal, because its rate tells you how often your users are paying re-specialization, which is a number you should know after a major OS release. - Make the fallback a real code path with a real UI, not a
return nil. After an OS update, the fallback runs for every user at once.
import CoreAI
import Foundation
import OSLog
@available(iOS 27.0, macOS 27.0, visionOS 27.0, tvOS 27.0, watchOS 27.0, *)
enum BookmarkResolution {
/// Resolved. The returned model pins its cache entry while you hold it.
case resolved(AIModel)
/// Well-formed bookmark, entry gone. NORMAL after an OS update, a storage purge,
/// or a source-asset change under the default policy. Not an error.
case staleNeedsRespecialize
/// The persisted Data is not a Core AI bookmark. This is a bug in YOUR persistence.
case malformed(any Error)
}
@available(iOS 27.0, macOS 27.0, visionOS 27.0, tvOS 27.0, watchOS 27.0, *)
func resolveModel(bookmark: Data, log: Logger) -> BookmarkResolution {
do {
if let model = try AIModel(resolvingBookmark: bookmark) {
return .resolved(model)
}
// ⚠️ THE SILENT PATH. Every user hits this after every OS update.
// Log it as a signal, not an error — but DO log it, because its rate is
// how you find out an OS release just cost your users a re-specialization.
log.notice("Core AI bookmark stale; cache entry gone. Re-specialization required.")
return .staleNeedsRespecialize
} catch {
// Not a Core AI bookmark at all. Wrong UserDefaults key, a migrated blob,
// Data written by something else. This one IS your bug.
log.error("Malformed Core AI bookmark: \(error, privacy: .public)")
return .malformed(error)
}
}
Then the caller has to do something with .staleNeedsRespecialize, and the type system makes it
awkward to ignore — which is the point of using an enum here rather than an AIModel?.
⚠️ And the one bookmarks-specific trap¶
⚠️ A bookmark does not pin anything. ✅ VERIFIED — Apple,
AIModel.bookmarkDataNOTE, verbatim: "Bookmark data is just data. It does not pin entries in the cache. Only aAIModelwill pin its associated entry in the cache while it is held."So holding a bookmark across a storage-pressure event protects nothing. If you need an entry to survive a screen's lifetime, hold the
AIModel— which is cheap, because Apple also documents: "The model instance is lightweight and doesn't own weights or intermediate buffers. Those resources belong to the functions you load from it."That asymmetry is useful: holding an
AIModelpins the cache entry at almost no memory cost; holding anInferenceFunctionis what costs memory. Load the model early, deferloadFunction(named:)until you actually run.
The migration checklist for bookmarks¶
If you are moving a 26.x app onto Core AI, or adopting bookmarks for the first time on 27:
- Every
AIModel(resolvingBookmark:)call site distinguishesnilfromthrow. - The
nilpath logs, and the log is something you can query in aggregate. - The
nilpath leads to a real re-specialization flow with UI, not to a silent degradation. - If you deleted the source
.aimodel, you specialized withcachePolicy: .persistent— otherwisesourceAssetChangedOrDeletedlets the system purge the artifact you just paid for. (✅ Apple states this directly: "If your app deletes the source model file to save storage, use the.persistentpolicy to keep the cached assets available across launches.") - If you deleted the source
.aimodel, you can still get it back — the re-specialization path after an OS update needs the source asset again. A bookmark is not a backup. - You have tested the stale path. The cheapest way: specialize, save the bookmark, call
AIModelCache.default.deleteEntries(for: url), then resolve. You should getnil.
That last item is the one that actually catches bugs, and it is a five-line test.
7. coreai-build compile exits 0 for architectures a device will reject¶
Ahead-of-time compilation is the right answer to a lot of the pain in §5 — a community-measured 4.9 s vs 19.2 s cold load on the same Mac, post-cache-wipe — and on iOS it is not optional at all for anything above toy size:
Community-measured, verbatim: "The exported
.aimodelis MLIR IR (main.mlirb,compilation.targets: []). macOS JIT-compiles it at load time; iOS cannot JIT. Load the raw IR on the phone and you get:Model load failed: NSPOSIXErrorDomain Code=2 "No such file or directory"."⚠️ That error message is spectacularly misleading. A missing compiled target surfaces as ENOENT. If you are debugging an iOS load failure and the error says "no such file or directory", check that you shipped an
.aimodelc, before you check your file paths.
So you will be running coreai-build compile. Which brings us to the trap.
The observed CLI¶
Community-documented, from a
--helprun verified 2026-06-10:coreai-build compile <input.aimodel> [--output <dir>] [--platform iOS|macOS|watchOS|visionOS|tvOS ...] [--min-deployment-version 27.0] [--preferred-compute gpu|neural-engine|none] [--architecture <arch> ...] [--expect-frequent-reshapes] coreai-build inspect <asset>.aimodel # signatures, inputs/outputs, states, weight dtypes coreai-build package <asset> # re-emits asset, bumps producer; does NOT rewrite IROutput naming is Apple-documented: ✅ VERIFIED, verbatim — "
coreai-buildoutputs one compiled.aimodelcfile per device architecture, using the input model's filename as the prefix. For example, compilingMyModel.aimodelproduces files namedMyModel.<arch>.aimodelc, where<arch>is the device architecture identifier returned bydeviceArchitectureNameat runtime."✅ GAP — RESOLVED 2026-07-31 — the full flag list, captured first-hand.
xcrun coreai-build compile --helphas now been run on this machine and pasted verbatim intonotes/sdk-interfaces/coreai-build-help-27.0-beta.txt(coreai-build 3600.79.1). The community synopsis above is confirmed flag-for-flag —--architecture(repeatable) and--expect-frequent-reshapesare real,--preferred-computetakes{gpu, neural-engine, none}(defaultnone),--platformdefaults to macOS — and the subcommand list iscompile|package|inspect(flags--io/--metadata/--storage/--compute/--ops/--json) | a previously-unknownmetadata(edits author/license/description/argument descriptions).✅ And the "tool moved under our feet" mystery is solved — 2026-07-31. The 2026-07-29 observation was accurate but incomplete: the Xcode 27.0 beta (
27A5228h) contains nocoreai-buildbinary anywhere in the app bundle — becausecoreai-buildships in the optional Metal Toolchain component (xcodebuild -downloadComponent MetalToolchain), mounted under~/Library/Developer/DVTDownloads/MetalToolchain/mounts/<hash>/Metal.xctoolchain/usr/bin/. What the app bundle ships isaimodelcatContents/Developer/usr/bin/aimodelc, which demands a command type ofpackageorcompileand requires--output; its embedded diagnostic "note: Please use 'xcrun coreai-build' instead." defers to the tool in the other, optional component — not to a tool missing from the product. With the component installed,xcrun --no-cache --find coreai-buildresolves it (plainxcrun --findcan fail from a stale cache). Practical consequences: CI must run the-downloadComponent MetalToolchainstep or it reproduces the "absent" state, and §10's provenance record should capture the exact tool path and version string, not just "compiled with Xcode 27".
⚠️ The silent failure¶
⚠️ SILENT FAILURE — a green compile does not validate your architecture choice.
Community-documented, device-validated 2026-06-10, verbatim: "
coreai-build compileEXITs 0 for ANY requested arch — a successful compile does NOT validate the arch choice; only a device load does."The evidence behind that statement is a pair of device tests, and it is worth reading because it also destroys the intuition people use to pick an architecture:
- "iPhone 17 Pro =
iPhone18,1→h18p. Anh17p.aimodelcpushed to it fails to load withinvalidCompiledModel; the same model compiled--architecture h18ploads + runs."- "M4 Max Mac =
Mac16,x→h16c. Of all 20 macOS archs, onlyh16cloads in the Python runtime on an M4 Max;h17*/h16g/h16sall raiseRuntimeError."And the author's own correction, which is the useful part:
"(Earlier notes saying 'h17p for iPhone 17 Pro' were name-matching, unvalidated — corrected here.)"
The
h-numbers track the hardware device identifier major version, not the marketing name. iPhone 17 Pro isiPhone18,1, so it ish18-something, noth17-something. Every engineer's first guess is wrong, the compiler accepts the wrong guess, and the failure appears on a device asinvalidCompiledModel— a runtime error, in a shipped build, on hardware you may not have.
The failure chain, laid out:
you guess an arch → coreai-build exits 0 → CI is green → you ship it
│
▼
user's device: invalidCompiledModel
Nothing in that chain fails until the last box, and the last box is somebody else's phone.
What to do instead¶
1. Do not guess. Read AIModel.deviceArchitectureName on the target hardware.
✅ VERIFIED —
static var deviceArchitectureName: String { get }. Apple's discussion, verbatim: "When compiling model assets ahead of time withxcrun coreai-build compile, the toolchain produces artifacts for specific device architectures. Use this property to discover which compiled asset matches the current device."
The two-line diagnostic build that removes all guesswork:
import CoreAI
@available(iOS 27.0, macOS 27.0, visionOS 27.0, tvOS 27.0, watchOS 27.0, *)
func reportDeviceArchitecture() {
// Run this once on every device class you support, and write the answers down.
// This is the ONLY authoritative source for the --architecture values you need.
print("deviceArchitectureName = \(AIModel.deviceArchitectureName)")
}
2. Select the asset at runtime by that name, and fail loudly if nothing matches.
A shipping community iOS app ranks candidate compiled assets by whether their filename contains
AIModel.deviceArchitectureName — the pattern generalises:
import CoreAI
import Foundation
@available(iOS 27.0, macOS 27.0, visionOS 27.0, tvOS 27.0, watchOS 27.0, *)
enum CompiledAssetSelection {
enum Failure: Error, CustomStringConvertible {
case noMatchingArchitecture(device: String, available: [String])
var description: String {
switch self {
case .noMatchingArchitecture(let device, let available):
return """
No compiled Core AI asset for this device architecture.
device: \(device)
shipped: \(available.joined(separator: ", "))
A `coreai-build compile` that exited 0 does NOT mean the arch was right.
"""
}
}
}
/// Pick the `.aimodelc` whose name carries this device's architecture, and throw a
/// diagnosable error rather than falling back to an arbitrary one.
static func choose(from candidates: [URL]) throws -> URL {
let arch = AIModel.deviceArchitectureName
// Names are `Base.<arch>.aimodelc`, so match on the exact penultimate component,
// not a substring — arch codes are short and substring matches misfire.
if let match = candidates.first(where: { url in
url.deletingPathExtension().pathExtension == arch
}) {
return match
}
throw Failure.noMatchingArchitecture(
device: arch,
available: candidates.map { $0.deletingPathExtension().pathExtension }
)
}
}
🟡 RECONSTRUCTED —
AIModel.deviceArchitectureNameand theBase.<arch>.aimodelcnaming are ✅ verified from Apple's documentation (quoted above). Parsing the arch out of the filename by takingdeletingPathExtension().pathExtensionis our code and relies on that naming convention holding; a community app in our corpus does the equivalent with acontainscheck, which we deliberately tightened because arch codes likeh16candh16gare short enough to collide inside longer names.
3. Do not ship all twenty architectures.
Community-documented, verbatim: "Always pass
--architecture h18p— omitting it emits all ~20 Mac GPU archs (34 GB)."And on the shape of the output: a
--platform macOScompile produced 20 per-arch.aimodelc(h13c…h17s);--platform iOS --preferred-compute neural-engineproduced 8 (h13g h14g h15g h16g h16p h17g h17p h18p). Community-measured, Xcode27A5194q, macOS 27.026A5353q, 2026-06-10.Apple's own guidance points the same way: ✅ VERIFIED, from the AOT article — "It's recommended to host the compiled assets remotely and download the matching variant to the device at runtime, because each device only uses one of them." That is Background Assets, and it is Part 15 reference 01.
4. Have at least one device load per shipped architecture in your release process.
This is the only step that actually closes the hole. A green compile proves the compiler did not crash. It proves nothing else.
A second, related landmine at the same layer¶
While you are in coreai-build, one more, because it produces a crash rather than an error:
⚠️
expectFrequentReshapes = trueon a fixed-shape graph SIGSEGVs on device. Community-documented, device-validated 2026-07-23, verbatim: "The hint is not free insurance — it is a request for a reshape-tolerant specialization. Ask for it at load time on an all-static graph and the runtime stops using the AOT specialization and compiles on device, which on iPhone 17 Pro segfaults inside the MPSGraph AICode compiler."EXC_BAD_ACCESS (SIGSEGV) … MPSGraphAICodeCompilerDelegate getInitializedAICodeBytecodeWithPayloadPrefix: → Compiler_coreAI.compile(moduleBytecode:to:with:) → libODIECompiler … CompileForDelegates"No error string, no partial output — the app just dies at
AIModel(contentsOf:options:)."Two details that make this worse than it sounds: "Compiling with
--expect-frequent-reshapesdoes NOT make the runtime hint safe — both the plain and the reshape-hinted.aimodelccrash when the runtime asks for the hint. It is the load-time option that matters." And the fix is trivial once you know: on the affected model,expectFrequentReshapes = falsegave "all 6 loads in 2.6 s, gate PASS".Rule: set
expectFrequentReshapes = trueonly where shapes genuinely change — dynamic query length, bucketed prefill, multi-stage pipelines. StaticS=1decode graphs and fixed-length decoders must load without it.
And one that is not about architectures at all¶
⚠️
CoreAI.frameworkis absent from the iOS Simulator SDK. Reported onapple/coreai-modelsissue #49 (FB23189921), still open as of 2026-07-29 (3 comments, no activity since 2026-06-28), verbatim: "CoreAI.frameworkships only in the device SDK (it's the Neural Engine inference runtime) and is absent from the iOS Simulator SDK. … every source thatimport CoreAIfails to compile for an iOS Simulator destination."The Apple maintainer declined the obvious
#if canImport(CoreAI)fix on the grounds that it "would basically make all operations into no-op." Downstream, a developer reported working around it by "separat[ing] all CoreAI functionality into a separate target … and weak link", which then trips a library-evolution warning.Why this belongs in a migration guide: if your 26.x app's development loop depended on SwiftUI Previews or Simulator testing, adopting Core AI breaks that loop, and no amount of artifact hygiene fixes it. Budget for device-only iteration on any target that imports Core AI, and consider isolating Core AI behind a protocol so the rest of your app still previews.
8. metadata.json's compression field records the request, not the result¶
The last artifact class, and the smallest file in the bundle, and the one that can cost you the most disk on a user's phone.
The bundle manifest¶
apple/coreai-models defines a bundle format: a directory containing one or more .aimodel
assets, a tokenizer/ directory, and a metadata.json describing the whole thing. The writer is
public Python and can be quoted exactly.
✅ VERIFIED —
apple/coreai-models,python/src/coreai_models/export/bundle.py, the metadata writer in full:metadata: dict[str, Any] = { "metadata_version": METADATA_VERSION, # "0.2" "kind": "llm", "name": name, "assets": {"main": f"{name}.aimodel"}, "language": { "tokenizer": hf_model_id, "vocab_size": getattr(hf_config, "vocab_size", None), "max_context_length": getattr(hf_config, "max_position_embeddings", None), "embedded_tokenizer": True, "function_map": {"main": ["main"]}, }, "source": { "model_definition": "torch", "hf_model_id": hf_model_id, }, "compression": compression if compression != "none" else None, "compilation": { "date": datetime.now().astimezone().isoformat(), "targets": [], }, }and the reader side, ✅ VERIFIED from
swift/Sources/CoreAIShared/Bundle/ModelBundle.swift:BundleKindis{ llm, vlm, diffusion, segmenter };ModelBundleexposesmetadataVersion,kind,name,assets: [String: String],verify()("checks every declared asset exists on disk"), and preserves the raw bytes.
Look at the compression line. compression is a str parameter — the preset name — threaded
down from the export configuration:
✅ VERIFIED —
python/src/coreai_models/export/pipeline.py:365passescompression=config.compressioninto the bundle writer, whereconfig.compressionis the preset string (defaulting toDEFAULT_MACOS_COMPRESSION_PRESET, i.e."4bit"for the macOS LLM path). Nothing between the quantiser and the writer observes whether quantisation happened.
⚠️ The silent failure¶
⚠️ SILENT FAILURE — quantisation failures are swallowed, and the manifest still claims success.
✅ VERIFIED —
apple/coreai-models,python/src/coreai_models/export/compiler.py, the post-MLIR quantisation path, quoted verbatim from source:if quant_type == "int4": try: coreai_program = quantize_weights( coreai_program, dtype=DType.INT4, qscheme=QScheme.SYMMETRIC if symmetric else QScheme.ASYMMETRIC, granularity=_GRANULARITY_MAP[granularity], block_size=block_size, weight_num_threshold=32768, in_place=True, ) logger.info("Applied INT4 weight quantization") except ImportError: logger.warning("Core AI quantization not available, skipping quantization") except Exception as e: logger.warning(f"Quantization failed: {e}") else: logger.warning(f"Unsupported quantization type: {quant_type}") return coreai_program
except Exception as e: logger.warning(...)thenreturn coreai_program. The unquantised program is returned. The export continues. The bundle is written. Andmetadata.jsonsays"compression": "4bit"— because that field records what you asked for, not what you got.So a completed, non-erroring, apparently successful export can hand you a full-precision asset labelled 4-bit. It will load. It will run. It will produce correct output. It will be roughly four times the size and use roughly four times the memory.
The only signal is file size.
This is the diffusion path specifically — apply_mlir_quantization is called from the diffusion
compiler, where quantisation happens after MLIR lowering rather than pre-export. It is not the LLM
path, which quantises pre-torch.export through PT2E. But the pattern — a metadata.json field
that records intent — is the bundle format's, and it applies to every kind.
🔴 GAP — how far the "records the request" property extends. We verified the swallow in the diffusion/post-MLIR path and verified that
bundle.pywrites the requested preset string unconditionally. We did not verify that the pre-export PT2E path has an equivalent swallow —export/pipeline.py's pre-export quantisation is a different call site with different error handling that we did not read line by line. What would resolve it: readingcoreai_models/export/pipeline.py's compression stage andcoreai_models/export/compression.pyend to end for bareexceptclauses. Safe default meanwhile: treat thecompressionfield as a declaration of intent for every kind, and verify by size. That advice is correct even where the code happens to be strict, and it costs one assertion.
The size check, as a gate¶
Compute what the asset should weigh and compare. This is crude and it works.
#!/usr/bin/env python3
"""Verify that a Core AI bundle's actual size is consistent with its declared compression.
`metadata.json`'s `compression` field records the REQUESTED preset. Quantization
failures are swallowed with a logger.warning (guide 17.6 §8), so the field can claim
4-bit for a full-precision asset. Size is the only signal.
Usage: python3 check_compression.py <bundle-dir> --params <parameter-count>
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
# Bytes per weight, by the storage class a preset name implies. Extend for your presets.
BYTES_PER_PARAM = {
"4bit": 0.5,
"4bit_weight_palettized_group32": 0.5,
"8bit": 1.0,
"int8": 1.0,
None: 2.0, # "none" is written as JSON null; fp16 trace dtype
}
# Scale + zero-point overhead, embeddings kept at higher precision, tokenizer files,
# and the IR itself all inflate the real number. Community exports land comfortably
# inside this band; outside it, something is wrong.
TOLERANCE_LOW, TOLERANCE_HIGH = 0.75, 1.60
def dir_size(path: Path) -> int:
return sum(f.stat().st_size for f in path.rglob("*") if f.is_file())
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("bundle", type=Path)
ap.add_argument("--params", type=float, required=True,
help="parameter count, e.g. 600e6 for a 0.6B model")
args = ap.parse_args()
meta_path = args.bundle / "metadata.json"
meta = json.loads(meta_path.read_text())
declared = meta.get("compression")
if declared not in BYTES_PER_PARAM:
print(f"unknown compression preset {declared!r}; add it to BYTES_PER_PARAM",
file=sys.stderr)
return 2
expected = args.params * BYTES_PER_PARAM[declared]
actual = dir_size(args.bundle)
ratio = actual / expected
print(f"bundle: {args.bundle}")
print(f"declared: compression={declared!r}")
print(f"expected: ~{expected / 1e9:.2f} GB ({BYTES_PER_PARAM[declared]} B/param)")
print(f"actual: {actual / 1e9:.2f} GB (ratio {ratio:.2f}x)")
if ratio > TOLERANCE_HIGH:
print(
f"\nFAIL: bundle is {ratio:.1f}x its declared storage class.\n"
f" A swallowed quantization failure produces exactly this shape.\n"
f" Re-run the export with logging at WARNING and grep for "
f"'Quantization failed'.",
file=sys.stderr,
)
return 1
if ratio < TOLERANCE_LOW:
print("\nWARN: bundle is smaller than expected — check your parameter count.",
file=sys.stderr)
return 0
if __name__ == "__main__":
raise SystemExit(main())
Calibrate the constants against a known-good export of your own before you trust the thresholds.
Community-published reference points, all from apple/coreai-models' official recipes measured by
the coreai-model-zoo author (community-measured, M4 Max):
| Model | Preset | Bundle size |
|---|---|---|
| qwen3-0.6b | 4bit / fp16 / ctx 8192 |
335 MB |
| qwen3-4b | 4bit / fp16 / ctx 40960 |
2.1 GB |
| qwen3-8b | 4bit / fp16 / ctx 40960 |
4.3 GB |
| gemma3-4b-it | 4bit / bf16 / ctx 131072 |
2.1 GB |
| gemma3-12b-it | 4bit / bf16 / ctx 131072 |
6.2 GB |
| mistral-7b-instruct-v0.3 | 4bit / fp16 / ctx 8192 |
3.8 GB |
| gpt-oss-20b | none (MXFP4 weights pass through) |
13 GB |
Note the last row: compression: "none" and yet a 20B model in 13 GB, because the upstream weights
were already MXFP4 and passed through unchanged. The compression field describes what the
pipeline was asked to do, not what precision the weights are in. That is a second, entirely
legitimate way the field can mislead you — and another reason to gate on size.
Where to log¶
If you own the export, do not rely on a downstream size check alone. Fail the export:
import logging
class FailOnQuantizationWarning(logging.Handler):
"""Turn coreai_models' swallowed quantization failure into a build failure.
`export/compiler.py` catches every Exception from quantize_weights and emits a
WARNING, then returns the unquantized program. The export then succeeds and the
bundle's metadata.json still claims the requested compression. See 17.6 §8.
"""
TRIPWIRES = ("Quantization failed", "skipping quantization", "Unsupported quantization type")
def emit(self, record: logging.LogRecord) -> None:
message = record.getMessage()
if any(t in message for t in self.TRIPWIRES):
raise RuntimeError(
f"Quantization did not happen, but the export would have continued and "
f"metadata.json would still declare the requested preset. Original "
f"warning: {message}"
)
logging.getLogger("coreai_models").addHandler(FailOnQuantizationWarning())
🟡 RECONSTRUCTED — the three tripwire strings are ✅ verified verbatim from
export/compiler.py(quoted above). The logger name"coreai_models"follows from the module'slogger = logging.getLogger(__name__)and the package layout, but we have not confirmed the effective hierarchy at runtime. Attach to the root logger if in doubt, and test that the handler fires by deliberately breaking a quantisation config once.
For why you would pick one compression scheme over another, and what each actually costs in quality, see Part 9. This section is only about the manifest lying to you.
9. Package-level migrations¶
The other half of "your build artifacts stop working" is the packages you depend on. Three cases, picked because each teaches a different failure mode: a real major-version break, a dependency declaration that resolves to nothing, and a one-line rename that the whole ecosystem is chasing.
9.1 mlx-swift-lm 2.x → 3.x¶
ml-explore/mlx-swift-lm is the Swift package for running LLMs and VLMs with MLX. Its main branch
is a new major version, and the README says so in a box you cannot miss.
✅ VERIFIED —
README.md, verbatim: "Themainbranch is a new major version number: 3.x. In order to decouple from tokenizer and downloader packages some breaking changes were introduced." And immediately after: "We useswift-formatto keep the code formatting consistent. CI has this pinned to603.0.0right now."
What actually changed¶
The decoupling is the whole story. In 2.x, the package depended on the Hugging Face Hub client and on a tokenizer implementation. In 3.x it does not — those are protocols you supply.
✅ VERIFIED —
Package.swiftdeclares exactly two dependencies:ml-explore/mlx-swift(.upToNextMinor(from: "0.31.4")) andswiftlang/swift-syntax("602.0.0" ..< "604.0.0"). There is no swift-transformers and no swift-huggingface dependency. The comment on the swift-syntax floor is itself worth stealing: "602.0.0 floor: swift.org publishes signed prebuilt swift-syntax artifacts only for >= 602 tags on current toolchains; a 600.x/601.x resolution falls back to the full source compile of swift-syntax."
The two protocols you now have to satisfy, quoted in full because they are small:
✅ VERIFIED —
Libraries/MLXLMCommon/Downloader.swiftandTokenizerLoader.swift:public protocol Downloader: Sendable { func download( id: String, revision: String?, matching patterns: [String], useLatest: Bool, progressHandler: @Sendable @escaping (Progress) -> Void ) async throws -> URL } public protocol TokenizerLoader: Sendable { func load(from directory: URL) async throws -> any Tokenizer }
The call-site migration¶
Straight from the package's own upgrade documentation, which is the authoritative source:
✅ VERIFIED —
Libraries/MLXLMCommon/Documentation.docc/upgrade.md, verbatim:// Before (2.x) – hub defaulted to HubApi() let container = try await loadModelContainer( configuration: LLMRegistry.gemma3_1B_qat_4bit ) // After (3.x) – Using HuggingFace integration macros import MLXHuggingFace let model = try await #huggingFaceLoadModelContainer( configuration: LLMRegistry.gemma3_1B_qat_4bit )and, if you want control over the two halves:
let model = try await loadModelContainer( from: #hubDownloader(), using: #huggingFaceTokenizerLoader(), configuration: modelConfiguration )Loading from a local directory, verbatim:
The full breaking-change list, ✅ verified verbatim from the same document:
| 2.x | 3.x |
|---|---|
hub: parameter (a HubApi) |
from: — any Downloader, or a URL for a local directory |
HubApi |
HubClient |
defaultHubApi global |
removed — use HubClient.default from the HuggingFace module |
tokenizer.decode(tokens:) |
decode(tokenIds:) — "to align with the transformers library in Python" |
ModelConfiguration.tokenizerId, .overrideTokenizer |
tokenizerSource: TokenizerSource? — .id(String) or .directory(URL) |
ModelConfiguration.preparePrompt |
removed — "This shouldn't be used anyway, since support for chat templates is available" |
ModelConfiguration.modelDirectory(hub:) |
removed |
loadTokenizer(configuration:hub:) |
removed — AutoTokenizer.from(directory:) |
replacementTokenizers / TokenizerReplacementRegistry |
removed — AutoTokenizer.register(_:for:) |
downloadModel(hub:configuration:progressHandler:) |
Downloader.download(id:revision:matching:useLatest:progressHandler:) |
loadTokenizerConfig(configuration:hub:) |
AutoTokenizer.from(directory:) |
ModelFactory._load(hub:configuration:progressHandler:) |
_load(configuration: ResolvedModelConfiguration) |
ModelFactory._loadContainer |
removed (base loadContainer builds from _load) |
MLXEmbedders.ModelConfiguration.nomic_text_v1_5 |
EmbedderRegistry.nomic_text_v1_5 |
MLXEmbedders.loadModelContainer(hub:configuration:) (free function) |
EmbedderModelFactory.shared.loadContainer(from:using:configuration:) |
MLXEmbedders.ModelType |
removed |
⚠️ The upgrade doc names two modules that do not exist in the package¶
⚠️ Dated migration pitfall — inspected documentation used nonexistent module names. At
mlx-swift-lmrevision3cbf928,upgrade.mdand several package READMEs referencedMLXLMHuggingFace/MLXEmbeddersHuggingFace; neither was a declared product. The actual module wasMLXHuggingFace. These stale imports produced a module-not-found build failure, not evidence of a broken local package cache. For that revision, use your ownDownloaderandTokenizerLoaderconformances or theMLXHuggingFacemacros overswift-huggingface/swift-transformers:#hubDownloader,#huggingFaceTokenizerLoader, and the model/container/language-model loading macros. Check the installed revision's Package.swift before applying this migration workaround.
The macros expand to code that references symbols at your call site, which produces another confusing error class:
⚠️ Macro expansions need imports you did not write. ✅ VERIFIED —
Libraries/MLXHuggingFace/FoundationModelsMacros.swift:17-25documents that the expansions referenceFoundation,MLXHuggingFace,MLXFoundationModels,MLXLMCommon,HuggingFaceandTokenizers, all of which must be imported where you invoke the macro. Missing one produces a "cannot find type X in scope" error pointing at generated code.The
#hubDownloader()expansion, ✅ verified verbatim fromHuggingFaceIntegrationMacros.swift:25-64, shows exactly what it needs:{ (hubApi: HuggingFace.HubClient) -> MLXLMCommon.Downloader in struct HubBridge: MLXLMCommon.Downloader { private let upstream: HuggingFace.HubClient init(_ upstream: HuggingFace.HubClient) { self.upstream = upstream } public func download( id: String, revision: String?, matching patterns: [String], useLatest: Bool, progressHandler: @Sendable @escaping (Foundation.Progress) -> Void ) async throws -> URL { guard let repoID = HuggingFace.Repo.ID(rawValue: id) else { throw HuggingFaceDownloaderError.invalidRepositoryID(id) } let revision = revision ?? "main" return try await upstream.downloadSnapshot( of: repoID, revision: revision, matching: patterns, progressHandler: { @MainActor progress in progressHandler(progress) }) } } return HubBridge(hubApi) }(HubClient())
The 27-only surface, and the trait that gates it¶
If you need to build against both the 26 and 27 SDKs — which is guide
17.4's subject — mlx-swift-lm shows you the mechanism it uses itself:
✅ VERIFIED —
Package.swift, the trait declaration, verbatim comment included:traits: [ // Gates the MLXLanguageModel adapter for Apple's FoundationModels // framework. Default-on. Disabling the trait compiles MLXFoundationModels // to an empty library: the entire `MLXLanguageModel` / `MLXLanguageModel.Executor` // surface requires FoundationModels types that are not available on platforms // older than iOS/macOS/visionOS 27.0 … .trait(name: "FoundationModelsIntegration", description: "…"), .default(enabledTraits: ["FoundationModelsIntegration"]), ],and the source-level gate,
FoundationModelsMacros.swift:3:
canImport(FoundationModels, _version: 2)is the interesting half — a module version check, not an OS-availability check, which is what lets one source tree compile against two SDK generations. Guide 17.4 covers when this is the right tool and when@availableis.
And the CI shape, which is the practical proof it works:
✅ VERIFIED — HEAD commit
3cbf928is literally "Integration tests: build on both macOS 26 and 27 SDKs (#464)", authored 2026-07-24 by an Apple engineer. The lint job buildsswift-formatfrom source pinned at603.0.0, with this rationale verbatim: "a new swift-format release can change formatting rules and reformat files no PR touched, turning the whole-repopre-commit run --allred on every open PR at once."That pin is a small, well-reasoned instance of this guide's whole thesis: an unpinned tool is an uncontrolled input to your build. If you contribute to this package, match the pin locally or your diffs will churn.
Recent commits in the same repo also show what beta-era SDK drift costs downstream, which is worth knowing before you assume a package "just works" on a new beta:
✅ VERIFIED — from the repo's own commit log: a commit fixing sampling-mode enum cases renamed
.top→.randomTopKand.nucleus→.randomProbabilityThreshold, "which broke compilation against the newer SDK"; commit1c86cc1, which found that the FM-27 beta.swiftinterfacedeclares…Action.updateUsage(input:output:metadata: = [:])while the shipping dylib exports onlyupdateUsage(input:output:), so the compiled reference alone SIGSEGVs at load under chained-fixups linking and the call had to be removed entirely; and9cd1a48, "Fix FoundationModels API drift and the integration tests that no longer compiled".⚠️ That middle one is the nastiest artifact-compatibility failure in this entire guide: the
.swiftinterfaceand the dylib disagreed. Your code compiled against a declaration that did not exist at runtime, and the failure was a segfault at load, beforemain. There is no defensive coding against that; there is only "pin your SDK and re-test on every seed."
9.2 foundation-models-utilities: the dependency line that resolves to nothing¶
apple/foundation-models-utilities is Apple's "Emerging and experimental patterns for building with
the Foundation Models framework" — the package that ships ChatCompletionsLanguageModel (the
LanguageModel conformance that turns mlx_lm.server, Ollama, vLLM and LM Studio into Foundation
Models backends), the history-management modifiers, and the Skills / SkillActivation surface.
Apple links it directly from the Foundation Models documentation:
✅ VERIFIED — Apple's Foundation Models documentation, verbatim: "Get the Foundation Models framework utilities (
https://github.com/apple/foundation-models-utilities) to access a collection of building blocks to help you explore emerging practices in working with large language models."
⚠️ The silent failure¶
⚠️ SILENT FAILURE — the README's dependency line resolves to nothing.
The README instructs consumers to write, ✅ verified verbatim from
README.md:30:✅ VERIFIED — the only tags that exist are
1.0.0-beta1and1.0.0-beta3(git ls-remote --tags;1.0.0-beta1→a047a50,1.0.0-beta3→376ca60). There is no1.0.0, andgh release listis empty.SwiftPM's
from:requirement excludes prereleases. A version range starting at1.0.0does not match1.0.0-beta3, because semantic versioning orders prereleases below their release. So the line Apple's README tells you to write matches no version at all.Why this is silent rather than loud: the symptom is a resolution error, which reads like a network problem, a private-repo problem, or a stale package cache. Engineers retry, clear
~/Library/Caches/org.swift.swiftpm, deletePackage.resolved, check their token — and the whole time the requirement is simply unsatisfiable. Nothing tells you "there are tags, they are all prereleases, and your requirement excludes prereleases."
How to actually depend on it¶
Three options, in descending order of how much we would recommend them:
// 1. RECOMMENDED — pin the exact prerelease tag. Reproducible, and it moves only when you move it.
.package(
url: "https://github.com/apple/foundation-models-utilities",
exact: "1.0.0-beta3"
),
// 2. Pin a revision. Use when you need a commit that is not tagged, or when you want the
// strongest possible guarantee. There are only two commits in the whole repository,
// so this is unusually tractable here.
.package(
url: "https://github.com/apple/foundation-models-utilities",
revision: "376ca60e61985369d5067bd3c575bdb6a13f0e1b"
),
// 3. Track a branch. Convenient during a beta, and a reproducibility hazard: `Package.resolved`
// records the resolved commit, but a fresh `swift package update` will move you.
.package(
url: "https://github.com/apple/foundation-models-utilities",
branch: "main"
),
✅ VERIFIED — repository facts as of the last read: two commits total (
a047a50"Hello foundation-models-utilities", 2026-06-07;376ca60"Updates to accompany Xcode 27 beta 3", 2026-07-10),mainis the only branch, zero pull requests in any state, issues disabled on GitHub (bug reports are routed to the Apple Developer Forums and Feedback Assistant), Apache-2.0,swift-tools-version: 6.2, platforms.macOS("27.0") .iOS("27.0") .visionOS("27.0") .watchOS("27.0"), and zero external dependencies (FoundationModelsis a system framework).
Once you have pinned it, note the platform floor: 27.0 on every platform. There is no way to
adopt this package in a target that still supports 26.x. If you need one codebase for both, the
package has to sit behind a trait or a #if canImport boundary — see guide
17.4.
The commit message that is a changelog¶
There is a second reason to know this repository during a migration, and it has nothing to do with
depending on it: commit 376ca60's message is the best public beta1 → beta3 changelog for the
Foundation Models framework surface itself.
✅ VERIFIED —
git log, commit376ca60"Updates to accompany Xcode 27 beta 3", message verbatim:- Renamed SamplingMode enum cases — `.top` → `.randomTopK` and `.nucleus` → `.randomProbabilityThreshold`. - Removed `.model(any LanguageModel)` modifier since it's now included in the Foundation Models framework. - `SkillActivations` no longer conforms to `RandomAccessCollection` — replaced with a public `activeSkillNames` property and an `isActive(_:)` method. - Added `urlSessionConfiguration` parameter to `ChatCompletionsLanguageModel.init` — allows tuning timeouts, proxies, and other transport settings; defaults to an ephemeral configuration. - Added instructions parameter to `Skills` — lets callers override the default leading instructions rendered above the skill list. - `Skills` now emits a default leading instruction telling the model to silently activate a matching skill or otherwise respond normally. - `ToggleSkillTool` default description — now instructs the model to activate without asking permission or announcing activation. - Improved skill instructions formatting … - `ChatCompletionsLanguageModel` schema name uses the new `GenerationSchema.name` API. - Fixed `SkillActivations` observation.
Two of those lines are framework-level renames that will break your code too, not just this
package's — SamplingMode.top → .randomTopK and .nucleus → .randomProbabilityThreshold. The
same rename shows up independently in mlx-swift-lm's commit history (§9.1), which is how you know
it is the framework moving and not one package's preference.
⚠️ Beta-era package pinning cuts both ways. Pinning
1.0.0-beta3protects you from surprise API churn, and it also means you are now compiling against a package written for Xcode 27 beta 3. When beta 4 renames something else, a pinned package does not follow. Watch the repo, or expect to move the pin. There is a public forum thread reportingSkillActivationAPIs from this package failing to build on Xcode 26 — the mirror-image version of the same problem.
9.3 apple/coreai-models #123 — the ecosystem chasing the same rename¶
The smallest item in this section, included because it is a perfect miniature of what guide 17.3 documents at scale.
✅ VERIFIED —
apple/coreai-modelsHEAD at the time of reading is5ed9981 "Move away from deprecated FM API (#123)", authored 2026-07-23. The full diff is two lines across two files:--- a/swift/Sources/CoreAILanguageModels/LanguageModel/CoreAILanguageModel.swift +++ b/swift/Sources/CoreAILanguageModels/LanguageModel/CoreAILanguageModel.swift @@ -61,7 +61,7 @@ public struct CoreAILanguageModel: LanguageModel { if isGuidedGenerationSupported { caps.append(.guidedGeneration) } - return LanguageModelCapabilities(capabilities: caps) + return LanguageModelCapabilities(caps)--- a/swift/Sources/CoreAILanguageModels/VLM/CoreAIVisionLanguageModel.swift +++ b/swift/Sources/CoreAILanguageModels/VLM/CoreAIVisionLanguageModel.swift @@ -29,7 +29,7 @@ public struct CoreAIVisionLanguageModel: LanguageModel { public var capabilities: LanguageModelCapabilities { - LanguageModelCapabilities(capabilities: [.vision]) + LanguageModelCapabilities([.vision])Deprecated:
FoundationModels.LanguageModelCapabilities.init(capabilities:). Replacement:LanguageModelCapabilities.init(_:)— unlabelled, taking[LanguageModelCapabilities.Capability]. Capability cases observed in use across the two files:.toolCalling,.reasoning,.guidedGeneration,.vision.
What makes this worth a section:
- This is Apple's own repository chasing Apple's own rename, three days after the
foundation-models-utilitiesbeta-3 update in §9.2. If Apple's first-party packages are still catching up to the beta-to-beta API surface, your assumption that "a package that builds today builds tomorrow" is not safe. - The rename is argument-label-only.
init(capabilities:)→init(_:). That is a deprecation, not a removal, so today you get a warning. When the deprecated overload goes, you get an error — in someone else's package, in a build you did not change. - It is invisible in a
Package.resolveddiff. Bump a dependency and this class of change comes along with it, silently, unless you read the commit log. - The same commit window has a sibling that is not a deprecation: commit
102f832("Polish a few APIs, method names, and remove unnecessarily vending public extension"), same day, renamedCoreAIRunner.init(from bundle:)→init(bundle:),PerformanceMetrics.setPromptTokenCount(_:)→recordPromptTokens(_:),setGeneratedTokenCount(_:)→recordGeneratedTokens(_:),getGeneratedTokenCount→ ageneratedTokenCountproperty, removedCLILogger.setLevel(to:)in favour of a settablestatic var level, and un-publishedDuration.inSeconds/.inMillisecondswith the rationale "vending members on a standard-library type we don't own would polluteDuration's API surface for every client of this library." (✅ verified from the repo.)
⚠️
apple/coreai-modelsis not a versioned dependency in the normal sense. ✅ VERIFIED — its README states: "We are not accepting code contributions at this time … If you open a pull request, it will be closed." Its own Swift package pinsmlc-ai/xgrammaron branchmain, not a semver range — a reproducibility footgun inside a first-party package. And a maintainer has stated in an issue thread that the publishedcoreai-modelsPyPI wheel "is just a stub", so the Python half must be used from a source checkout.Practical consequence for your migration: pin
apple/coreai-modelsby revision, and read the commit log when you move the pin.from:on a repository with this much intentional churn is not doing what you want.
The general rule this section is really about¶
| What you pinned | What you actually pinned | What still moves |
|---|---|---|
| A package version | That package's source | Its transitive deps on branch: "main" |
A wheel version (coreai-core==1.0.0b2) |
The Python package | The OS framework it dispatches to (§4.3) |
| An Xcode version | The compiler and SDK | Nothing, and this is the strongest pin you have |
A Package.resolved |
Resolved commits | Nothing, until someone runs swift package update |
| Nothing (you did not pin) | — | Everything, including swift-format reformatting your whole repo |
Three of those five rows have a real example in this guide.
10. The artifact provenance checklist¶
Everything above is a story about a question somebody had to answer under pressure:
- "Which of our published models were built with 0.4.0?" (§3)
- "Which of our benchmark numbers were taken on macOS-26-era artifacts?" (§4)
- "Which architectures did we actually compile for this release?" (§7)
- "Is the asset we shipped actually 4-bit?" (§8)
Each of those is answerable in minutes if you recorded the right things at build time, and takes
days of archaeology if you did not. During the community 0.4.0 recovery, the audit was
grep producer and the answer was immediate — because a producer field happened to exist. You
should not depend on happening to be lucky.
This section is the checklist. It is deliberately boring.
The record¶
For every artifact you publish — every .aimodel, every .aimodelc, every bundle — record a
sidecar with at least these fields. The values below are a historical July 2026 example,
not the current package profile; record the actual installed versions and asset producer for each export:
{
"artifact": {
"name": "qwen3_0_6b_dynamic",
"kind": "aimodel",
"path": "exports/qwen3_0_6b_dynamic.aimodel",
"bytes": 335544320,
"content_hash": "sha256:9f2c…",
"built_at": "2026-07-28T09:14:22+09:00"
},
"producer": {
"coreai_torch": "0.4.1",
"coreai_core": "1.0.0b2",
"coreai_opt": "0.2.1",
"torch": "2.9.0",
"python": "3.11.9",
"asset_producer_field": "coreai-core 1.0.0b2",
"converter_mode": "RELEASE"
},
"export_host": {
"os_name": "macOS",
"os_version": "26.4",
"os_build": "25E5xxx",
"hardware": "Mac16,5 (M4 Max)",
"xcode": "27.0 beta 3 (27A5218g)",
"coreai_build": "3600.75.3",
"metal_toolchain": "v27.1.5194.15"
},
"recipe": {
"command": "uv run coreai.llm.export qwen3-0.6b --platform macOS --output-name qwen3_0_6b_dynamic",
"source_model": "Qwen/Qwen3-0.6B",
"source_revision": "a1b2c3d4e5f6…",
"compression_requested": "4bit",
"context_length": 8192,
"trace_dtype": "float16"
},
"targets": {
"platform": "macOS",
"architectures": ["h16c"],
"preferred_compute": "gpu",
"expect_frequent_reshapes": false,
"device_validated": ["Mac16,5"]
},
"verification": {
"lowering_fingerprint": {
"constexpr_blockwise_shift_scale": 0,
"parametrized_linear": 0,
"linear": 28
},
"size_vs_declared_ratio": 1.12,
"behavioural_gate": "PASS (16/16 greedy tokens vs fp16 reference)",
"benchmark": {
"device": "Mac16,5 (M4 Max)",
"os": "macOS 27.0 beta (26A5353q)",
"protocol": "512 prompt / 1024 generation, 5 trials, release build",
"decode_tok_s": 484.0,
"prefill_tok_s": 9396.0,
"measured_at": "2026-07-28T09:31:05+09:00"
}
}
}
Why each field is there¶
Nothing on that list is decorative. Each one exists because a specific failure in this guide would have been cheap to diagnose if it had been recorded.
| Field | The question it answers | Section |
|---|---|---|
producer.coreai_torch + asset_producer_field |
"Which assets were built by the bad converter?" | §3.5 |
producer.converter_mode |
"Did we ship debug stack traces?" | §3.9 |
export_host.os_version / os_build |
"Which artifacts got the good lowering?" | §4 |
export_host.xcode / coreai_build |
"Do these .aimodelc need a toolchain recompile?" |
§3.8, §7 |
recipe.command |
"How do we rebuild this?" — the recovery loop's step 1 | §3.7 |
recipe.source_revision |
"The upstream repo changed / vanished. What did we actually build from?" | §3.8 |
recipe.compression_requested |
"What did we ask for?" — paired with the next row | §8 |
verification.size_vs_declared_ratio |
"…and did we get it?" | §8 |
targets.architectures + device_validated |
"Which of these did a real device actually load?" | §7 |
targets.expect_frequent_reshapes |
"Why does this one model SIGSEGV on load?" | §7 |
verification.lowering_fingerprint |
"Did a re-export silently change the compute path?" | §4.6 |
verification.behavioural_gate |
"Is the repaired asset still correct?" — hashes cannot answer this | §3.7 |
verification.benchmark |
"Is the thing we shipped as fast as the thing we measured?" | §4.7 |
artifact.content_hash |
"Which exact bytes does this user have?" | below |
⚠️ On
content_hash: record it, but do not expect it to prove equivalence. Community-measured: Core AI conversion is not byte-deterministic —main.mlirbdiffers by a handful of bytes run-to-run, and a published 1.19 GB bundle differed by 492 bytes across two conversions of the same input. Sohash(rebuild) != hash(original)tells you nothing.The hash is for identity, not equivalence: this file, the one on the CDN, the one in the crash report, the one the user has. That is exactly what you need during an incident. Equivalence is established behaviourally (§3.7).
Two places to put it¶
1. A sidecar file, checked in and published. <name>.provenance.json next to the artifact, and a
copy in your repository. This is the one you actually query during an incident, because jq over a
directory of sidecars answers "which artifacts are affected" in one command:
# "Which published artifacts were exported on macOS 27?"
jq -r 'select(.export_host.os_version | startswith("27"))
| "\(.artifact.name)\t\(.export_host.os_version)\t\(.artifact.content_hash)"' \
published/**/*.provenance.json
# "Which artifacts were built by a pre-0.4.1 converter?"
jq -r 'select(.producer.coreai_torch < "0.4.1") | .artifact.path' \
published/**/*.provenance.json
That is the §3 audit, answered from a file instead of from the assets. The reason to have both is that the sidecar can answer questions the asset cannot — the asset does not know what host OS built it (§4), and that is the field you will most want.
2. Inside the asset itself, so the record survives being copied around detached from your repository. Core AI supports this directly, and the API is verified:
✅ VERIFIED —
AIModelAssetexposes creator-defined metadata with a mutating update:"Pass a closure that takes the existing metadata and updates it. After the closure executes, this method writes the new metadata to the model asset on disk."
Metadatahasdescription,author,license,creationDate, andcreatorDefinedMetadata: [String : Metadata.CreatorDefinedValue], whereCreatorDefinedValueis an enum with cases.string,.integer,.number,.bool,.array,.dictionary. Six typed subscript overloads exist, disambiguated by a defaultedtype:parameter —String,Bool,Double,Int,[CreatorDefinedValue],[String: CreatorDefinedValue].Apple's own example, verbatim:
So you can stamp provenance into the asset from a Swift build step:
import CoreAI
import Foundation
@available(macOS 27.0, *)
func stampProvenance(
at assetURL: URL,
exportHostOSBuild: String,
coreaiTorchVersion: String,
recipeCommand: String,
contentHash: String
) throws {
var asset = try AIModelAsset(contentsOf: assetURL)
try asset.updateMetadata { metadata in
metadata.author = "Example Inc."
metadata.license = "Apache-2.0"
// The field this guide exists to make you record. See §4.
metadata["export_host_os_build"] = exportHostOSBuild
metadata["coreai_torch_version"] = coreaiTorchVersion
metadata["recipe_command"] = recipeCommand
metadata["content_hash"] = contentHash
}
}
🟡 RECONSTRUCTED — the
AIModelAsset.updateMetadataAPI and theString-typed subscript are ✅ verified from Apple's reference pages (quoted above). The choice of keys is ours. The Python-side equivalent isAIModelAssetMetadata, passed as an optional second positional toprogram.save_asset(path, metadata)— ✅ verified in use inapple/coreai-models(export/metadata.py'sbuild_aimodel_metadata(hf_model_id, component=None) -> AIModelAssetMetadata, which setsauthor,license,model_descriptionandcreation_date = int(time.time())).🔴 GAP: whether the Python
AIModelAssetMetadataexposes the creator-defined dictionary that the SwiftMetadatadoes is unverified —apple/coreai-modelsonly ever sets the four standard fields, andcoreai-coreis a binary wheel. What would resolve it:python -c "import coreai.runtime as r; help(r.AIModelAssetMetadata)"in an environment with the wheel installed. Safe default meanwhile: write the sidecar JSON from Python (which always works), and stamp creator-defined keys from Swift if you need them in-asset.
Worth knowing about Apple's own stamping helper, because its failure mode is the pattern this whole guide warns about:
✅ VERIFIED —
apple/coreai-models,export/metadata.py:build_aimodel_metadatalooks the model up in a hardcoded_METADATAdict. For an unregistered id it logs an 80-!banner warning and returns metadata with onlycreation_datepopulated — "so the export still completes." The asset ships with blank author, licence and description.A loud warning and a degraded artifact. Better than silence, and still an export that succeeds while producing something you did not ask for. If you export models outside Apple's catalogue, you are responsible for the metadata; nothing will stop you shipping an anonymous asset.
The three-line version¶
If you take nothing else from this guide:
- Record the export host's OS build alongside every artifact. It is the single field this stack makes load-bearing and no build system records by default.
- Archive the artifact bytes, not just the command that produced them. The command may stop being able to reproduce them, and §4 is a dated instance of exactly that.
- Benchmark the bytes you ship, on the device class you ship to, with a written-down protocol — and store the number next to the bytes.
11. Triage: what to check, in what order¶
You arrived here because something broke, or because you are about to move a project to 27 and want to know what will. Work down the table.
| Symptom | Probable cause | Go to |
|---|---|---|
expected AICode versioned location, got: loc(fused<...>) |
Asset converted with coreai-torch 0.4.0 |
§3 |
Failed to convert to versioned IR / LLVM ERROR: cannot unwrap empty odiec_module_t |
Same | §3 |
coreai-build inspect succeeds but compile fails |
Same — inspect is not a health check |
§3.3 |
| A model got slower after you re-exported it, and nothing else changed | Export-lowering regression; check the host OS | §4 |
| Memory footprint roughly doubled with no recipe change | Same | §4.1 |
| Small models regressed, large models did not | Same — the effect is size-dependent | §4.1 |
| First load is slow again after an OS update | Cache invalidation; unconditional, by design | §5 |
| App silently re-downloads a model it already had | Bookmark resolved to nil, else branch did the wrong thing |
§6 |
invalidCompiledModel on device, green build in CI |
Wrong --architecture; compile exits 0 regardless |
§7 |
NSPOSIXErrorDomain Code=2 "No such file or directory" loading on iOS |
You shipped an uncompiled .aimodel; iOS cannot JIT |
§7 |
EXC_BAD_ACCESS inside MPSGraphAICodeCompilerDelegate at load |
expectFrequentReshapes = true on a fixed-shape graph |
§7 |
no such module 'CoreAI' building for the Simulator |
CoreAI.framework is device-SDK-only |
§7 |
| Asset is ~4× the size its metadata implies | Swallowed quantisation failure; metadata records the request | §8 |
no such module 'MLXLMHuggingFace' |
That module does not exist; the upgrade doc is stale | §9.1 |
SwiftPM cannot resolve foundation-models-utilities |
from: "1.0.0" excludes the only (prerelease) tags |
§9.2 |
| A dependency stopped compiling after an Xcode beta | FM API drift; see the beta1→beta3 changelog | §9.2 |
LanguageModelCapabilities(capabilities:) deprecation warning |
Argument-label rename to init(_:) |
§9.3 |
Whole-repo swift-format diff on an unrelated PR |
Unpinned swift-format; CI pins 603.0.0 |
§9.1 |
The pre-flight, for a project you are about to migrate¶
Run these before you start, in this order. Each is minutes.
# 1. Which of your assets were built by a pre-0.4.1 converter? (§3.5)
python3 audit_producers.py ./exports ./published
# 2. Which lowering did they get? Record the counts as a baseline. (§4.6)
./lowering-check.sh ./exports
# 3. Is the declared compression consistent with the size? (§8)
python3 check_compression.py exports/my_model --params 600e6
# 4. What is this device's architecture, really? (§7)
# Run a debug build containing: print(AIModel.deviceArchitectureName)
# 5. What is your export host? Record it. It is a build input. (§4.5, §10)
sw_vers ; sysctl -n hw.model ; xcodebuild -version ; xcrun coreai-build --version
python3 -c "import coreai_torch, torch; print(coreai_torch.__version__, torch.__version__, coreai_torch.__file__)"
Step 5's last line is the one people skip and the one that catches the shadowing-egg-info problem
(§3.4), because it prints where coreai_torch was actually imported from.
What to fix first¶
If more than one thing applies, this is the order that minimises wasted work:
- Producer audit (§3.5) before anything else, because
coreai-build packagedestroys the evidence and you may be about to run it. - Record your export host (§10) before you upgrade anything, because you cannot recover the information afterwards.
- Baseline your lowering fingerprints and benchmarks (§4.6) before you re-export anything, because a regression is only detectable against a baseline.
- Then fix things.
12. Sources and evidence ledger¶
Apple documentation (evidence class 3)¶
| Claim | Source |
|---|---|
| Specialization produces code "tied to that device's hardware and OS version" | Managing model specialization and caching (/documentation/coreai/managing-model-specialization-and-caching) |
| OS update purges the cache "regardless of policy" | Same article; repeated on AIModelCache.Policy and Policy.PurgeConditions |
.persistent holds "until the next OS update" |
AIModelCache.Policy reference |
model(for:options:) "never performs specialization" |
AIModelCache.model(for:options:) reference |
specialize controls when, not how much |
Managing model specialization and caching |
Bookmark: malformed → throws, stale → nil |
AIModel.init(resolvingBookmark:) reference |
| "Bookmark data is just data. It does not pin entries in the cache." | AIModel.bookmarkData NOTE |
AIModel is lightweight; functions own the memory |
AIModel overview NOTE |
init(contentsOf:) accepts .aimodel or .aimodelc |
AIModel.init(contentsOf:options:) parameter docs |
deviceArchitectureName; Model.<arch>.aimodelc naming |
AIModel.deviceArchitectureName reference; the AOT article |
| "host the compiled assets remotely and download the matching variant" | The AOT article |
AIModelAsset inspects without specializing; isValid(at:)'s three checks |
AIModelAsset overview and isValid(at:) |
updateMetadata(_:), Metadata, CreatorDefinedValue, six typed subscripts |
AIModelAsset.Metadata reference pages |
Foundation Models docs link to foundation-models-utilities |
Apple's Foundation Models documentation |
Apple source and shipping repositories (evidence class 2)¶
The converter rows below preserve the inspected 0.4.1/b2 incident snapshot. Current conversion uses 0.4.3/b3; see the version floor and Part 8 for that workflow.
| Claim | Source |
|---|---|
TorchConverter.Mode = {DEBUG, RELEASE}, DEBUG is the default, mode is keyword-only |
apple/coreai-torch coreai_torch/converter.py:140, Mode docstring |
include_stack_trace = mode == Mode.DEBUG |
coreai_torch/converter.py:110-138 |
strip_debug_info(program: AIProgram) -> None, in place; rewrites all locations to ID-only |
coreai_torch/debugging/debug_info.py:539 |
coreai_torch.__version__ == "0.4.1" |
coreai_torch/__version__.py |
coreai-core==1.0.0b2 exact pin; torch>=2.8.0 with no ceiling; warns above 2.13.0 |
coreai-torch pyproject.toml, __init__.py:32-39 |
constexpr_blockwise_shift_scale → coreai.blockwise_shift_scale |
coreai_torch/_custom_to_core.py resolver; apple/coreai-optimization lowering docs |
Private coreai-core API "may move or change without notice" |
coreai-torch docs/guides/custom-op-lowering.ipynb |
Bundle metadata.json schema 0.2, incl. "compression": compression if … else None |
apple/coreai-models python/src/coreai_models/export/bundle.py |
compression=config.compression — the request, threaded straight through |
apple/coreai-models export/pipeline.py:365 |
Quantisation failures swallowed with logger.warning |
apple/coreai-models export/compiler.py (apply_mlir_quantization) |
build_aimodel_metadata degrades to creation_date only, with a banner warning |
apple/coreai-models export/metadata.py |
BundleKind = {llm, vlm, diffusion, segmenter}; ModelBundle.verify() |
apple/coreai-models swift/Sources/CoreAIShared/Bundle/ModelBundle.swift |
Commit #123 diff: LanguageModelCapabilities(capabilities:) → (_:) |
apple/coreai-models 5ed9981, 2026-07-23 |
Commit 102f832 API polish (five renames + un-published Duration members) |
apple/coreai-models, same day |
"We are not accepting code contributions"; xgrammar on branch: "main" |
apple/coreai-models README.md, Package.swift |
mlx-swift-lm 3.x upgrade guide, full breaking-change list |
ml-explore/mlx-swift-lm Libraries/MLXLMCommon/Documentation.docc/upgrade.md |
Downloader / TokenizerLoader protocol definitions |
Libraries/MLXLMCommon/Downloader.swift, TokenizerLoader.swift |
| Two dependencies only; the swift-syntax 602 floor rationale | mlx-swift-lm Package.swift |
FoundationModelsIntegration trait; canImport(FoundationModels, _version: 2) |
Package.swift; FoundationModelsMacros.swift:3 |
#hubDownloader() expansion; required call-site imports |
HuggingFaceIntegrationMacros.swift:25-64; FoundationModelsMacros.swift:17-25 |
MLXLMHuggingFace / MLXEmbeddersHuggingFace do not exist |
grep over the checkout at HEAD 3cbf928; Package.swift product list |
swift-format pinned 603.0.0, with rationale |
README.md; .github/workflows/pull_request.yml |
Dual-SDK CI; .swiftinterface-vs-dylib SIGSEGV (1c86cc1); FM drift fixes |
mlx-swift-lm commit log (3cbf928, 1c86cc1, 9cd1a48) |
Only tags are 1.0.0-beta1 / 1.0.0-beta3; two commits; issues disabled; zero deps; 27.0 floor |
apple/foundation-models-utilities git ls-remote --tags, git log, Package.swift, README.md |
| The beta1 → beta3 framework changelog | foundation-models-utilities commit 376ca60 message |
SDK interfaces and toolchain probes (evidence class 2 — captured/run 2026-07-29, Xcode 27.0 beta 27A5228h)¶
| Claim | Source |
|---|---|
AIModelCache.model(for:options:) signature verbatim (throws -> AIModel?, not async); AIModelCache lives in CoreAIDelegates; Policy / PurgeConditions statics (§5) |
notes/sdk-interfaces/CoreAIDelegates-27.0-macos.swiftinterface:29-60 |
bookmarkData / init?(resolvingBookmark:) / deleteEntry(referencedBy:) verbatim (§6) |
Same interface :14-20, 41 |
import CoreAI is an umbrella: CoreAI → @_exported CoreAIDelegates → @_exported Asset/Common/Compiler/Runtime, all -public-module-name CoreAI; CoreAICache/CoreAICommon/CoreAICompiler have empty public surfaces |
CoreAI-27.0-macos.swiftinterface:5; CoreAIDelegates-27.0:5-8; the three stub interfaces |
No public error type in CoreAIRuntime; CoreAIAsset.AssetError is the only public Core AI error (relevant to §3/§7 triage — see 17.5 §5.2) |
CoreAIRuntime-27.0-macos.swiftinterface (grep), CoreAIAsset-27.0:229-247 |
coreai-build not included in the Xcode 27.0 beta app bundle; with the optional Metal Toolchain component not installed, xcrun coreai-build did not resolve. The bundle's aimodelc stub was present (package|compile, --output required), embedding "Please use 'xcrun coreai-build' instead" (§7) |
Run directly on this machine, 2026-07-29 |
coreai-build 3600.79.1 found in the Metal Toolchain component; full --help for compile/package/inspect/metadata captured; --preferred-compute {gpu, neural-engine, none}; 24 --architecture codes enumerated by validation probing (§7) |
notes/sdk-interfaces/coreai-build-help-27.0-beta.txt, run 2026-07-31 |
| Claim | Source |
|---|---|
Root cause: 0.4.0 baked stack traces as MLIR fused locations; beta-2 compiler rejects; fires on deep hierarchies |
apple/coreai-torch #37 + v0.4.1 release notes |
The strip_debug_info repair recipe |
apple/coreai-torch #44, maintainer @cymbalrush |
Full error text with call_stack = ["PixelShuffle$1", …] |
apple/coreai-torch #37 reporter |
| "Beta 3 needs new exports and clean re-compile" | apple/coreai-models #77, maintainer @stikves |
CoreAI.framework absent from the iOS Simulator SDK (FB23189921), still open |
apple/coreai-models #49 |
Community sources (evidence class 6 — always attributed as such)¶
All from the coreai-model-zoo / apple-silicon-llm-bench public repositories of a single
engineer, measured on one M4 Max and one iPhone 17 Pro, on beta software.
| Claim | Date | Note |
|---|---|---|
The 0.4.0 incident: scope, negative list, producer fingerprint, environment, recovery loop, two-wheel strip_debug_info recipe, 40 bundles byte-identical |
2026-07-18, updated 2026-07-21 | knowledge/coreai-torch-041-ir-incident.md, read directly |
| Runtime gate is OS-side; authoring gate is in the wheel — an explicit self-correction | 2026-07-21 | Same file |
.aimodelc cannot be stripped; retired model with deleted upstream weights (2026-06-30) |
2026-07-21 | Same file; README.md |
The macOS 26 → 27 export-lowering A/B (1,116 → 500 tok/s), op-level forensics, two-native-stacks mechanism, USE_LOCAL_COREAI=1 negative result |
2026-06-11 | Primary doc methodology/coreai-export-lowering.md not in our corpus; quoted via the author's own summaries in knowledge/apple-models-bench.md and official/README.md, both read directly |
| iPhone A/B: 115.1 vs 57.2 tok/s decode, 5,807 vs 1,519 prefill, 0.22 vs 0.47 GB | 2026-06 | knowledge/apple-models-bench.md, read directly |
| Size-dependence (8B unaffected, 0.6B 2.2×); "small-model numbers are the canary" | 2026-06 | Same |
| "the macOS-26-era artifact that current toolchains can no longer reproduce" | — | official/README.md, read directly |
coreai-build compile exits 0 for any --architecture; h18p vs h17p device validation; M4 Max = h16c only |
2026-06-10 | knowledge/aot-and-specialization.md, read directly |
expectFrequentReshapes = true SIGSEGVs a fixed-shape graph at load |
2026-07-23 | Same |
20 macOS archs / 8 iOS archs; omitting --architecture emits 34 GB |
2026-06-10 | Same |
AOT cold load 4.9 s vs 19.2 s; 194 s cold load on a 3 GB .aimodelc; 0.8B ≈ 4.8 s, 2.3 GB ≈ 29 s |
2026-06/07 | Same; knowledge/apple-models-bench.md |
iOS cannot JIT; NSPOSIXErrorDomain Code=2 for a missing compiled target |
2026-06 | methodology/coreai-ios.md via our notes |
coreai-build CLI --help surface |
verified 2026-06-10 | knowledge/aot-and-specialization.md |
| Conversion is not byte-deterministic (492 B of 1.19 GB) | — | Zoo CONTRIBUTING.md / PORTING.md via our notes |
| Protocol swing 115 vs 184 tok/s on one artifact; run-2 drop is thermal | 2026-06 | knowledge/apple-models-bench.md |
| Official-recipe bundle sizes table (335 MB … 13 GB) | 2026-06/07 | Same |
Shadowing coreai_torch.egg-info silently downgrades exports |
2026-07-18 | knowledge/coreai-torch-041-ir-incident.md |
🔴 Declared gaps in this guide¶
- The exact IR-format change between beta 1 and beta 2 (§3.2). Resolved by an Apple statement or
the
coreai-coreMLIR dialect definition. - The
.aimodelmetadata.jsonfull schema (§3.5) — three keys are attested by observation; the writer is a binary wheel. - Whether the macOS 26 → 27 export-lowering regression is still live (§4.8). Resolved by a
five-minute re-export plus
strings | grep -c. This is the highest-value open question in the guide — it changes the advice in three sections. - No supported way to dump Core AI IR (§4.6);
stringsis a heuristic fingerprint. - Whether specialization is cancellable (§5). Resolved by a device test.
- Whether the pre-export PT2E quantisation path swallows failures the way the post-MLIR path does
(§8). Resolved by reading
export/pipeline.pyandexport/compression.pyend to end. - ~~
coreai-build compile --help's full flag list (§7)~~ CLOSED 2026-07-31 — a real--helpis pasted innotes/sdk-interfaces/coreai-build-help-27.0-beta.txt. The 2026-07-29 confusion (27A5228h's app bundle does not containcoreai-build, and the optional component was not installed;aimodelcembeds "Please use 'xcrun coreai-build' instead") resolved: the tool lives in the optional Metal Toolchain component, not the app bundle. Both spellings coexist by design —coreai-buildis the developer CLI,aimodelcthe Xcode-internal stub. - Whether Python's
AIModelAssetMetadataexposes creator-defined keys (§10). Resolved byhelp()in an environment with the wheel. AIProgram._load_bytecode/AIModelAsset.loadare private/undocumented (§3.7) and may move.
Guide 17.6 · Part 17 — Migration from pre-iOS 27. Continue with 17.1 — What changed between iOS 26 and iOS 27 for the source-level diff, or Part 15 for the distribution and benchmarking practices this guide keeps pointing at.