#!/usr/bin/env python3 """Convert the selected purpose-lite checkpoint to a fixed-shape Core ML package.""" from __future__ import annotations import argparse import hashlib import json import math import shutil import sys from pathlib import Path from typing import Any, Sequence import numpy as np import torch import torch.nn.functional as functional from purpose_data import LABELS, DataError, load_jsonl, write_json from train import MAX_LENGTH, encode_fixed_shape SCRIPT_DIR = Path(__file__).resolve().parent DEFAULT_MODEL_DIR = ( SCRIPT_DIR / "outputs" / "purpose-lite-v1-distilled-qat-mlx-4e" / "model" ) DEFAULT_VALIDATION = SCRIPT_DIR / ".artifacts" / "dataset-v1" / "validation.jsonl" DEFAULT_OUTPUT = ( SCRIPT_DIR / "outputs" / "purpose-lite-v1-distilled-qat-mlx-4e" / "coreml" / "purpose-lite-v1-fp16.mlpackage" ) COREMLTOOLS_VERSION = "9.0" class FixedShapeBertForCoreML(torch.nn.Module): """Conversion-only BERT forward without Transformers' dynamic mask helpers.""" def __init__(self, model: Any) -> None: super().__init__() self.model = model config = model.config self.num_heads = int(config.num_attention_heads) self.head_size = int(config.hidden_size) // self.num_heads if int(config.hidden_size) % self.num_heads: raise DataError("BERT hidden size must be divisible by attention heads") if int(config.max_position_embeddings) < MAX_LENGTH: raise DataError("BERT checkpoint cannot represent the fixed 128-token input") self.register_buffer( "fixed_position_ids", torch.arange(MAX_LENGTH, dtype=torch.int64).reshape(1, MAX_LENGTH), persistent=False, ) def forward( self, input_ids: Any, attention_mask: Any, token_type_ids: Any, ) -> Any: bert = self.model.bert input_ids = input_ids.to(torch.int64) token_type_ids = token_type_ids.to(torch.int64) value = ( bert.embeddings.word_embeddings(input_ids) + bert.embeddings.position_embeddings(self.fixed_position_ids) + bert.embeddings.token_type_embeddings(token_type_ids) ) value = bert.embeddings.LayerNorm(value) zero = torch.zeros((), dtype=value.dtype, device=value.device) hidden = torch.full((), -10000.0, dtype=value.dtype, device=value.device) additive_mask = torch.where( attention_mask[:, None, None, :] != 0, zero, hidden, ) for layer in bert.encoder.layer: self_attention = layer.attention.self def split_heads(projected: Any) -> Any: return projected.reshape( 1, MAX_LENGTH, self.num_heads, self.head_size, ).permute(0, 2, 1, 3) queries = split_heads(self_attention.query(value)) keys = split_heads(self_attention.key(value)) values = split_heads(self_attention.value(value)) scores = torch.matmul(queries, keys.transpose(-1, -2)) / math.sqrt( self.head_size ) probabilities = torch.softmax(scores + additive_mask, dim=-1) context = torch.matmul(probabilities, values) context = context.permute(0, 2, 1, 3).reshape( 1, MAX_LENGTH, -1, ) attention_output = layer.attention.output.dense(context) value = layer.attention.output.LayerNorm(value + attention_output) intermediate = functional.gelu( layer.intermediate.dense(value), approximate="none", ) value = layer.output.LayerNorm( value + layer.output.dense(intermediate) ) pooled = torch.tanh(bert.pooler.dense(value[:, 0])) return self.model.classifier(pooled) def _checkpoint_config(model_dir: Path) -> dict[str, Any]: config_path = model_dir / "config.json" try: config = json.loads(config_path.read_text(encoding="utf-8")) except (OSError, UnicodeError, json.JSONDecodeError) as exc: raise DataError(f"{config_path}: cannot load model config: {exc}") from exc configured_labels = [ config.get("id2label", {}).get( str(index), config.get("id2label", {}).get(index), ) for index in range(len(LABELS)) ] if ( config.get("model_type") != "bert" or config.get("hidden_size") != 384 or config.get("num_hidden_layers") != 6 ): raise DataError("Core ML conversion requires the purpose-lite BERT architecture") if configured_labels != list(LABELS): raise DataError("Core ML checkpoint label order does not match purpose-lite") return config def _sha256(path: Path) -> str: digest = hashlib.sha256() with path.open("rb") as handle: for chunk in iter(lambda: handle.read(1024 * 1024), b""): digest.update(chunk) return digest.hexdigest() def _package_manifest(package: Path) -> dict[str, Any]: files = [] for path in sorted(item for item in package.rglob("*") if item.is_file()): files.append( { "path": str(path.relative_to(package)), "bytes": path.stat().st_size, "sha256": _sha256(path), } ) return { "schemaVersion": 1, "modelVersion": "purpose-lite-v1", "package": package.name, "bytes": sum(item["bytes"] for item in files), "files": files, } def _verify_wrapper_parity( torch_model: Any, wrapper: FixedShapeBertForCoreML, tokenizer: Any, records: Sequence[dict[str, Any]], ) -> float: encoded = encode_fixed_shape( tokenizer, [record["prompt"] for record in records], torch, ) maximum_error = 0.0 with torch.inference_mode(): for index in range(len(records)): item = {key: value[index : index + 1] for key, value in encoded.items()} reference = torch_model(**item).logits candidate = wrapper( item["input_ids"].to(torch.int32), item["attention_mask"].to(torch.int32), item.get("token_type_ids", torch.zeros_like(item["input_ids"])).to( torch.int32 ), ) error = float(torch.max(torch.abs(reference - candidate)).item()) maximum_error = max(maximum_error, error) torch.testing.assert_close(candidate, reference, rtol=1e-5, atol=2e-5) if int(candidate.argmax(dim=-1).item()) != int( reference.argmax(dim=-1).item() ): raise DataError("conversion wrapper changed a parity prediction") return maximum_error def convert(args: argparse.Namespace) -> dict[str, Any]: _checkpoint_config(args.model_dir) checkpoint = args.model_dir / "model.safetensors" if not checkpoint.is_file(): raise DataError(f"{checkpoint}: checkpoint is missing") if args.output.suffix != ".mlpackage": raise DataError("Core ML output must end in .mlpackage") if args.output.exists(): if not args.overwrite_output: raise DataError( f"{args.output}: output exists; pass --overwrite-output intentionally" ) if args.output.is_dir(): shutil.rmtree(args.output) else: args.output.unlink() args.output.parent.mkdir(parents=True, exist_ok=True) try: import coremltools as ct from transformers import AutoModelForSequenceClassification, AutoTokenizer except ImportError as exc: raise DataError( "Core ML conversion requires requirements-coreml.txt on macOS" ) from exc if ct.__version__ != COREMLTOOLS_VERSION: raise DataError( f"expected coremltools {COREMLTOOLS_VERSION}, found {ct.__version__}" ) model = AutoModelForSequenceClassification.from_pretrained( args.model_dir, local_files_only=True, ).eval() tokenizer = AutoTokenizer.from_pretrained(args.model_dir, local_files_only=True) wrapper = FixedShapeBertForCoreML(model).eval() validation = load_jsonl(args.validation) if len(validation) < args.parity_records: raise DataError("validation split is smaller than --parity-records") maximum_error = _verify_wrapper_parity( model, wrapper, tokenizer, validation[: args.parity_records], ) print(f"conversion-wrapper parity: max_abs_error={maximum_error:.3g}") example = ( torch.zeros((1, MAX_LENGTH), dtype=torch.int32), torch.ones((1, MAX_LENGTH), dtype=torch.int32), torch.zeros((1, MAX_LENGTH), dtype=torch.int32), ) with torch.inference_mode(): traced = torch.jit.trace(wrapper, example, strict=True) traced = torch.jit.freeze(traced) deployment_target = getattr(ct.target, args.minimum_deployment_target, None) if deployment_target is None: raise DataError( f"coremltools does not support {args.minimum_deployment_target}" ) coreml_model = ct.convert( traced, convert_to="mlprogram", minimum_deployment_target=deployment_target, compute_precision=ct.precision.FLOAT16, inputs=[ ct.TensorType( name="input_ids", shape=(1, MAX_LENGTH), dtype=np.int32, ), ct.TensorType( name="attention_mask", shape=(1, MAX_LENGTH), dtype=np.int32, ), ct.TensorType( name="token_type_ids", shape=(1, MAX_LENGTH), dtype=np.int32, ), ], outputs=[ct.TensorType(name="logits", dtype=np.float32)], ) coreml_model.author = "Nucleic" coreml_model.short_description = "purpose-lite-v1 prompt classifier" coreml_model.version = "purpose-lite-v1" coreml_model.user_defined_metadata["com.nucleic.model.version"] = ( "purpose-lite-v1" ) coreml_model.user_defined_metadata["com.nucleic.model.labels"] = json.dumps( list(LABELS), separators=(",", ":"), ) coreml_model.user_defined_metadata["com.nucleic.model.sourceSha256"] = _sha256( checkpoint ) coreml_model.user_defined_metadata["com.nucleic.model.fixedShape"] = "1x128" coreml_model.user_defined_metadata["com.nucleic.model.minimumDeploymentTarget"] = ( args.minimum_deployment_target ) coreml_model.save(str(args.output)) manifest = _package_manifest(args.output) manifest.update( { "sourceCheckpoint": str(checkpoint), "sourceCheckpointSha256": _sha256(checkpoint), "coremltoolsVersion": ct.__version__, "minimumDeploymentTarget": args.minimum_deployment_target, "computePrecision": "float16", "wrapperMaximumAbsoluteError": maximum_error, } ) manifest_path = args.output.with_name(f"{args.output.stem}-manifest.json") write_json(manifest_path, manifest) print(f"Core ML package: {args.output} ({manifest['bytes']} bytes)") return manifest def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--model-dir", type=Path, default=DEFAULT_MODEL_DIR) parser.add_argument("--validation", type=Path, default=DEFAULT_VALIDATION) parser.add_argument("--output", type=Path, default=DEFAULT_OUTPUT) parser.add_argument("--parity-records", type=int, default=16) parser.add_argument( "--minimum-deployment-target", default="macOS15", choices=("macOS15", "macOS26"), ) parser.add_argument("--overwrite-output", action="store_true") return parser def main(argv: Sequence[str] | None = None) -> int: parser = build_parser() args = parser.parse_args(argv) if args.parity_records <= 0: parser.error("--parity-records must be positive") try: convert(args) except (AssertionError, DataError, OSError, RuntimeError, ValueError) as exc: print(f"error: {exc}", file=sys.stderr) return 1 return 0 if __name__ == "__main__": raise SystemExit(main())