Files
aszc-dev-ComfyUI-CoreMLSuite/CONVERTER_EXTRACTION_SPEC.md
aszc d90546b6bb feat(extraction): split conversion into the coreml-diffusion package (E0–E5) (#63)
* docs(extraction): E0 seam inventory + correct stale spec assumptions

Resolve all pre-flight greps for the converter-extraction seam:
- conversion/* and lcm/unet.py confirmed comfy-free
- converter.py: only folder_paths reach-in is get_out_path
- lcm/converter.py: folder_paths + comfy.model_management to cut; dup
  helpers and SimianLuo HF hardcode confirmed
- no attention module-global; already per-call

Correct two stale assumptions verified against current source:
- ml-stable-diffusion is already fully removed (#58); CoreMLModel is a
  local coremltools wrapper, not Apple's. Drop the dep-pinning blocker
  and the package/suite dep lines that assumed it.
- model_version discovery must emit .name (node reverses via
  ModelVersion[...]); the .value form in the draft would KeyError on
  every saved workflow.

* feat(extraction): E1 coreml_diffusion package + discovery API

Stand up the framework-free coreml_diffusion namespace and freeze its
versioned discovery contract. The package re-exports from already
comfy-free coreml_suite sources (model_version, attention, core.naming);
the conversion implementation moves in E2.

- list_model_versions/list_attention_impls/list_quant_modes return
  today's exact dropdown strings, so wiring the node onto them (E3)
  changes no value and breaks no saved workflow.
- Status/_MODEL_STATUS registry gates VERIFIED vs EXPERIMENTAL in the
  package, so promoting a model expands the node dropdown with no Suite
  change (additive-only contract; CONTRACT_VERSION=1.0).
- Tier-0 test pins the contract and proves comfy/diffusers/coremltools
  are not pulled on import.

Node untouched; zero behavior change.

* refactor(extraction): E2 move conversion mechanics into coreml_diffusion

Physically relocate the framework-free conversion code into the package
and collapse the duplicated LCM/main helpers, behavior-preserving.

- coreml_suite/conversion/ -> coreml_diffusion/conversion/ (attention,
  shapes, trace, unet)
- coreml_suite/core/naming.py -> coreml_diffusion/naming.py (the cache-key
  contract now lives with the package; tests re-pointed)
- coreml_suite/converter.py logic -> coreml_diffusion/convert.py, with
  convert() made keyword-only past (ckpt_path, model_version, out_path)
  per the interface contract; out_path is injected (no folder_paths)
- dedup: load_coreml_model / convert_to_coreml / get_coreml_inputs /
  add_cnet_support / get_encoder_hidden_states_shape / inputs-spec now
  defined once in the package; get_sample_input gains an optional
  scheduler arg so the LCM path shares it (same keys/order/dtypes)
- coreml_suite.{converter,lcm.converter} reduced to comfy-side shims:
  folder_paths path resolution and the LCM scheduler's
  comfy.model_management stay here; the package imports neither
- __init__ keeps discovery + compose_out_name eager; convert is lazy via
  __getattr__ so 'import coreml_diffusion' stays Tier-0 pure

Nodes untouched (E3 thins them onto the package). Tier-0 (109) and smoke
(3, real coremltools conversion) green; [M2-ANE] golden pending a server.

* refactor(extraction): E3 thin nodes onto coreml_diffusion + discovery dropdowns

The CoreMLConverter node now calls coreml_diffusion directly instead of the
coreml_suite.converter shim, and its dropdowns are populated at runtime from
the package's discovery API.

- INPUT_TYPES dropdowns (model_version / attention_implementation /
  quantize_nbits) now come from a fail-soft _discover() that calls
  coreml_diffusion.list_*; a missing/old package falls back to a literal list
  and logs a warning instead of de-registering the node. Installing a newer
  coreml_diffusion surfaces new conversion types with no Suite change.
- folder_paths path resolution moved inline into the node; the package's
  convert() takes the output path as an injected positional.
- compose_out_name / lora_names_from_params now imported from coreml_diffusion
  (lazily, inside convert) — no node-side copy.
- deleted the dead coreml_suite/converter.py and coreml_suite/core/naming.py
  shims (no remaining importers).

Field names, RETURN_TYPES/NAMES and NODE_*_MAPPINGS unchanged; dropdown values
are a superset of the prior literals (additive-only). Tier-0 (109) and smoke
(3) green; [M2-ANE] golden re-runs on push.

* refactor(extraction): E5 depend on external coreml-diffusion package

Conversion code now lives in the standalone coreml-diffusion repo. The Suite
deletes its in-tree copy and depends on the package instead.

- removed coreml_diffusion/ (whole package), coreml_suite/model_version.py and
  coreml_suite/attention.py (moved to the package as its source of truth), and
  the tests that moved with them (discovery, conversion_helpers, out_name; smoke
  synthetic_unet + split_einsum)
- re-pointed ModelVersion imports (config.py, nodes.py, lcm/converter.py) to
  coreml_diffusion
- pyproject: drop the coreml_diffusion package include and the conversion-only
  deps (peft/omegaconf/transformers, now transitive via coreml-diffusion); add
  coreml-diffusion as a dependency with a local path source until it is published
  (switch to git tag/PyPI once the repo exists, so CI can resolve it)

Suite Tier-0 green (75); conversion code fully absent from the Suite. The comfy
node still imports coreml_diffusion (installed package) for ModelVersion + the
discovery dropdowns + convert.

* build(extraction): pin coreml-diffusion to git tag v0.1.0

Switch the coreml-diffusion source from a local path to the published git tag
so CI can resolve it. Suite Tier-0 green resolving from the tag.

* ci(extraction): drop Suite smoke tier (moved to coreml-diffusion)

The conversion smoke tests moved to the coreml-diffusion repo, which runs its
own Tier 1. The Suite's smoke lane had no tests left (pytest exit 5). The Suite
keeps Tier 0 (inference units) and the m2 golden e2e.

* chore(release): v2.1.0; wire coreml-diffusion into requirements.txt

Minor bump: the conversion path moved to the external coreml-diffusion package
(node graph + artifact cache keys unchanged, golden-verified). requirements.txt
(used by ComfyUI Manager) now installs coreml-diffusion from the v0.1.0 tag and
drops the conversion-only deps now provided transitively.
2026-05-26 22:12:56 +02:00

619 lines
38 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ComfyUI-CoreMLSuite — Converter Extraction Spec for Claude Code
> **Companion to `MODERNIZATION_SPEC.md`.** That spec hardens the repo and (Phase 3)
> splits the *inference* math from the framework. **This** spec splits the *conversion*
> path (`safetensors → CoreML`) out into a standalone, `comfy`-free, pip-installable
> package that CoreMLSuite then depends on — and that other projects (incl. on-device
> iOS tooling) can reuse.
>
> **Same discipline as the modernization spec:** safety-net first, behavior-preserving
> until told otherwise, one phase = one branch = one PR, `STOP — VALIDATE` gate between
> every phase, golden-latent as the regression anchor. `[M2]` = needs macOS/Apple Silicon;
> `[M2-ANE]` = needs the Neural Engine. Everything else must run on plain Linux/CI.
---
## 0. How to work (read first — non-negotiable)
1. **Behavior-preserving until Phase E6.** Phases E1–E5 must not change image output, node
names, `INPUT_TYPES` field names, or `NODE_CLASS_MAPPINGS` keys. The node graph is the
public contract; saved user-workflow JSON breaks if these change.
2. **The conversion package produces an artifact and stops there.** Its job ends at a written
`.mlpackage` / `.mlmodelc` on disk. It must NOT import `comfy`, `folder_paths`, or
`comfy_extras`, and must NOT know ComfyUI's `models/unet` layout. Paths are *inputs*.
3. **The runtime loader stays in the suite.** The loader is the **local** `coreml_suite.coreml_model.CoreMLModel`
— a thin wrapper over `coremltools.models.MLModel` (NOT Apple's
`python_coreml_stable_diffusion.coreml_model.CoreMLModel`, which is no longer used; see #58).
It *runs* a compiled model in Python — a desktop/Python inference concern, not a conversion
concern. It is NOT moved into the package. (On iOS the `.mlmodelc` is loaded natively; the
package's output is the deliverable, not a Python runner.)
4. **Decouple in-repo before splitting repos.** Phases E1–E4 create the package *inside this
repo* and prove equivalence. The physical second-repo split is Phase E5, only after the
golden latent is proven identical. Do not create a second repository before Gate E4 passes.
5. **Reuse the existing regression anchor.** The golden latent / PSNR anchor from
`MODERNIZATION_SPEC.md` Phase 2 is the cross-cutting proof for every gate here. If it is not
yet captured, capture it first (it is a prerequisite for E2 onward).
6. **No new runtime dependencies** without flagging in the gate report (name, why, license, size).
7. **A failing gate means stop and report**, not work around into the next phase.
8. **Tooling is `uv`, not bare `pip`/`venv`.** Every environment/install/lock step uses the
project's `uv` toolchain: `uv venv`, `uv pip install`, `uv pip install -e .`, `uv lock`,
`uv run pytest`, `uv export`/`uv pip freeze` for baselines. Where this spec says "fresh venv",
read "`uv venv` + `uv pip install`". Reserve `uv pip` (not `pip`) inside that venv too.
9. **The package is the single source of truth for *what is possible*; the node is a thin,
discovery-driven frontend.** See the "Interface contract" pillar below — this is the
maintainer's hard requirement and it overrides the earlier (now-rescinded) "freeze the
dropdown list" instruction.
---
## Interface contract (the maintainer's hard requirement) — read before any phase
Two coupled guarantees must hold once the package is split out:
**(A) Updating the converter must NOT require updating CoreMLSuite.**
This is satisfied by treating the package's public surface as a versioned contract:
- `convert(...)` and `compile_model(...)` are **keyword-only with defaults** for everything
past the genuinely-required positionals (`ckpt_path`, `model_version`, `out_path`). New
capabilities are added as new keyword args with defaults, so an old Suite's call still
validates against a newer package. **Never** reorder or rename existing parameters.
- `compose_out_name` (the `.mlpackage` filename = the cache key) **moves into the package** and
is versioned with it. The Suite must not carry its own copy; if the package changes the naming
scheme that is a **major** bump (old cached artifacts stop resolving).
**(B) CoreMLSuite must be able to list *new* conversion types WITHOUT a Suite code change or
version bump.** Today the node hardcodes its dropdowns:
```python
"model_version": ([ModelVersion.SD15.name, ModelVersion.SDXL.name],), # hand-typed, also INCOMPLETE (no LCM / SDXL_REFINER)
"attention_implementation": (list(ATTENTION_IMPLEMENTATIONS),), # from coreml_suite.attention
"quantize_nbits": (list(QUANT_NBITS_VALUES), {"default": "none"}), # from coreml_suite.core.naming
```
These are replaced by **runtime discovery calls into the package**, evaluated inside
`INPUT_TYPES` (ComfyUI re-evaluates `INPUT_TYPES` on every plugin load):
```python
import coreml_diffusion
"model_version": (coreml_diffusion.list_model_versions(),),
"attention_implementation": (coreml_diffusion.list_attention_impls(),),
"quantize_nbits": (coreml_diffusion.list_quant_modes(), {"default": "none"}),
```
Effect: `uv pip install -U coreml_diffusion` + ComfyUI restart surfaces any newly-added type in the old
plugin's dropdown — **no Suite edit, no Suite version bump.** This is the requirement.
**The cost, stated honestly (accept this trade-off explicitly at Gate E0):**
- The Suite becomes a "dumb" frontend; the package is the sole authority on what conversions
exist. The Suite can no longer guarantee its saved workflows are valid against *arbitrary*
future package versions.
- Therefore the package's discovery identifiers (`ModelVersion` values, attn-impl strings, quant
modes) are an **ADDITIVE-ONLY contract**: the package may *add* identifiers freely (minor bump,
no Suite change); **removing or renaming an identifier is a breaking change requiring a MAJOR
bump and a migration note**, because a saved workflow JSON references these strings verbatim.
Without this rule, "no version bump" silently becomes "randomly broken workflows."
- `INPUT_TYPES` must **fail soft** when the package is missing/old: wrap the discovery calls so a
missing `coreml_diffusion` (or an old one lacking a `list_*` function) yields a sane fallback list and a
logged warning, instead of the node failing to register and disappearing from the menu.
**Discovery API the package must expose (stable names):**
```python
coreml_diffusion.list_model_versions() -> list[str] # VERIFIED ones only, e.g. ["SD15","SDXL"] today (.name — see seam.md)
coreml_diffusion.list_attention_impls() -> list[str] # ["SPLIT_EINSUM","SPLIT_EINSUM_V2","ORIGINAL"]
coreml_diffusion.list_quant_modes() -> list[str] # ["none","8","6","4"]
coreml_diffusion.CONTRACT_VERSION: str # bump rules above; Suite may log/compare it
```
These return the *display strings already used today*, so existing workflows keep validating.
**Verification status is a PACKAGE property, not a node hardcode (maintainer's intent).**
The Suite wants to expose *every model the converter can verifiably convert*. Today `lcm` and
`sdxl_refiner` are absent from the converter node not because the Suite chooses to hide them, but
because they lack a full golden/PSNR verification. So the gating lives in the package as a status:
```python
from enum import Enum
class Status(Enum):
VERIFIED = "verified" # has a golden anchor + passing [M2-ANE] check
EXPERIMENTAL = "experimental" # convertible but not yet anchored/verified
# internal registry, single source of truth.
# KEY by ModelVersion enum MEMBER so list_* can emit .name. Keying by the lowercase
# .value string returns ["sd15",...], which the node reverses via ModelVersion[...] -> KeyError.
_MODEL_STATUS = {ModelVersion.SD15: Status.VERIFIED, ModelVersion.SDXL: Status.VERIFIED,
ModelVersion.SDXL_REFINER: Status.EXPERIMENTAL, ModelVersion.LCM: Status.EXPERIMENTAL}
def list_model_versions(include_experimental: bool = False) -> list[str]:
return [v.name for v, s in _MODEL_STATUS.items() # .name -> "SD15","SDXL"; node reverses with ModelVersion[...]
if s is Status.VERIFIED or (include_experimental and s is Status.EXPERIMENTAL)]
```
Consequence: **promoting a model to VERIFIED in the package expands the Suite's dropdown with no
Suite change and no Suite bump** — exactly the requirement. The act of verification (E-LCM
produces an LCM golden anchor; same later for refiner) is what flips the status. The Suite's
converter node calls `list_model_versions()` (verified-only); a power-user/CLI path may pass
`include_experimental=True`. Promotion VERIFIED-from-EXPERIMENTAL is additive (minor bump);
demotion or removal is breaking (major bump + note).
---
## Naming & layout (chosen — frozen at Gate E0)
**Distribution name (PyPI):** `coreml-diffusion`. **Import name (Python):** `coreml_diffusion`.
(PyPI normalizes `-`/`_`; the distribution uses the hyphen, the importable module the underscore.)
Availability checked: both `coreml-diffusion` and the near variants were free on PyPI at E0.
**Why this name (the positioning it encodes):** the project's niche is *diffusion models on Apple
Neural Engine via CoreML, inside ComfyUI and on-device* — **not** Stable Diffusion specifically.
`sd*` was rejected because it falsely narrows scope to SD; `coreml-diffusion` keeps `coreml` on the
front for discoverability while `diffusion` honestly states the scope (SD/SDXL/LCM today, Flux and
other diffusion architectures later) **without** promising arbitrary non-diffusion torch models,
whose tracing/shape/sample-input pipeline differs. The name must not be re-narrowed to SD in
future docs. ANE is the *differentiator* (documented in the README), but `coreml` was chosen over
`ane` in the name for search discoverability per maintainer decision.
Target package layout (framework-free — zero `comfy` imports):
```
coreml_diffusion/
__init__.py # public API surface (see "Public API" below)
model_version.py # ModelVersion enum — the SINGLE source of truth, no comfy
attention.py # ATTENTION_IMPLEMENTATIONS tuple (from coreml_suite/attention.py) + apply_attention_implementation
pipeline.py # get_pipeline (from_single_file), get_unet (cml UNet from ref unet)
unet.py # UNet2DConditionModelLCM (moved from coreml_suite/lcm/unet.py)
inputs.py # get_sample_input, lcm_inputs, sdxl_inputs,
# get_encoder_hidden_states_shape, get_coreml_inputs, get_inputs_spec
controlnet.py # add_cnet_support (conversion-side residual SHAPE calc only)
convert.py # convert_unet, convert (orchestration), convert_to_coreml, load_coreml_model
compile.py # compile_coreml_model
quantize.py # (Phase E6 / MODERNIZATION Phase 6 lands here) palettization 4/6/8-bit
cli.py # console entry point: `coreml-diffusion convert ...`
pyproject.toml # standalone packaging (at E5)
```
What stays in `coreml_suite/` (the ComfyUI side, thinned):
- `nodes.py` — still owns **name-encoding** (`out_name` construction), path resolution via
`folder_paths`, the node `INPUT_TYPES`/mappings, and wrapping the result in `CoreMLModel`.
- `models.py`, `latents.py`, `controlnet.py` (inference parts), `lcm/utils.py`, `config.py`
(inference config build) — untouched by this spec except the import-source of `ModelVersion`.
### Public API (the contract `coreml_diffusion` exposes)
```python
from coreml_diffusion import ModelVersion, convert, compile_model, compose_out_name
from coreml_diffusion import list_model_versions, list_attention_impls, list_quant_modes, CONTRACT_VERSION
# Mirror the CURRENT converter.py signature, made keyword-only past the required positionals
# and with paths/device injected (no folder_paths, no comfy.model_management):
# convert(ckpt_path, model_version, out_path, *,
# batch_size=1, sample_size=(64, 64), controlnet_support=False,
# lora_weights=None, attn_impl=list_attention_impls()[0], config_path=None,
# quantize_nbits="none", device=None) -> None # side effect: writes out_path
# (current convert() returns None and writes via convert_unet → coreml_unet.save; keep that,
# or change to `return out_path` as a deliberate, documented improvement — pick one at E0.)
# compile_model(src_path, out_dir, final_name) -> str # returns compiled .mlmodelc path
```
Note: `convert` takes an **explicit `out_path`** — no `folder_paths`. `device` is injected
(defaults to torch's default device). `compose_out_name` lives here (cache-key contract) and the
node imports it from the package. The `list_*` discovery functions back the node's dropdowns.
---
## The import chains to cut (root cause inventory) — REVISED against current code
> **State note (verified):** the code moved on since the original draft. Several chains are
> already cut. Re-verify each line by `grep` before acting; do not assume the original draft.
**Already done (verify, then skip):**
- ✅ `converter.py` already imports `from coreml_suite.model_version import ModelVersion`, and
`model_version.py` is **clean** (`from enum import Enum` only — zero comfy). The old
"converter → config → comfy" chain is **already broken**. `config.py` still imports comfy, but
it is **inference-side** (`get_model_config` via `supported_models_base`/`latent_formats`) —
*not* on the conversion path. Do **not** treat `config.py` as a converter dependency.
- ✅ `converter.py` now uses `diffusers.UNet2DConditionModel.from_single_file` and a local
`CoreMLUNetWrapper` (in `coreml_suite/conversion/unet.py`) — it is **no longer** importing the
Apple `python_coreml_stable_diffusion.unet.UNet2DConditionModel*` internals on the main path.
A `coreml_suite/conversion/` subpackage already exists (`attention`, `shapes`, `trace`, `unet`).
- ✅ Name-encoding already extracted to `coreml_suite/core/naming.py` (`compose_out_name`,
`lora_names_from_params`, `ATTN_SUFFIX`, `QUANT_NBITS_VALUES`) **with characterization tests**
(`tests/unit/test_characterization_out_name.py`). The pure-naming split is done.
- ✅ Quantization is **already implemented** in `converter.py` (`quantize_nbits`, k-means
`palettize_weights`) and surfaced as an optional node input. Phase E6 is therefore *move*, not
*build* (see revised E6).
**Still to cut (the real remaining work):**
1. `coreml_suite/converter.py::get_out_path` → `from folder_paths import get_folder_paths`.
Main converter still reaches into ComfyUI's model dir. **Cut: `out_path` is an injected arg;
`folder_paths` resolution moves up into the node** (the node already computes `out_name`).
2. `coreml_suite/lcm/converter.py` → still has its **own** `from folder_paths import
get_folder_paths` (`get_out_path`) and (per original draft) `comfy.model_management`. Verify
the current LCM file and cut both: inject `out_path` and `device`.
3. Global mutation of the attention impl: confirm where it now lives. Main path appears to route
through `coreml_suite/conversion/attention.apply_attention_implementation` (cleaner than the
old global), but `lcm/converter.py` may still set a module global at import. **Ensure the
package sets attention per-call, never at import time.**
4. **Duplication LCM vs main:** `lcm/converter.py` still carries its own copies of
`convert_to_coreml`, `load_coreml_model`, `get_out_path`, `get_sample_input` (the LCM variant
takes a `scheduler` arg), and hardcodes `SimianLuo/LCM_Dreamshaper_v7`. **Dedupe into the
single `coreml_diffusion` implementation;** the HF-hardcode consolidation is the *behavior-changing*
part → deferred to optional **E-LCM**, not E1–E5.
5. **`compose_out_name` ownership:** currently in `coreml_suite/core/naming.py` and called by the
node. Per the Interface-contract pillar it must **move into the package** (it is the cache-key
contract) and the node must import it from `coreml_diffusion`, not keep a copy.
---
## Phase E0 — Seam decision & inventory (no code change)
**Objective:** lock the cut line, the interface contract, and naming so later phases don't drift.
### Tasks
1. Produce `docs/extraction/seam.md`: a table of every symbol in `converter.py`,
`lcm/converter.py`, `lcm/unet.py`, **plus the already-extracted `conversion/` subpackage
(`attention`, `shapes`, `trace`, `unet`) and `core/naming.py`**, classified
**CONVERSION → coreml_diffusion** vs **STAYS (comfy/node)**. Note which are already framework-free.
2. ~~Confirm the current `python_coreml_stable_diffusion` footprint.~~ **DONE (seam.md §6):
footprint is ZERO** — no runtime imports anywhere; only a docstring mention in
`core/__init__.py:4`. Main path uses `diffusers` + local `CoreMLUNetWrapper`; the runtime
`CoreMLModel` (STAYS in suite) is a local coremltools wrapper, not Apple's. No shape/attn helper
comes from Apple (local `conversion/shapes.py`, `conversion/attention.py`).
3. **Decide the interface contract concretely (the maintainer's hard requirement):**
- Discovery functions `list_model_versions / list_attention_impls / list_quant_modes` live in
the package and return today's display strings verbatim. Node `INPUT_TYPES` calls them.
- `ModelVersion` values, attn-impl strings, quant modes are **ADDITIVE-ONLY** across package
versions; removal/rename = MAJOR bump + migration note. Write this into the package's
versioning policy doc now.
- `compose_out_name` moves to the package; node imports it (no copy). Confirm the
characterization tests in `test_characterization_out_name.py` will be re-pointed, not
duplicated.
- **Resolve the `model_version` dropdown question (maintainer decided):** the Suite exposes
*every model the converter can verifiably convert*. `lcm` and `sdxl_refiner` are absent today
only because they lack a golden/PSNR verification — **not** because the node hardcodes a
short list. Encode this as a **status registry in the package** (`VERIFIED` vs
`EXPERIMENTAL`); `list_model_versions()` returns VERIFIED-only by default. The converter node
calls it plainly. Promoting LCM/refiner to VERIFIED (after E-LCM / a refiner anchor) expands
the dropdown with **no Suite change**. Do NOT add permanent per-node filtering — the gate is
verification status, owned by the package.
4. ~~Confirm the `ml-stable-diffusion` git dep is pinned.~~ **N/A — already removed (#58).** Verified:
zero `python_coreml_stable_diffusion` imports in the repo; `CoreMLModel` is now a local
coremltools wrapper; the dep is absent from `pyproject.toml`/`requirements.txt`. No SHA to pin.
### STOP — VALIDATE (Gate E0)
```
## Gate E0 report
- seam.md committed: <path>; symbol counts (move / stay / already-framework-free)
- python_coreml_stable_diffusion usage (verified by grep): conversion=<list> runtime=<list>
- Discovery API signatures frozen: list_model_versions (verified-only) / list_attention_impls / list_quant_modes
- Status registry decided: sd15+sdxl=VERIFIED, lcm+sdxl_refiner=EXPERIMENTAL (gated, not hidden)
- Additive-only contract policy doc written (incl. promotion=minor, demotion/removal=major): <path>
- model_version dropdown: expose all (incl. LCM/REFINER) / filtered per node — DECISION: <...>
- compose_out_name move-not-copy confirmed; tests re-point plan: <...>
- LCM consolidation deferred to optional E-LCM: YES/NO
- ml-stable-diffusion: N/A — already removed (#58), not a dependency (was: pin-or-BLOCKER)
- Package name in-repo: coreml_diffusion (final PyPI name deferred to E5)
```
---
## Phase E1 — Establish `coreml_diffusion` package + discovery API (mostly verification)
**Objective:** stand up the package namespace and the discovery surface. Much of the comfy-chain
cut is **already done** — this phase mostly *verifies* that and adds the discovery functions.
### Tasks
1. **Verify (don't redo):** `coreml_suite/model_version.py` is already clean (`Enum` only). Confirm
`import coreml_suite.model_version` works with **no comfy** (`uv run python -c "..."` in a
comfy-free `uv venv`). If true, E1's original "extract ModelVersion" task is already satisfied.
2. Create the `coreml_diffusion/` package skeleton with `__init__.py` exporting the **discovery API**
backed by the *existing* sources of truth for now (re-export `ModelVersion`, the
`ATTENTION_IMPLEMENTATIONS` tuple, and `QUANT_NBITS_VALUES`) so values are byte-identical:
```python
def list_model_versions(): return [v.name for v in ModelVersion] # .name -> "SD15" (node reverses via ModelVersion[...]; .value KeyErrors)
def list_attention_impls(): return list(ATTENTION_IMPLEMENTATIONS)
def list_quant_modes(): return list(QUANT_NBITS_VALUES)
CONTRACT_VERSION = "1.0"
```
(At this stage `coreml_diffusion` may live inside the repo and import from `coreml_suite.*`; the
physical move of implementation happens in E2. The point of E1 is to freeze the *contract*.)
3. **Decided (`.name`):** the node renders `ModelVersion.SD15.name` (`"SD15"`) and reverses the
dropdown string via `ModelVersion[model_version]` (name lookup, `nodes.py:286`). Discovery API
therefore returns `.name`; `.value` (`"sd15"`) would `KeyError`. Recorded in `seam.md` §5.
### Acceptance criteria
- `uv run python -c "import coreml_diffusion; print(coreml_diffusion.list_model_versions(), coreml_diffusion.list_quant_modes())"`
works in a **comfy-free** `uv venv` and prints today's exact strings.
- Existing characterization tests pass unchanged.
- No node behavior change yet (node still uses its current hardcoded lists in E1).
### STOP — VALIDATE (Gate E1)
```
## Gate E1 report
- model_version.py confirmed comfy-free (uv, no comfy): PASS/FAIL
- coreml_diffusion.list_* returns byte-identical strings to current dropdowns: YES/NO (show values)
- .name vs .value decision for model_version discovery: <...>
- CONTRACT_VERSION set; additive-only policy linked: <path>
- Characterization tests unchanged & green (uv run pytest): YES/NO
```
---
## Phase E2 — Move conversion code into `coreml_diffusion` (in-repo, dedup, behavior-preserving)
**Objective:** physically relocate the conversion mechanics into the framework-free package,
collapsing the two duplicate converters into one, with paths/device injected.
### Tasks
1. Move into `coreml_diffusion/`: `pipeline.py` (`get_pipeline`, `get_unet`), `unet.py`
(`UNet2DConditionModelLCM`), `inputs.py` (sample/lcm/sdxl input builders +
`get_encoder_hidden_states_shape` + `get_coreml_inputs` + `get_inputs_spec`),
`controlnet.py` (`add_cnet_support`), `convert.py` (`convert_unet`, `convert`,
`convert_to_coreml`, `load_coreml_model`), `compile.py` (`compile_coreml_model`).
2. **Dedupe LCM vs main** (the real remaining duplication): delete `lcm/converter.py`'s copies of
`convert_to_coreml` / `load_coreml_model` / `get_out_path` / `get_sample_input` (LCM variant
carries a `scheduler` arg — fold that into the shared `get_sample_input` as an optional param)
in favor of the single `coreml_diffusion` implementation. The main path's helpers
(`get_unet`/`get_encoder_hidden_states_shape`/`get_coreml_inputs`/`convert_unet`/`convert`) and
the `conversion/` subpackage (`attention`, `shapes`, `trace`, `unet`) move as-is.
3. **Inject paths**: replace `get_out_path`'s `folder_paths` reach-in with an injected `out_path`
argument on `convert(...)`; `folder_paths` resolution moves up into the node (which already
computes `out_name`). No `folder_paths` import anywhere in `coreml_diffusion`.
4. **Inject device** where the LCM path used `comfy.model_management` (verify it still does):
`convert(..., device=None)`, default to torch's default device.
5. **Attention per-call, never at import:** main path already routes through
`conversion/attention.apply_attention_implementation` — keep that. If `lcm/converter.py` still
sets any module global at import, remove it; the package sets attention from the `attn_impl`
arg inside `convert`.
6. **Move `compose_out_name` into the package** (`coreml_diffusion/naming.py`); re-point
`test_characterization_out_name.py` imports to `coreml_diffusion.naming` — assertions and values
unchanged. The node will import it from the package in E3.
7. Leave **thin shims** in `coreml_suite/converter.py` and `coreml_suite/lcm/converter.py` that
re-export from `coreml_diffusion`, preserving the old call signatures the nodes use (nodes untouched
this phase). Shims map comfy `folder_paths`/device into package args.
### Acceptance criteria
- `uv run pytest -m unit` (Tier 0) imports `coreml_diffusion.*` with **no comfy / no MPS** and is green on Linux.
- The dedup leaves exactly one implementation of each previously-duplicated function.
- Characterization tests pass unchanged after the `compose_out_name` re-point.
- `[M2]` A real SD1.5 conversion via the shim still produces a loadable model.
- `[M2-ANE]` **Golden latent identical / within tolerance** to the MODERNIZATION Phase 2 anchor
(same seed/prompt) — proves the move + dedup changed nothing.
### STOP — VALIDATE (Gate E2 — first regression gate)
```
## Gate E2 report
- Tier 0 import of coreml_diffusion without comfy/MPS (uv run): PASS/FAIL
- LCM/main duplicated funcs collapsed to one (list old→new): <map>
- compose_out_name moved to package; char-tests re-pointed & green: YES/NO
- Paths injected (no folder_paths in package): confirmed
- Device injected (no comfy.model_management in package): confirmed
- Attention set per-call, not at import (both main & lcm): confirmed
- [M2-ANE] Golden latent vs Phase-2 anchor: identical / within tol <x> / DIVERGED (STOP)
- Node INPUT_TYPES / mappings untouched: confirmed (diff)
```
**If the golden latent diverged at all, STOP and report — do not continue.**
---
## Phase E3 — Thin the nodes onto the package (behavior-preserving)
**Objective:** remove the shims; have the ComfyUI nodes call `coreml_diffusion` directly, keeping the
node contract byte-identical.
### Tasks
1. `CoreMLConverter.convert` (in `coreml_suite/nodes.py`): keep the `folder_paths`-based path
resolution **in the node**; import `compose_out_name` from `coreml_diffusion` (not `coreml_suite.core`);
call `coreml_diffusion.convert(...)` and `coreml_diffusion.compile_model(...)` directly; wrap the compiled path
in `CoreMLModel`.
2. **Wire the dropdowns to discovery (the maintainer's hard requirement).** Replace the hardcoded
`INPUT_TYPES` lists with fail-soft discovery calls:
```python
def _discover(fn, fallback):
try:
import coreml_diffusion
return getattr(coreml_diffusion, fn)()
except Exception as e: # missing/old package, or import error
logger.warning(f"coreml_diffusion.{fn} unavailable ({e}); using fallback {fallback}")
return fallback
...
"model_version": (_discover("list_model_versions", ["SD15", "SDXL"]),),
"attention_implementation": (_discover("list_attention_impls", ["SPLIT_EINSUM","SPLIT_EINSUM_V2","ORIGINAL"]),),
"quantize_nbits": (_discover("list_quant_modes", ["none","8","6","4"]), {"default": "none"}),
```
This is what makes "update the package → new types appear in the old node, no Suite bump" true.
3. `COREML_CONVERT_LCM` (in `coreml_suite/lcm/nodes.py`): route through `coreml_diffusion` for the shared
mechanics. **Keep the existing LCM behavior/HF-hardcode for now** — consolidation is optional E-LCM.
4. Delete the now-dead `coreml_suite/converter.py` / `coreml_suite/lcm/converter.py` shims (or
reduce to a one-line re-export if anything external imports them — grep first).
### Acceptance criteria
- `NODE_CLASS_MAPPINGS` / `NODE_DISPLAY_NAME_MAPPINGS` keys: **unchanged** (diff `__init__.py`).
- Every `INPUT_TYPES` **field name** unchanged. Dropdown **values**: the discovery calls must
return **a superset of** today's values, with every previously-present value still present and
spelled identically (additive-only). *(This deliberately replaces the original spec's
"values must be byte-identical/frozen" criterion — the maintainer requires the list be
extensible at runtime. Frozen-field-names + additive-only-values is the new contract.)*
- With `coreml_diffusion` **absent**, the node still registers and shows the fallback lists (fail-soft).
- `[M2-ANE]` Golden latent still identical to the Phase-2 anchor.
- `[M2-ANE]` The committed e2e workflow `tests/integration/...` still passes (PSNR > 25).
### STOP — VALIDATE (Gate E3)
```
## Gate E3 report
- Node mappings diff: empty (confirmed)
- INPUT_TYPES field-names diff: empty (confirmed)
- Dropdown values: superset of prior, all prior values still present & identical: YES/NO (show)
- Fail-soft with coreml_diffusion absent (node still registers): PASS/FAIL
- compose_out_name now imported from coreml_diffusion (no node-side copy): confirmed
- [M2-ANE] Golden latent vs anchor: identical / within tol / DIVERGED (STOP)
- [M2-ANE] e2e workflow PSNR: <value> (> 25?)
- Dead converter shims removed / reduced: <list>
```
---
## Phase E4 — Standalone packaging & CLI (still in-repo)
**Objective:** make `coreml_diffusion` independently installable and usable without ComfyUI, with a CLI
suitable for the planned article and for on-device/iOS conversion workflows.
### Tasks
1. Add `coreml_diffusion/pyproject.toml`: name (working `coreml_diffusion`), `requires-python`, dependencies
= `coremltools` (pinned to the MODERNIZATION-validated version), `diffusers`, `transformers`,
`peft`, `omegaconf`, `numpy`, `torch`. **No `ml-stable-diffusion`** (already removed in #58, see
§0.3) and **no comfy**. Suite pins `transformers>=4.44`/`peft>=0.13`/`omegaconf>=2.3` today;
grep-confirm each is on the conversion path before listing it. A `[project.scripts]` entry:
`coreml-diffusion = "coreml_diffusion.cli:main"`.
2. `coreml_diffusion/cli.py`: `coreml-diffusion convert --ckpt PATH --model-version sd15 --out PATH
[--height --width --batch-size --attn-impl --controlnet --lora NAME:STRENGTH ... --config PATH]`
and `coreml-diffusion compile --src PATH --out-dir DIR --name NAME`. Mirrors `convert()`/`compile_model()`.
3. Tier-0 Linux tests for the CLI **arg→call mapping** (mock the heavy `convert`); the real
convert remains `[M2]`. Add a `[M2]` smoke test: convert a tiny synthetic UNet end-to-end.
4. README for the package: install, CLI usage, "produce a `.mlpackage`/`.mlmodelc` for use in a
Swift/iOS app", and the ANE positioning note (low-power, GPU-free, embeddable; SD1.5/SDXL on
ANE, **not** a Flux-speed claim).
### Acceptance criteria
- Fresh `python -m venv` + `uv pip install ./coreml-diffusion` (no ComfyUI present) imports and runs
`coreml-diffusion --help` and the arg-mapping tests on Linux.
- `[M2]` `coreml-diffusion convert` produces a model file identical (golden) to the node path.
### STOP — VALIDATE (Gate E4)
```
## Gate E4 report
- uv pip install ./coreml-diffusion in comfy-free venv: PASS/FAIL (log)
- CLI arg→call tests (Tier 0, Linux): green
- [M2] CLI-produced model golden vs node-produced model: identical / DIVERGED
- Package deps list (with pinned SHAs/versions + licenses):
- New runtime deps vs suite before: <none / list>
```
**This is the gate that proves the package stands alone. Do not split repos before it passes.**
---
## Phase E5 — Physical split into a second repository
**Objective:** move `coreml_diffusion/` to its own repo; CoreMLSuite depends on it by pinned version.
### Tasks
1. Create the new repo (maintainer action — agent prepares the tree, not the GitHub repo).
Choose final distributable name; rename imports if changed (single sweep, recorded).
2. CoreMLSuite `pyproject.toml` / `requirements.txt`: replace the conversion-only deps with a
pinned dependency on the new package (`coreml_diffusion==<version>` from PyPI, or `git+...@<tag>`
until first PyPI release). (There is no `git+...ml-stable-diffusion` line to remove — already
gone since #58.)
3. ~~Keep `python_coreml_stable_diffusion` for the loader.~~ **Void.** The loader is the local
`coreml_suite/coreml_model.py` over `coremltools`; the suite keeps `coremltools` as a direct dep
for it. No Apple lib involved.
4. Set up the new repo's CI: Tier 0 on Linux (import + arg-mapping + input-shape math),
`[M2]`/`[M2-ANE]` on a self-hosted/macOS-ARM runner reusing the golden-latent anchor.
5. Versioning: SemVer; first release `0.1.0`. Document the compatibility matrix
(coreml_diffusion ↔ coremltools version ↔ diffusers version). No ml-stable-diffusion axis.
### Acceptance criteria
- CoreMLSuite installs in a fresh venv pulling the new package; e2e workflow still passes `[M2-ANE]`.
- New repo CI green on Linux (Tier 0) and `[M2-ANE]` golden latent matches the anchor.
- No conversion code remains in CoreMLSuite (grep: no `ct.convert`, no `from_single_file`,
no `torch.jit.trace`).
### STOP — VALIDATE (Gate E5)
```
## Gate E5 report
- New repo tree prepared: <path/branch>; final package name: <name>
- Suite depends on package by pinned version: <spec>
- Suite e2e [M2-ANE] PSNR after split: <value> (> 25?)
- Conversion code fully absent from suite: confirmed (grep output)
- Compatibility matrix documented: <link>
- First release tag: 0.1.0
```
---
## Phase E6 — Quantization travels WITH the conversion code (already implemented → move)
**Objective:** quantization is **already implemented** (k-means `palettize_weights` in
`converter.py`, `quantize_nbits` node input, `_q<bits>` filename suffix, README tradeoff table).
There is nothing to *build*. It simply **moves with the conversion code in E2** as part of
`convert_unet`. This phase is a checkpoint that it survived the extraction intact, plus exposing
it through the CLI.
### Tasks
1. Confirm the palettization block moved cleanly into `coreml_diffusion` (lives in `convert.py` or a
`quantize.py` helper called from `convert_unet`). Default `"none"` stays byte-identical.
2. Expose via CLI flag `--quantize {none,8,6,4}` (E4 already lists this) and via
`list_quant_modes()` discovery (E1/E3).
3. The existing README tradeoff table (SD1.5 1×512×512 SPLIT_EINSUM: none/8/6/4 → size/ms/PSNR)
moves to the package README. Re-confirm one row `[M2-ANE]` so the article can cite a live number.
### Acceptance criteria
- Default (`none`) output byte-identical to pre-extraction (covered by the E2/E3 golden latent).
- `coreml-diffusion convert --quantize 4` produces a `_q4` artifact matching the node's `_q4` artifact `[M2]`.
- `list_quant_modes()` drives the node dropdown (no hardcoded copy remains).
### STOP — VALIDATE (Gate E6)
```
## Gate E6 report
- Palettization relocated into coreml_diffusion, called from convert_unet: confirmed
- Default none output identical (golden): YES/NO
- [M2] CLI --quantize {8,6,4} artifacts match node artifacts: YES/NO
- Tradeoff table in package README with at least one re-confirmed [M2-ANE] row: <link>
```
---
## Phase E-LCM — FIRST task after the split: clean up LCM + verify → promote (behavior-changing, gated)
> Promoted from "optional, someday" to **the first thing after E5**, per maintainer intent: the
> Suite should expose every verifiably-convertible model, and LCM is the obvious first cleanup.
Two coupled goals:
1. **Consolidate the LCM path.** Make the LCM node use the unified `from_single_file` path in
`coreml_diffusion.convert(model_version=LCM, ...)` instead of the hardcoded `SimianLuo/LCM_Dreamshaper_v7`
HF download; drop the duplicated LCM helpers (already deduped in E2). **Behavior change** ⇒
capture an LCM golden anchor *before* the change, then prove within-tolerance after.
2. **Verify → promote.** Once the LCM conversion has a passing `[M2-ANE]` golden anchor, flip
`_MODEL_STATUS["lcm"] = Status.VERIFIED` **in the package** (minor bump). The Suite's dropdown
gains `lcm` automatically — no Suite change, no Suite bump. This is the end-to-end proof that
the discovery contract works as designed.
Repeat the same recipe for `sdxl_refiner` when it gets an anchor (separate small gate). Do NOT
bundle E-LCM into E1–E5; it changes behavior and must stand on its own golden.
### STOP — VALIDATE (Gate E-LCM)
```
## Gate E-LCM report
- LCM golden anchor captured BEFORE change: <path/hash>
- LCM node now uses unified from_single_file path; HF hardcode removed: confirmed
- [M2-ANE] LCM golden after change: identical / within tol <x> / DIVERGED (STOP)
- Status flipped lcm→VERIFIED in package (minor bump <ver>): confirmed
- Suite dropdown now lists lcm with NO Suite code change / NO Suite bump: confirmed (diff empty)
- LCM node accepts a checkpoint arg now (documented breaking-ish UI note): <link>
```
---
## Article deliverable (after E4)
Once the CLI exists and stands alone, the "convert a Comfy/A1111 workflow into an on-device iOS
app" write-up becomes a clean tutorial: `coreml-diffusion convert` → `.mlmodelc` → load in Swift/CoreML.
Frame the niche honestly per the README note above (ANE feasibility & power, not raw Flux speed).
---
## Quick reference: extraction gate discipline
```
E0 Seam decision, interface contract, discovery API frozen → Gate E0 (cut line + additive-only policy?)
E1 Stand up coreml_diffusion + discovery API (mostly verify) → Gate E1 (list_* byte-identical, comfy-free?)
E2 Move conversion code, dedup LCM/main, inject paths/device→ Gate E2 (golden identical? duplicates gone?) ← first regression gate
E3 Thin nodes onto package + wire discovery dropdowns → Gate E3 (field-names frozen, values additive, fail-soft, golden identical?)
E4 Standalone packaging + CLI (uv) → Gate E4 (uv pip install w/o comfy? CLI golden?) ← proves it stands alone
E5 Physical second-repo split → Gate E5 (suite depends on pkg? conversion absent?)
E-LCM FIRST post-split: clean up LCM, verify → promote → Gate E-LCM (LCM golden? dropdown gains lcm w/ no Suite bump?)
E6 Quantization checkpoint (already built → moved in E2) → Gate E6 (default identical? CLI quant matches?)
(refiner) same recipe as E-LCM when an anchor exists → own small gate (promote sdxl_refiner→VERIFIED)
```
**Interface-contract invariants (the maintainer's hard requirement), restated:**
- Package API is keyword-only-with-defaults past the required positionals → converter updates
don't force Suite updates.
- Node dropdowns are discovery-driven (`coreml_diffusion.list_*`) + fail-soft → new conversion types
appear in the old plugin with `uv pip install -U coreml_diffusion`, **no Suite code change, no bump**.
- Discovery identifiers are **additive-only**; removal/rename = MAJOR bump + migration note.
- `compose_out_name` (cache key) lives in the package, single copy.
**Golden rule (inherited): never cross a gate with a failing acceptance criterion.
Stop, report, wait. The golden latent is the single source of truth that the extraction
changed nothing.**