# PURPOSE_CLASSIFIER — a purpose-built prompt classifier for Intelligence routing

> **Status:** runtime integrated, artifact qualification in progress, 2026-07-31. Dataset,
> training/export, and the macOS `purpose-lite` Core ML path are implemented; see the
> handoff ledger in
> §11. Follow-on to the Intelligence slider (the purpose-routed
> model/effort feature in `Sources/NucleicCore/IntelligenceRouting.swift` +
> `PromptPurpose.swift`). This document specifies replacing the *generic* on-device LLM
> layer of the purpose classifier with a **purpose-built classifier** — smaller, faster,
> deterministic, and more accurate — and doing it **cross-platform**, with first-class
> Windows parity via Windows ML. Written to be self-contained: an agent picking up any work
> item in §10 should be able to execute it from this document plus the referenced files.

---

## 0. Summary and goals

The Intelligence slider routes each prompt by (purpose × level) → (model, effort). Purpose
comes from a layered classifier; today the model layer is Apple Foundation Models (AFM)
guided generation — a ~3B *generalist* chat model pressed into an 8-way labeling job. That
is simultaneously overkill (hundreds of ms, a whole LLM) and underfit (no task-specific
training; prompt-engineering ceiling). An 8-way intent classifier over short coding prompts
is a classic small-model problem.

The existing `IntelligenceRouter.matrix` is also the model ranking: each purpose × level
cell orders its preferred provider lanes. Runtime quota is a hard availability constraint,
not another classification signal. A reached Claude/Codex provider window removes that lane;
a reached Claude model-family window removes that family only. The router then keeps the
current cell ranking, walks lower same-purpose levels before higher ones for the nearest
budget-safe match, and temporarily bypasses a provider pin only when that pin is quota-blocked.
An existing chat keeps its fixed backend, so it may substitute a same-backend model but blocks
send when the entire backend is limited. All exclusions disappear automatically at reset.

**Goals**

1. **Accuracy:** ≥ 95% on the held-out eval set (vs. the ~90% acceptance gate the layered
   AFM path was specced to). Misroutes must still never cross more than one cost tier —
   the routing matrix's tier-smoothness invariant (`IntelligenceRoutingTests`) stays the
   damage bound *independent of any classifier*.
2. **Latency:** p95 ≤ 20 ms per classification (vs. seconds for AFM) — cheap enough to run
   on every debounce tick, not just the settled draft.
3. **Determinism & auditability:** same input → same verdict; a misclassification is
   traceable to exemplars/training data, not to a generation's mood.
4. **Cross-platform:** one canonical model artifact that serves macOS *and* the Windows
   port (WINDOWS_PORT.md), where AFM does not exist. Today Windows parity for
   classification is heuristics + optional agent-CLI escalation only.
5. **Energy efficiency first, speed second:** classification runs on every debounce tick,
   so per-inference *energy* matters more than shaving milliseconds. The accelerator
   ladder, in order: **ANE/NPU whenever present** (at least as power-efficient as CPU for
   fixed-shape fp16/int8 encoders — that is what those blocks exist for); **discrete/
   integrated GPU (NVIDIA, AMD, Intel) where there is no NPU** — the classic-desktop
   case, where an RTX/Radeon delivers far more TOPS than the CPU and wall power blunts
   the energy argument; **CPU as the compatibility floor, never the preference**. On
   battery the GPU rung is skipped (NPU-or-CPU only) — a dGPU wake for a background
   classifier is exactly the energy bill this policy exists to avoid. Both shipped models
   are *designed for accelerator eligibility from day one* (fixed shapes, fp16-friendly
   ops — §4.2), and scheduling is verified, not assumed (§8).
6. **A model ladder, one contract:** a **light** classifier every machine can run, a
   **deep** classifier (§4.3) that exploits ANE/NPU headroom for higher accuracy on the
   ambiguous prompts the light model gets wrong, and a **max** classifier (§4.4) that
   spends big-GPU headroom (RTX/Radeon-class, AC power only) on the best result the
   hardware can deliver. Same labels, same calibration semantics, same wire — machines
   differ only in which artifact answers.
7. **No architectural churn:** the classifier slots into the existing layer stack behind
   `PurposeVerdict.Source`; routing, session plumbing, UI, and wire stay as shipped.

**Non-goals:** classifying anything beyond `PromptPurpose.classifiable` (8 labels);
server-side classification (privacy posture stays local-only); replacing the deterministic
heuristics (they remain the fast path and the last-resort fallback everywhere).

---

## 1. Current state

Layering (see `Sources/NucleicApp/PromptPurposeService.swift`, mirrored in
`DelegatedIntelligenceProvider.classifyPromptPurpose`):

| Layer | What | Where it runs today |
|---|---|---|
| L0 | `HeuristicPurposeClassifier` (keyword buckets + structure) — HIGH-confidence verdicts are final | Everywhere (pure Swift, NucleicCore — compiles on macOS/Linux/Windows) |
| L1 | Bundled `purpose-lite-v1-w8a8` (`PurposeMLClassifier`, calibrated Core ML) | macOS, CPU + Neural Engine |
| L2 | AFM guided generation (`PurposeVerdictGen`, fixed label set) | macOS fallback (`AppleIntelligence.swift`) |
| L3 | One-shot `claude -p --model haiku` escalation (`AgentCLIIntelligenceBackend`) | Anywhere with a logged-in agent CLI |
| L4 | `PurposeVerdict.general` → the deliberately mid-tier routing row | Everywhere |

The L1 model loads lazily on its own actor, remains warm for the app run, and fails closed
to L2. `nucleic.purposeClassifierEnabled` is the default-on kill switch;
`nucleic.purposeClassifierModel` accepts `auto`, `lite`, `deep`, or `off` (`deep` currently
skips L1 because no deep artifact is bundled). HIGH/MEDIUM local verdicts are accepted;
LOW must agree with L0. Session routing notes retain the `.custom` source and artifact
version for diagnosis.

Shared assets already in place that this plan builds on: the 92-prompt labeled fixture set
(`Tests/NucleicCoreTests/Fixtures/purpose-prompts.json`), the eval harness
(`PromptPurposeClassifierTests`, incl. the `NUCLEIC_PURPOSE_EVAL=1` end-to-end gate), the
tier-drift invariant, the additive wire kind `classifyPromptPurpose`, and the
`PurposeVerdict.Source` provenance enum.

---

## 2. Why purpose-built beats generic AFM here

- A fine-tuned/trained small encoder sees *thousands* of in-domain examples; AFM sees an
  instruction and 8 label definitions. On short, jargon-heavy coding prompts that gap is
  typically 5–15 accuracy points.
- Guided generation guarantees an on-menu label but not a *calibrated* one; a trained
  classifier gives usable softmax margins, which the layering contract (accept / corroborate
  / escalate) is built around.
- AFM availability is environmental (model downloaded? Intelligence setting on? macOS
  only). A shipped classifier is always there — including on Windows.
- LoRA-adapting AFM itself was considered and **rejected**: Apple's adapter toolkit pins
  adapters to the base-model version, and Apple retrains the base across OS updates — a
  perpetual retrain-and-reship treadmill that buys nothing over a task-owned model for a
  fixed 8-label task.

---

## 3. Phase 1 — embedding + centroid classifier (Apple platforms, days of work)

A stopgap that likely already beats generic AFM, and produces the confidence machinery
Phase 2 reuses.

- **Mechanism:** embed the normalized prompt head (≤ 600 chars) with Apple's
  `NLContextualEmbedding` (on-device, macOS 14+; asset download via `requestAssets` with a
  graceful "not ready → skip layer" path). Classify by cosine similarity against
  per-purpose **centroids** computed from a curated exemplar set; confidence = top-two
  margin mapped onto `PurposeVerdict.Confidence` (thresholds picked on the fixture set).
- **Exemplars:** `Sources/NucleicApp/Resources/purpose-exemplars.json` (~30–60 per
  purpose), seeded from the fixture set plus hand-written variants. Reviewable in PR like
  any code; a misclassification is fixed by editing exemplars and re-running the eval.
- **Placement:** new L1 ahead of AFM. `PurposeVerdict.Source` gains `.custom`. AFM demotes
  to "only when the centroid margin is low"; everything else in the stack is untouched.
- **Not portable:** `NLContextualEmbedding` is Apple-only. Windows keeps its current
  ladder until Phase 2 — this phase must not be the parity story, only the quick win.

Deliverables: `PurposeCentroidClassifier` (NucleicApp), exemplar resource (first
`resources:` block for the NucleicApp target in Package.swift), thresholds derived from and
locked by `PromptPurposeClassifierTests` extensions.

---

## 4. Phase 2 — trained cross-platform classifier (the real thing)

### 4.1 Dataset

- **Synthesis:** generate ~5,000–8,000 labeled prompts with a frontier model — per purpose,
  varied register (terse/verbose), length (2 words → pasted stack traces), domain (web,
  systems, mobile, data), language quirks, and deliberate near-boundary cases
  (backend-vs-writing "update the API docs…", refactor-vs-quickFix "rename X everywhere").
  Store under `ml/purpose-classifier/data/` with a generation manifest (prompts used,
  model, date) so the set is reproducible and auditable.
- **Curation:** dedupe (near-duplicate embedding filter), spot-check ~10% by hand, keep the
  92 shipped fixtures **out of training** — they join the held-out eval set.
- **Splits:** ~80/10/10 train/val/test, stratified per purpose; test set frozen at ~500+
  prompts and versioned. The CI accuracy gate runs against the frozen test set.
- **Local feedback corpus (opt-in, local-only):** when a user overrides the resolved chip's
  routed pick, append (normalized-prompt-hash, predicted purpose, chosen model) to a local
  JSONL under Application Support. Never leaves the machine; harvested manually during
  development as hard-case candidates for the next training round.

### 4.2 The light classifier (`purpose-lite`)

- **Architecture:** MiniLM-class sentence encoder (6-layer, 384-dim) + linear
  classification head, fine-tuned end-to-end on the 8 labels. Artifact budget: **≤ 25 MB**
  on disk, inference ≤ 10 ms for a 128-token input on any backend. Runs *everywhere* — it
  is the parity floor and the fallback when the deep model isn't present.
- **Accelerator-eligible by construction** (this is what makes the ANE/NPU-first energy
  policy real rather than aspirational):
  - **fixed input shape** (batch 1 × 128 tokens, padded/truncated) — dynamic shapes are
    the #1 cause of silent ANE/NPU fallback to CPU;
  - **fp16 weights + activations** for the Apple/Core ML artifact (the ANE is an fp16
    machine; int8 is used where the target EP prefers it — QNN builds take the int8 QDQ
    variant); export.py emits both quantization variants from one checkpoint;
  - op set restricted to accelerator-friendly kernels (standard attention/GELU/LayerNorm —
    no custom ops, no control flow in the graph).
- **Pipeline:** Python under `ml/purpose-classifier/` (train.py, export.py, eval.py),
  pinned requirements, one-command retrain. Outputs the canonical artifacts + a metrics
  report the PR must include.
- **Calibration:** temperature-scale on the validation split; map calibrated top-1
  probability and top-2 margin onto HIGH/MEDIUM/LOW using the same acceptance semantics the
  layers already implement (HIGH/MEDIUM accepted; LOW must agree with L0 to win).
- **Fallback option (if the ONNX toolchain fights us on macOS):** Create ML
  `MLTextClassifier` on the same dataset for the Apple side only, keeping ONNX for Windows.
  Two artifacts from one dataset — acceptable but second-choice; see parity gate below.

### 4.3 The deep classifier (`purpose-deep`) — the model the ANE/NPU is *for*

A ~22M-parameter encoder saturates quickly on easy prompts but plateaus on the genuinely
hard ones: mixed intents ("plan and implement…", "update the API docs for the new rate
limits"), vague drafts, long pasted context where the ask is buried. A second, heavier
model targets exactly that residual — and is sized so that running it on CPU would be an
obvious waste, while on ANE/NPU it is still a millijoule-class, sub-100 ms call.

**Sizing (2026 hardware reality).** The accelerators this model gates on have enormous
headroom relative to any encoder: the Copilot+ floor is 40 TOPS, Intel Panther Lake ~50,
AMD XDNA2 ~60, and Snapdragon X2 Elite's Hexagon 80–85 TOPS — the class of hardware that
runs quantized **7B** LLMs at usable rates ([NPU comparison](https://localaimaster.com/blog/npu-comparison-2026),
[X2 Elite coverage](https://markets.financialcontent.com/stocks/article/tokenring-2026-2-5-the-85-tops-revolution-qualcomms-snapdragon-x2-elite-redefines-the-ai-pc-era-at-ces-2026),
[NPU guide](https://www.solidaitech.com/2026/05/npu-neural-processing-unit-complete-guide.html));
the ANE side routinely serves Apple's ~3B foundation model. So the deep model is sized by
*task saturation*, not by hardware: past roughly half a billion parameters, **encoder**
accuracy on a fixed 8-way task flattens, and the next meaningful step up is a 1–3B
generative decoder — rejected *as the default path*, because it reintroduces exactly what
this plan removes (uncalibrated generation, hundreds of ms, GB-scale memory residency)
for negligible classification gain on ordinary machines. (§4.4 revisits the decoder under
strict constraints as the optional GPU-max tier.) Target ship size for deep: **large-class
encoder, ~400M params**, decided by ablation (below), with the older base size kept as a
pipeline ablation rung rather than a shipping artifact.

- **Architecture:** ModernBERT-class encoder, **large size (~400M params; ~150M base
  variant trained as an ablation rung)**, **512-token window** (the hard cases are long —
  pasted stack traces and context-buried asks are where the extra capacity pays),
  multi-task heads trained jointly on the same dataset (plus the hard-case slices §4.1
  generates):
  1. **purpose** — the same 8-way softmax (same calibration contract as `purpose-lite`);
  2. **secondary purpose / mixed-intent** — a second softmax + "is mixed" flag, so a
     genuinely two-purpose prompt is *known* to be mixed instead of low-confidence; the
     router's response is defined and conservative: route the primary, but never below the
     secondary's cost tier at the same level (stays inside the tier-drift bound);
  3. **difficulty** — a calibrated 0–1 regression head (trained from frontier-model-graded
     labels in the synthesis pass). *Advisory only* in v1: surfaced in the chip tooltip
     ("reads like a Deep-level task") and recorded in the routing note. The slider remains
     the user's budget dial; auto-level is a separate future decision, not smuggled in.
- **Future document/plan parsing:** the more complex classifier should resolve referenced
  plans or documents and classify the instruction together with their contents. For example,
  given `Read (document) and begin work`, it should parse `(document)` and combine what the
  document describes with `begin work` to determine whether the task is backend, frontend,
  or another purpose; the generic action phrase alone does not contain enough signal.
- **Artifact budget:** ≤ 850 MB fp16 / ≤ 450 MB int8 (the QNN/NPU builds take the int8-QDQ
  variant; Core ML takes fp16 for the ANE). Far too big to bundle: shipped as an on-demand
  downloadable asset (Settings › Intelligence "Download the deep classifier", plus a
  first-run offer on eligible hardware), version-pinned alongside the app like the
  macOS-VM base images are. `purpose-lite` stays bundled so classification never depends
  on a download.
- **Ablation ladder, decided by gates not vibes:** the pipeline trains lite (22M),
  deep-base (~150M), and deep-large (~400M) from the same dataset; §8's gates pick what
  ships. Deep-large must beat deep-base by ≥ 2 points on the hard slice to justify its
  size — if it doesn't, the dataset (not the parameter count) is the bottleneck, base
  ships, and the next investment goes into data.
- **Same export discipline as 4.2:** fixed 1×512-token shape, fp16 (+ int8-QDQ variant),
  both ONNX and Core ML artifacts, same tokenizer spec, same threshold JSON schema.
- **Model selection at runtime** (per machine, decided once per launch and re-checked on
  asset/hardware/power change):
  - max model present **and** its §4.4 eligibility holds (verified GPU rung + memory
    floor + AC power) → **max is the L1 classifier**;
  - else deep model present **and** verified accelerator-resident (§5/§6 checks) →
    **deep is L1**; lite stays loaded as the instant-answer for the first keystrokes
    if the bigger asset is still warming;
  - otherwise → lite is L1 (which is already ≥ the current AFM path).
  Power transitions re-run this ladder live: unplugging mid-session steps max → deep
  seamlessly (next debounce tick answers on the smaller model).
  A cascade variant (lite always answers; deep re-scores only low-margin drafts) is the
  fallback posture if deep-everywhere measures worse on energy than expected — the
  layering makes either wiring a one-line change in `PromptPurposeService`.

### 4.4 The GPU-max classifier (`purpose-max`) — spending real GPU headroom

§4.3's sizing argument stands: past ~0.5B parameters, a *bigger encoder* buys latency,
not accuracy. So the tier above deep-large is not a larger encoder — it is a **different
kind of model** that only RTX/Radeon-class (or big-unified-memory Apple GPU) headroom
makes viable: a small **instruction-tuned decoder used as a constrained classifier**.
This deliberately *revisits* §2's decoder rejection, which was a rejection for the
*default* path (uncalibrated free generation, hundreds of ms, GB-scale residency on
every machine). Under the max tier's constraints, each objection is answered rather than
waved off:

- **Architecture:** a 1.5–4B instruction-tuned decoder (Qwen/Phi-class), LoRA-fine-tuned
  on the same dataset and merged. Classification is **not free generation**: one prefill
  over the 512-token head + a single constrained step with the logits masked to the 8
  label tokens — the masked-label distribution is the output, temperature-scaled on the
  validation split exactly like the encoders (this restores the calibration story the §2
  rejection was about). Secondary-purpose and difficulty ride the same masked readout.
- **What it buys beyond accuracy:** a short *generated* rationale (one clause, capped
  tokens) for the chip tooltip — a per-prompt "why", where the encoders can only attach
  canned per-cell copy. Generation of the rationale is optional, async after the label,
  and never on any routing path.
- **Eligibility (all required):** a verified GPU rung per §5/§6 (NvTensorRT-RTX /
  MIGraphX / OpenVINO-GPU on Windows, Apple GPU on macOS) **and** a memory floor
  (≥ 6 GB dedicated VRAM, or ≥ 24 GB unified memory on Apple silicon) **and AC power**.
  On battery the ladder drops to deep on the ANE/NPU automatically — max is a
  wall-power luxury by definition, and the power-transition listener swaps tiers live
  (mid-draft transitions just change which model answers the next debounce tick).
- **Artifacts:** int4/int8 quantized, ~1–2.5 GB — a separately downloaded optional asset
  ("best on this machine" offer in Settings › Intelligence, never auto-downloaded).
  Export uses the generative stacks (ONNX Runtime GenAI format for Windows ML's runtime;
  a stateful Core ML model on macOS) — meaningfully more export complexity than the
  encoders, which is why max is its own late work item and not on deep's critical path.
- **Budgets & gates:** label p95 ≤ 250 ms on the eligibility-floor GPU (still well inside
  the debounce window; rationale generation excluded from the budget); ships only on a
  **≥ +2-point hard-slice win over deep-large** — if constrained decoding can't beat the
  large encoder where it matters, max doesn't exist and the doc's saturation argument was
  simply right; idle-unload is mandatory (a multi-GB resident model must never outlive
  composer attention, and must never keep a dGPU awake).

### 4.5 Artifacts, conversion, versioning

- **Canonical artifact: ONNX** (opset ≥ 17), one file, one tokenizer spec (exported
  vocab + normalization rules — no runtime HuggingFace dependency).
- **macOS runtime:** convert ONNX → Core ML (`coremltools`) at build time in the pipeline;
  ship the `.mlmodelc` as a NucleicApp resource; run via Core ML on CPU/ANE.
  **Numeric-parity gate:** eval.py must score the Core ML artifact and the ONNX artifact on
  the frozen test set; label agreement ≥ 99.5% and identical accuracy-to-the-gate, or the
  conversion is rejected (this is what makes "one model, two runtimes" honest).
- **Windows runtime:** the ONNX artifact as-is, via Windows ML (§6).
- **Naming:** `purpose-lite-v<N>` / `purpose-deep-v<N>` / `purpose-max-v<N>` with `.onnx`
  (canonical, per-EP quantization variants; GenAI-format for max) and `.mlmodelc`
  (converted) suffixes.
- **Versioning:** artifacts named `purpose-<lite|deep|max>-v<N>.<…>`; the version
  string rides `PurposeVerdict.reason` provenance and the session routing note, so field
  behavior is attributable to a model version. Old artifact kept for one release for a fast
  revert (`nucleic.purposeClassifierVersion` defaults key as the pin/kill switch).

---

## 5. macOS runtime integration

- New `PurposeMLClassifier` (NucleicApp) conforming to a small
  `PurposeModelClassifying` seam: `classify(prompt: String) async -> PurposeVerdict?`
  (nil = model unavailable/not loaded — layering skips it, exactly like AFM's nil).
- `PromptPurposeService` layer order becomes: L0 heuristics → **L1 PurposeMLClassifier**
  (Phase 1: centroid; Phase 2: Core ML) → L2 AFM (only while it still beats or backstops
  L1 — removed once §8's gates pass two releases in a row) → L3 agent CLI → L4 general.
- Loading: lazily on first classification, off the main thread, kept warm per app run.
  Classification stays purely a debounce-time affair; the send path's 400 ms grace and
  synchronous-heuristic fallback are unchanged.
- **ANE-first compute policy** (goal #5): sessions are created with
  `MLModelConfiguration.computeUnits = .cpuAndNeuralEngine` — deliberately *not* `.all`
  as the first choice, which would invite GPU scheduling where the ANE is both present
  and more efficient. At load, verify placement with `MLComputePlan` (macOS 14+): count
  ops scheduled on the ANE and record the ratio against the artifact's expected floor (a
  per-version constant emitted by the conversion step).
- **GPU rung (Apple):** if the ANE floor is unmet (older ANEs vs. deep-large, or a future
  op regression) **and** the Mac is on AC power (`PowerSource.onACPower` — the same
  power-source seam `IntelligenceChoice.energyAware` uses), retry with
  `computeUnits = .all` and re-verify via `MLComputePlan` that the GPU actually took the
  graph; Apple-silicon GPUs (and the M5-generation GPU neural accelerators) run these
  encoders easily. On battery, or if the GPU pass also falls to CPU, the deep model
  yields to `purpose-lite` rather than burn CPU on a 400M encoder. Scheduling is
  *verified per machine at every rung*, never assumed (Core ML silently re-schedules
  ineligible ops; that silence is the risk the checks close).
- The lite model stays loaded even when deep is active (a few MB) so the first
  keystrokes of a session and any deep-model hiccup degrade to lite, not to nothing.
- Kill switch: `nucleic.purposeClassifierEnabled` (default true once shipped) — distinct
  from the slider toggle, so support can disable the model layer without losing routing.
  `nucleic.purposeClassifierModel` (`auto` | `lite` | `deep` | `off`) for support/debug
  pinning; `auto` is the §4.3 selection ladder.

---

## 6. Windows parity

### 6.1 Research findings (July 2026) — what "the right way" currently is

The suggested "Windows ML powered by the ONNX Runtime with DirectML" is *half* right, and
the half that changed matters:

- **Windows ML (the new one) is the correct runtime.** It reached **GA** and ships in the
  **Windows App SDK (1.8.1+)**, targeting Windows 11 **24H2 (build 26100) or later**. It
  embeds a shared, OS-managed **ONNX Runtime** — apps stop bundling their own ORT — with
  APIs in `Microsoft.Windows.AI.MachineLearning` (and the `Microsoft.ML.OnnxRuntime`
  surface on .NET 8+ / C++20). ([Get started](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/get-started),
  [overview](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/overview),
  [GA report](https://windowsforum.com/threads/windows-ml-ga-production-ready-on-device-ai-runtime-for-windows-11.381965/))
- **DirectML is legacy.** Microsoft lists DirectML as a *legacy* execution provider in
  sustained engineering; new development moved to Windows ML's **execution-provider
  catalog**, which downloads and manages vendor EPs dynamically (QNN for Qualcomm NPUs,
  OpenVINO for Intel, NvTensorRT-RTX for NVIDIA, VitisAI, MIGraphX) per device.
  ([Supported execution providers](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/supported-execution-providers),
  [EP install/initialize](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/initialize-execution-providers),
  [DirectML status issue](https://github.com/microsoft/onnxruntime/issues/23783))
- **Device policies are the energy knob.** The Windows ML-shipped ONNX Runtime exposes
  outcome-oriented EP selection via `SessionOptions.SetEpSelectionPolicy` with
  `OrtExecutionProviderDevicePolicy` values — `MAX_EFFICIENCY`, `PREFER_NPU`,
  `MAX_PERFORMANCE`, and friends; `MAX_EFFICIENCY`/`PREFER_NPU` pick an NPU when present
  and fall back to CPU cleanly. ([EP selection](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/select-execution-providers))
- **GPUs are first-class in the same catalog.** The EP catalog is not NPU-only: it carries
  **NvTensorRT-RTX** (NVIDIA RTX discrete GPUs), **MIGraphX** (AMD Radeon), and
  **OpenVINO** (Intel iGPU/dGPU alongside its NPU leg). On the enormous installed base of
  desktops with a powerful GPU and *no* NPU — most non-Copilot+ gaming/workstation
  hardware — the GPU **is** the accelerator, at hundreds-to-thousands of effective TOPS.
  ([Supported execution providers](https://learn.microsoft.com/en-us/windows/ai/new-windows-ml/supported-execution-providers))
- **Plan accordingly:** the CPU EP remains the required *compatibility floor* (a ≤ 25 MB
  classifier needs single-digit milliseconds there), and the configured posture follows
  goal #5's ladder, power-source-aware:
  - **on battery:** `MAX_EFFICIENCY` (NPU when present, else CPU) — never wake a dGPU
    from a background classifier;
  - **on AC:** NPU when present (`PREFER_NPU`), else the validated GPU EP for the device
    (NvTensorRT-RTX / MIGraphX / OpenVINO-GPU, chosen explicitly per Microsoft's
    "explicit first, policies once working" guidance), else CPU.
  The **deep model (§4.3) activates when an NPU *or verified GPU* EP took the graph** —
  the mirror of the macOS residency check, read from the session's assigned EP/device
  after compile. Windows ML's AOT **model compilation** step runs at first use per EP so
  steady-state launches don't repay graph/engine compilation (TensorRT-RTX engine builds
  are exactly the case this exists for). Do not take a DirectML dependency; do not bundle
  a private ONNX Runtime (the App SDK's shared runtime is the point). Phi Silica /
  Foundry Local (Windows' local LLMs) were considered and rejected for this task for the
  same reason AFM is being demoted: generalist LLMs are the wrong tool for a fixed
  8-label classifier (Phi Silica remains the planned Windows analogue for the
  *generative* soft-AI surfaces per WINDOWS_PORT.md §UI table).

### 6.2 Where it runs in the Windows architecture

WINDOWS_PORT.md D1 splits the product into `nucleic-hostd.exe` (Swift, authority) and
`Nucleic.exe` (WinUI 3 C#, a *renderer* over the sync protocol, like the iPhone). That
split decides everything:

- **Classification runs in the C# renderer** (`Nucleic.exe`), where the composer and the
  keystrokes live — same reasoning that put Phi Silica calls on the C# side of the port's
  responsibility table. Implementation: `PurposeClassifier.cs` over Windows ML, loading the
  same canonical `purpose-classifier-v<N>.onnx` + tokenizer spec, applying the same
  calibrated thresholds (shipped alongside the artifact as JSON so C# and Swift can't
  drift on acceptance rules).
- **L0 heuristics on Windows:** `HeuristicPurposeClassifier` is pure NucleicCore Swift and
  already compiles for Windows — but hostd, not the renderer, runs Swift. Rather than
  reimplement keyword buckets in C#, the renderer treats the model as its only local
  layer and defers L0 to the routing request (below). The HIGH-fast-path saving matters
  on macOS (skip an LLM); on Windows the model itself is ~10 ms, so a fast path buys
  nothing.
- **Routing stays in hostd (single source of truth).** Add an additive request/response
  pair to the sync protocol: the renderer sends draft-purpose + level
  (`WireRouteIntelligence { purposeRaw, level }` — a **new-chat-composer verb**: routing
  is a creation-time decision, see the resolved note in §9), hostd answers with the
  `IntelligenceRouter` resolution (model, effort, reason) — hostd applies connected
  providers, the pin, live quota exclusions, degradation, codexPro, and clamps, exactly as the macOS composers do
  in-process. The chip renders the answer; send passes the resolved strings through the
  existing `startChat` verb unchanged. (Alternative considered: project the whole matrix
  wire-side like `WireModelCatalog` and route in the renderer — rejected for now; the
  matrix changes more often than the wire should, and hostd knows liveness/pins locally.)
- **Escalation on Windows:** hostd can already run L2 (`AgentCLIIntelligenceBackend`,
  `claude -p` works on Windows once the CLI is present) — the routing request's answer may
  carry hostd's own verdict when the renderer sends "purpose unknown/low", preserving the
  full ladder without the renderer knowing about CLIs.
- **Degradation ladder on Windows** (each step is a strict subset, never a failure):
  Windows ML unavailable (App SDK < 1.8.1, OS < 24H2, artifact missing) → renderer sends
  "purpose unknown" → hostd answers with L0 heuristics (+L2 if available) → worst case the
  `general` row. Windows never blocks a send on classification, same contract as macOS.

### 6.3 Windows work items land with, not after, the port

The Windows composer (WinUI) does not exist yet (WINDOWS_PORT §7 is in execution). The
parity requirement on this plan is that the **artifacts and wire verbs be
renderer-agnostic** from day one — the same routing request also gives the iOS composer a
path to the slider later, for free.

---

## 7. Wire & delegation changes (all additive)

- `WireRouteIntelligence` request/response on the renderer sync protocol (§6.2) —
  additive message kinds, tolerated-by-ignoring on old peers per SYNC's forward-compat
  rules; `WireMessageTests` round-trip coverage.
- No change to `IntelligenceRequestKind.classifyPromptPurpose` (the host→device delegation
  path); the prompt-twin rule in `IntelligenceDelegation.swift` continues to apply to the
  L2/L3 templates only.
- `PurposeVerdict.Source` += `.custom`; session routing note gains the classifier version
  string (§4.3). Both are additive Codable.

---

## 8. Evaluation and rollout gates

1. **Frozen test set** (~500+, incl. the 92 shipped fixtures): on prompts with a
   content-recoverable label, `purpose-lite` accuracy ≥ 95%; per-purpose recall ≥ 85%
   (no label allowed to rot silently while the average looks fine). `vague-eval` is an
   OOD **abstention** slice, not label-scored: those prompts deliberately omit the context
   that would determine an 8-way truth, never enter training, and must resolve LOW at least
   90% of the time. Counting accidental agreement with their synthetic labels as accuracy
   was removed after the first real baseline made the category error measurable (§11).
   **`purpose-deep`: ≥ 97% overall, and ≥ +5 points over lite on the label-scored frozen
   *hard slice*** (the mixed-intent/ambiguous split §4.1 curates; `vague-eval` keeps its
   separate abstention gate) — if deep can't beat lite where it matters, it doesn't ship,
   however good its average. Within the deep
   ladder, large ships over base only on a ≥ 2-point hard-slice win (§4.3's ablation
   rule); otherwise base ships and the delta funds dataset work instead.
2. **Tier-drift invariant** re-run over *model* predictions (not just heuristics), for
   both models: every test-set misroute within one cost tier at all levels/lanes — and for
   deep's mixed-intent path, the primary+secondary blend rule proven to stay in-bound.
3. **Parity gates:** Core ML↔ONNX label agreement ≥ 99.5% per model (§4.5); C#↔Swift
   threshold config is one shared JSON so acceptance semantics can't fork. For max, the
   masked-label readout must additionally pass a calibration bound (ECE ≤ lite's on the
   validation split) — a decoder that classifies better but *confides* worse would poison
   the accept/corroborate/escalate contract.
4. **Latency:** lite p95 ≤ 20 ms; deep p95 ≤ 100 ms *on the accelerator it's gated to*;
   max label-readout p95 ≤ 250 ms on the eligibility-floor GPU (rationale generation
   excluded — async, off every routing path). All still several debounce ticks under the
   700 ms window, and never on the send path. Measured in eval.py and asserted loosely in
   a perf smoke test.
5. **Accelerator-residency gates** (goal #5 made falsifiable):
   - macOS: `MLComputePlan` ANE-op share ≥ the per-artifact floor emitted at conversion,
     checked at load on-device (rung fallbacks per §5 — the GPU retry must itself pass a
     GPU-share check before deep stays active);
   - Windows: session EP/device recorded after compile; deep requires an NPU **or
     validated GPU** EP (NvTensorRT-RTX / MIGraphX / OpenVINO-GPU), never bare CPU;
   - the battery/AC policy split verified by a unit test over the policy-selection
     function on both platforms (pure logic; the power-source seam is injectable);
   - **energy spot-measurement** (one-time per model version, documented in the metrics
     report): `powermetrics` sampling of a 1,000-classification loop, ANE vs. CPU-pinned
     on the same Mac — the ANE run must draw less average power *and* less energy per
     inference, or the compute-unit policy for that artifact is reconsidered. Equivalent
     spot-check on one NPU-equipped Windows box (HWiNFO/vendor tooling) when §6's item
     lands. Not a CI gate (hardware-bound), but a required PR artifact.
6. **Rollout:** ship dark behind `nucleic.purposeClassifierEnabled=false` for one canary
   cycle with A/B logging *locally* (both verdicts recorded in the routing note's reason);
   flip default on when disagreement analysis shows the model ≥ AFM on real drafts; remove
   the AFM layer (L2 demotion, §5) after two clean releases. Deep rolls out behind the
   same telemetry one cycle after lite; max one cycle after deep, and only if its §4.4
   hard-slice-over-deep-large gate held on the frozen set (a tier that can't beat the one
   below it is deleted, not shipped).

---

## 9. Risks / open questions

- **Windows ML floor:** 24H2+ and App SDK 1.8.1+ — older Windows 11 installs silently get
  the hostd-side ladder only. Acceptable (parity of *behavior contract*, not of layer
  count); revisit if the port's minimum OS lands lower than 24H2.
- **Tokenizer drift:** shipping our own exported tokenizer spec avoids runtime deps but
  means the exporter must be tested against golden tokenizations (add to eval.py).
- **Dataset bias:** synthetic prompts skew toward the generator's phrasing. Mitigations:
  multiple generator models, the local override corpus (§4.1), per-purpose recall gates.
- **NucleicApp resources:** first `resources:` block for the app target (Phase 1) — verify
  `scripts/package-app.sh` carries bundle resources into the packaged .app.
- **Silent accelerator fallback:** Core ML and Windows ML both reserve the right to
  re-schedule ineligible ops to CPU without erroring — the residency gates (§8.5) exist
  precisely because "it runs" and "it runs on the ANE" are different facts. Biggest
  practical trigger: an op or shape introduced by a future retrain; the per-artifact
  residency floor makes that regress loudly at conversion time.
- **Deep-model distribution:** up to ~850 MB fp16 is decisively an on-demand asset, not
  app payload — needs the download/verify/pin plumbing (reuse the macOS-VM base-image
  asset pattern), a metered-connection-aware download UI, and an offline story (lite
  covers it). Memory residency (~1 GB warm for deep-large fp16) also argues for unloading
  the deep model after composer idle — add an idle-unload timer to `PurposeMLClassifier`.
- **ANE/NPU generation spread:** M1-era ANEs and 40-TOPS-floor NPUs are far slower than
  the 2026 flagships the sizing research describes; the deep model's 100 ms budget is set
  against the *oldest supported* silicon, not the Snapdragon X2 class, and the §4.3
  cascade posture (plus shipping deep-base instead of deep-large per the ablation rule) is
  the pre-planned retreat if that budget fails there.
- **dGPU sharing:** on gaming/workstation desktops the GPU rung shares VRAM and schedulers
  with whatever else is running; a ~400M encoder is a rounding error, but the classifier
  must never *keep* a dGPU awake — sessions on the GPU rung get the same idle-unload
  timer as deep's memory residency, and first-use TensorRT engine compilation happens off
  any user-visible path.
- **Resolved (2026-07-30): routing is chat-creation-only, on every platform.** An
  earlier draft floated per-turn re-routing in-session; it is ruled out. Switching a
  running chat's model — or even just its reasoning effort, on providers whose cache
  keys include it — invalidates the provider's prompt cache, so every later turn
  re-pays the full accumulated context at uncached input rates. On a long agentic chat
  that can *multiply* cost, dwarfing anything smart routing saves. The classifier
  therefore runs for the new-chat composer only; a running chat's model/effort change
  only by the user's deliberate manual pick (which also clears the routing note).
- **Open:** does the difficulty head (§4.3) ever graduate from advisory tooltip to an
  auto-level suggestion? Deliberately out of scope here — it changes what the slider
  *means* and deserves its own decision.

---

## 10. Work items

Status reflects this worktree as of 2026-07-31; details and continuation commands are in
§11.

| # | Item | Status | Deliverables |
|---|---|---|---|
| 1 | Phase-1 centroid classifier (Apple) | Superseded | Phase 2 reached runtime first; `.custom` provenance and calibrated acceptance ship with Core ML instead |
| 2 | Dataset pipeline | **Complete** | `ml/purpose-classifier/` synth+curate+split scripts, frozen test set v1 (incl. the hard slice), difficulty grading pass, generation manifest |
| 3 | `purpose-lite` training + export | **Complete** | canonical 23.1 MB int8-QDQ ONNX, fixed-shape tokenizer contract, calibration JSON, frozen reports, selected distilled-QAT weights |
| 4 | macOS Core ML runtime | **Integrated; qualification pending** | bundled 22.8 MB W8A8 package, exact Swift tokenizer, `PurposeMLClassifier` with `.cpuAndNeuralEngine`, lazy warm loading, kill switch/model pin, layered fallback, frozen runtime gate; formal ONNX parity + W8A8 `MLComputePlan`/energy reports remain |
| 5 | Eval hardening | **In progress** | accuracy + per-purpose recall gates, hard-slice split, tier-drift over model predictions, latency smoke, energy spot-measurement procedure documented |
| 6 | Wire routing verbs | Not started | `WireRouteIntelligence` request/response, hostd handler over `IntelligenceRouter`, `WireMessageTests` |
| 7 | Windows ML runtime (with the port's §7 renderer) | Not started | `PurposeClassifier.cs` (Windows App SDK 1.8.1+, power-source-aware policy ladder — `MAX_EFFICIENCY` on battery; NPU→GPU (NvTensorRT-RTX/MIGraphX/OpenVINO-GPU)→CPU on AC — explicit-EP validation path, AOT compile-at-first-use), shared threshold JSON, degradation ladder |
| 8 | `purpose-deep` training + heads | **In progress** | MLX-native ModernBERT base/large graph, pinned upstream revisions, fixed 512-token head-tail contract, four joint heads, hard-aware selection, real-backbone parity verifier, and mixed-intent router blend are implemented; base Metal training/frozen evaluation remain |
| 9 | Deep-model distribution + selection | Not started | on-demand asset download/verify/pin (macOS-VM base-image pattern), §4.3 runtime selection ladder, accelerator-residency gating on both platforms, Settings download affordance |
| 10 | Rollout | Not started | dark-ship + local A/B logging, lite default flip, deep one cycle later, max one after that, AFM layer retirement |
| 11 | `purpose-max` (optional tier, after 8–9 prove out) | Not started | 1.5–4B LoRA-fine-tuned decoder, masked-label constrained readout + calibration, GenAI-format ONNX + stateful Core ML exports, GPU+memory+AC eligibility gating with live power-transition stepping, async rationale generation, idle-unload |

Items 1–5 are independent of the Windows port's schedule; 6 can land any time (additive);
7 rides the port's renderer milestone; 8–9 start once item 5's hard slice exists (deep's
gate is defined against it); 11 starts only after deep ships and holds its gates — max is
an *optional* crown, not a dependency of anything.

---

## 11. Implementation ledger and agent handoff

### 11.1 What is implemented

The initial implementation lives under `ml/purpose-classifier/`; its operating notes are
in `ml/purpose-classifier/README.md`.

- **Source cleanup and provenance:** `data/generation-manifest.json` declares
  `purpose-prompts.jsonl` and `purpose-prompts-round2.jsonl` as the only canonical source
  files. The `round2-NN.jsonl` files are retained generation batches that duplicate the
  round-two aggregate and must not be ingested again. Three exact copies of shipped eval
  prompts (`make it pop`, `continue`, `do the thing we discussed`) were removed from the
  round-one source. The fixture count in this document was corrected from 82 to the actual
  92 records.
- **Validation and curation:** `validate-data.py` now validates the two canonical files by
  default. `purpose_data.py` and `prepare_data.py` implement strict schema checks, runtime-
  compatible whitespace normalization plus NFKC, exact fixture exclusion, conservative
  word-trigram Jaccard near-deduplication behind SimHash LSH candidate search, deterministic
  stratification by purpose/slice/primary language, and a fail-closed frozen-test check.
  Conflicting labels on a detected near-duplicate stop the run for review.
- **Frozen dataset v1:** 12,214 canonical source records become 12,193 after 18 lexical
  near-duplicate templates and 3 human-reviewed semantic template copies are excluded.
  The logical split remains 80/10/10 to rounding: 9,824 train, 1,228 validation, and
  1,228 test. The versioned test membership is 1,141 synthetic
  records in `data/frozen-test-v1.jsonl` plus 87 classifiable records from the shipped
  fixture. Its five `general` fixtures are excluded because `general` is not a model label.
  The test set contains 634 synthetic hard-slice records. Counts, distributions, input
  hashes, output hashes, seed, and the hard-slice definition are locked in
  `data/dataset-v1-manifest.json`. All 605 `vague-eval` source records are validation/test
  only.
- **Semantic audit and review:** `audit_data.py` embeds all curated prompts plus the 87
  classifiable fixtures with the pinned MiniLM encoder, proposes high-cosine nearest
  neighbors without auto-editing data, and emits a deterministic 10% human-review CSV.
  `review_data.py` reconstructs the exact sample, checks progress, validates immutable
  generated columns and legal accept/relabel/reject decisions, and finalizes a versionable
  ledger only after every row is complete. `prepare_data.py --human-review` applies a
  completed ledger before splitting and records its hash and decision summary in the
  dataset manifest. The reviewed duplicate exclusions and 1,219-record label/difficulty
  review contract are locked in `data/curation-review-v1.json`. After applying the three
  reviewed exclusions, the final semantic audit contains one retained same-label
  validation-only wording variant and zero candidates crossing a frozen boundary
  (`data/semantic-audit-v1.json`).
- **Training:** `train.py` fine-tunes the commit-pinned
  `sentence-transformers/all-MiniLM-L6-v2` encoder with the fixed eight-label order and
  fixed 128-token padding/truncation. Long records use `[CLS]` + 63 head tokens + `[SEP]`
  + 62 tail tokens + `[SEP]`; the semantic audit exposed that head-only truncation made
  pasted contexts with different tail-buried asks identical inputs. It saves the best
  checkpoint, fits temperature only on label-scorable validation records, and treats every
  `vague-eval` record as incorrect when deriving abstention-aware HIGH/MEDIUM/LOW
  thresholds. It can continue from a local checkpoint, evaluates and retains that
  checkpoint as epoch zero, supports validation early stopping, and has an opt-in boundary
  loss weight for validation-controlled hard-slice experiments. Its opt-in QAT mode
  mirrors the shipping graph with straight-through per-tensor uint8 embeddings and
  activations plus per-channel int8 linear weights, while preserving ordinary checkpoint
  keys for the existing exporter. `cache_teacher.py` writes prompt-and-label-bound float
  logits in a separate process; QAT can use that small cache for KL distillation and
  validation selection that combines accuracy with float-teacher agreement, without
  holding two models resident. Pinned Python dependencies are in
  `requirements-base.txt` and `requirements.txt`; Linux defaults to the CPU-only PyTorch
  wheel so a development install does not silently download the CUDA stack.
- **Export:** `export.py` emits fixed `1 x 128`, opset-17 fp16 and int8-QDQ graphs, a
  runtime-independent tokenizer/normalization spec, four golden tokenizations, shared
  calibration JSON, graph checks, hashes, and an export report. Quantizing `Gather` is
  required because the WordPiece embedding table alone otherwise breaks the size budget.
  Int8 calibration now uses an exact deterministic sample stratified by purpose, slice,
  and primary language; the report records the seed, distributions, and prompt hashes.
  The first real export is 45,546,039 bytes fp16 and 23,148,504 bytes int8-QDQ; the latter
  passes the 25 MiB shipping budget.
- **Evaluation:** `eval.py` combines the frozen synthetic set with the 87 classifiable
  shipped fixtures and reports overall accuracy, macro/per-purpose recall, confusion
  matrix, per-slice and label-scored hard-slice accuracy, fixture accuracy, OOD abstention,
  ECE, accepted coverage/precision, every misclassification, routing tier drift, and
  batch-one latency including tokenization. It scores either the PyTorch checkpoint or the
  actual ONNX artifact. It fails unless label-scored accuracy is at least 95%, every purpose
  recall is at least 85%, `vague-eval` is LOW at least 90%, every misroute stays within one
  cost tier, and p95 latency is at most 20 ms (unless `--no-gate` is supplied). ONNX runs
  can also compare the PyTorch checkpoint in-process and report overall, scorable, and
  per-slice label agreement plus every correctness transition.
- **Core ML pre-integration tooling:** `convert_coreml.py` converts the selected checkpoint
  directly from PyTorch to a fixed `1 x 128` float16 ML Program, using an export-only BERT
  forward that avoids dynamic Transformers masking helpers and is parity-checked before
  conversion. It emits a per-file package hash manifest. `quantize_coreml.py` calibrates a
  W8A8 candidate with the exporter's same deterministic stratified validation sample and
  the closest Core ML-supported form of its quantization policy: asymmetric uint8
  linear-operation activations and embedding weights plus symmetric per-channel int8
  linear weights. The activation policy is deliberately scoped to floating-point linear
  operations because Core ML Tools' global pass also selects integer embedding-index
  additions and emits an invalid mixed-dtype quantize operation.
  Its bounded temporary-package lifecycle works around Core ML Tools retaining a complete
  weight copy per calibration prediction until process exit. A source-, policy-, and
  sample-bound A8 cache prevents weight-stage retries from repeating calibration. It
  preserves a second manifest and enforces the 25 MiB shipping budget. `eval.py` can score
  Core ML with explicit compute units and fails the gated path unless a supplied ONNX
  reference reaches 99.5% scorable label agreement. `inspect_coreml.py` records
  preferred/supported devices and both operation-count and estimated-cost ANE shares from
  `MLComputePlan`.
- **Tests:** the Python suite covers record validation, normalization, exact, lexical, and
  reviewed semantic duplicate handling, label-conflict failure, deterministic split
  membership, vague-eval isolation, frozen-set refresh/verification, deterministic review
  sampling, fail-closed review finalization/application and corpus-drift checks, embedding
  candidate thresholds, fixed head-tail tokenization, QAT checkpoint compatibility and
  gradient flow, zero-inclusive affine ranges, distillation loss and gradient flow,
  classification metrics, ECE,
  confidence-threshold nesting, and the all-label-pairs routing damage bound.

### 11.2 Verified commands and observed state

Run from the repository root:

```bash
python3 ml/purpose-classifier/validate-data.py
python3 ml/purpose-classifier/prepare_data.py
ml/purpose-classifier/.venv/bin/python ml/purpose-classifier/audit_data.py
ml/purpose-classifier/.venv/bin/python ml/purpose-classifier/review_data.py
python3 -m unittest discover -s ml/purpose-classifier/tests -p 'test_*.py' -v
```

At handoff these produce:

- canonical validation: **12,214 records, 0 errors, 23 warnings**; the warnings are known
  round-one `pasted-context` rows estimated just below the requested 100-token floor;
- preparation: **12,193 curated records; 9,824 train, 1,228 validation, 1,228 logical
  frozen test**; and
- semantic audit: **12,280 records audited; 1 retained same-label candidate, 0 crossing a
  frozen boundary; 1,219 prompts sampled for planned human review**; and
- label/difficulty review: **generated values accepted as-is by explicit dataset-owner
  direction; 0 row-level changes applied**; and
- tests: **35 passed**.

The first full three-epoch CPU experiment ran all 9,824 train and 1,228 validation records.
Validation accuracy improved monotonically (74.67% → 76.95% → 77.52%) under the then-current
all-record metric. Frozen analysis exposed why that aggregate is misleading:
`vague-eval`, intentionally absent from training, scored 28.18%, while the content-
recoverable slices scored **94.98%** together (core 96.25%, boundary 91.06%, mixed 97.50%,
pasted context 98.81%, shipped fixtures 89.66%). Every per-purpose recall clears 85% on
that scored population, but the accuracy gate misses by roughly two basis points and vague LOW
coverage misses its new 90% abstention gate. PyTorch CPU batch-one p95 was 28.31 ms in the
first measured pass and 32.80 ms in the final verification pass on the shared scratch
ARM64 container.

The real int8-QDQ ONNX export scored **94.02%** on the same label-scored population,
93.59% on the scored hard slice, and 89.66% on shipped fixtures. It passed routing drift
(maximum one tier, zero violations) and measured 25.75 ms p95 / 6.01 ms median in the
container, but still misses accuracy, OOD abstention, and p95 gates. The fp16 and int8
artifacts, checkpoint, and reports are intentionally gitignored; no model artifact is
committed from an experiment that misses rollout gates.

A three-epoch continuation from that checkpoint at `5e-6` reached **95.19% validation**
accuracy at epoch two, but the selected checkpoint scored only **94.88%** on the frozen
label-scored population (one fewer correct than the incoming checkpoint). It did clear the
vague abstention gate, but did not improve the shipped fixtures or the 94.17% scored hard
slice, so it is retained only as a rejected experiment.

Deterministic stratified int8 calibration recovered part of the initial conversion loss.
The validation-selected 256-record sample scored **93.71%** on validation plus shipped
fixtures and **94.24%** frozen, versus 93.01%/94.02% for 512 records and 92.91%/94.13%
for all 1,228 validation records. The 256-record policy is retained for export; adding
more PTQ calibration data does not recover the gap.

The baseline's residual errors are concentrated rather than random: 16 of the 47
label-scored misses are in the boundary slice; the largest confusion pairs are quick-fix
→ debugging (6), planning → writing (5), debugging → quick-fix (4), backend
implementation → writing (3), and both directions of quick-fix ↔ refactor (3 each).
Five of eleven shipped planning fixtures — prompts phrased as an RFC, design doc, or
implementation plan — route to writing/review. A `2x` boundary-weighted continuation at
`3e-6` selected epoch two at **94.97% validation** and then scored **95.09% frozen**
(891/937), clearing the label-accuracy gate by one correct prediction. It fixed one core
planning → review miss without introducing a new frozen error; boundary accuracy, hard-slice
accuracy (94.46%), and shipped-fixture accuracy (89.66%) were unchanged. Its calibrated
vague LOW rate improved to 274/291 (**94.16%**), so accuracy, recall, abstention, and
tier-drift gates pass; shared-container CPU p95 remains over budget at 31.32 ms. Because
the accuracy margin is a single record, this is a candidate checkpoint rather than a
robustly finished v1.

The candidate checkpoint's validation-selected 256-record MinMax PTQ export scores
**94.34% frozen**. Automated parity analysis reports 97.44% label agreement with PyTorch
on the scorable population: quantization breaks 14 PyTorch-correct decisions, repairs 7,
and changes 3 already-wrong labels. Agreement is weakest on boundary (95.53%) and shipped
fixtures (94.25%). An exploratory ONNX Runtime entropy-calibration run was abandoned after
stalling in transformer activation-histogram construction and produced no int8 artifact;
that option is not exposed by the pipeline.

QAT materially narrows that conversion gap. A two-epoch `1e-6`, `2x` boundary-weighted
continuation selected 93.65% fake-quant validation accuracy; its exported int8 graph
scores **93.31% validation** and **94.88% frozen (889/937)**, with **94.46%** scored-hard
and **89.66%** shipped-fixture accuracy. It is two correct predictions short of the
accuracy gate. Scorable PyTorch↔ONNX agreement improves to **98.19%**: quantization breaks
9 correct decisions, repairs 7, and changes 1 already-wrong label. The 23,148,500-byte
graph was the pre-distillation shipping candidate.

A validation-selected follow-up deliberately removed the boundary upweighting and used
one epoch at `5e-7`. It improved the exported int8 validation score to **93.71%** and
scored-hard validation to **94.64%**, but regressed frozen accuracy to **94.34% (884/937)**.
That checkpoint is rejected. The divergence is evidence that the present validation split
cannot safely select another optimizer-only QAT continuation this close to the gate; do
not spend more frozen-set looks on learning-rate or weighting sweeps.

The next conversion experiment is implemented but not promoted: float-teacher logit
distillation directly trains the fake-quant student to preserve the 95.09% checkpoint's
decisions. Full teacher logits for 9,824 train and 1,228 validation records were cached in
155 seconds, and a 64-record run verified cache binding, shuffled alignment, loss,
backpropagation, teacher-aware selection, and checkpoint reload. The shared CPU runtime
then throttled after the first backward batch (subsequent batches took roughly 150 seconds,
including with a lower-memory optimizer canary), so the full run was intentionally not started.
This is a runtime limitation, not a reported accuracy result; resume with the documented
hard timeout and progress logging when normal QAT throughput is available.

Because all intended training hosts are Apple Silicon and PyTorch's fused fake-quantization
operator is unavailable on MPS, a native MLX backend now mirrors the BERT classifier,
fixed-shape tokenizer, QAT numerics, distillation loss, selection policy, and Hugging Face
checkpoint contract. Its fake quantization uses MLX `round`, `clip`, and `stop_gradient`,
and selected weights round-trip to the existing PyTorch ONNX export path. The backend
includes a fail-closed verifier for exact fake-quant primitives, float-logit parity, QAT
prediction parity with bounded backend drift, QAT gradient flow, and exact weight
round-tripping. An executable MLX CPU-backend canary passed that verifier plus a complete
two-step distilled-QAT training, checkpoint-selection, calibration, and artifact-writing
cycle. At that point, the verifier still had to pass directly on Metal before a full MLX
candidate could be treated as an experiment result.

The Apple-Silicon verifier subsequently passed on Metal: fake-quant primitives and the
checkpoint round trip were exact, float max absolute logit error was 8.58e-6, QAT
predictions matched, and QAT gradients were finite and nonzero. A deterministic
four-epoch-ceiling run stopped after epoch three and selected epoch two at **94.75%**
fake-quant validation accuracy and a **95.77%** teacher-aware selection score. Its
23,148,500-byte int8-QDQ export scores **95.20% frozen (892/937)** with **95.19%** macro
recall, **94.17%** scored-hard accuracy, and **98.08%** scorable PyTorch↔ONNX agreement.
Every purpose recall is above 91%; the vague-abstention and routing-tier-drift gates pass.
The corresponding float checkpoint scores only 94.88% frozen, confirming that the gain is
specific to quantization robustness and that the int8 artifact is the candidate of record.
Container CPU latency is not an accelerator result; target-runtime latency and
energy/residency remain open hardware gates.

A real-traffic augmentation experiment is now staged from
`.artifacts/nucleic-history-first-prompts.labeled.jsonl`. The source contains 1,069
human-validated first prompts. The deterministic training-only builder excludes 13
`vague-eval` rows to preserve the abstention contract and three 0.92-near-duplicate
history rows; there is no exact overlap with the canonical train, validation, synthetic
test, or 87 classifiable shipped fixtures. The resulting split contains **10,877 train,
1,228 validation, and 1,141 synthetic test records**. Validation and test are byte-for-byte
identical to dataset v1. The augmented train SHA-256 is
`4e68c900a22a8e2ce93817eaec3d95e9bd7026ecd5adec946b10aae3aed04c25`.

The teacher cache has been regenerated and record-key verified for all 10,877/1,228
train/validation rows. A bounded PyTorch CPU canary passed cache binding, QAT
backpropagation, validation selection, checkpoint save, and reload. MLX 0.32.0 CPU parity
also passed (exact fake-quant primitives and checkpoint round trip, 4.77e-6 float-logit
maximum error, exact QAT predictions, and healthy gradients), but its two-batch training
canary produced no progress after more than four minutes and was terminated. Do not use
that fallback for a full run.

The matched Metal experiment subsequently completed in 111 seconds. It stopped after
epoch two and selected epoch one at **94.42% fake-quant validation accuracy**, 97.92%
teacher agreement, and a 95.59% selection score. The selected float checkpoint scores
**95.09% frozen (891/937)**, 95.09% macro recall, and 94.17% scored-hard accuracy. This is
two correct decisions above the accepted baseline's float checkpoint, with the gains
concentrated in writing and quick-fix recall while backend recall loses one decision.

The matched 23,148,500-byte int8-QDQ export reverses that improvement: it scores
**94.66% frozen (887/937)**, 94.66% macro recall, and 94.17% scored-hard accuracy. Relative
to the accepted 95.20% int8 artifact, it loses five correct decisions and fails the
shipping accuracy gate. Mixed accuracy improves from 96.25% to 98.75% and shipped
fixtures improve from 89.66% to 90.80%, but boundary falls from 91.06% to 89.94% and core
from 96.84% to 95.66%. Scored PyTorch↔ONNX disagreement actually narrows from 18 to 15
records, but its direction becomes harmful: quantization breaks nine float-correct
decisions and repairs five, versus six breaks and nine repairs for the accepted baseline.
The real-history augmentation therefore helps the selected float model but does not
improve the shipping graph. Preserve it under
`outputs/purpose-lite-v1-distilled-qat-mlx-history-v1/` as a rejected experiment; keep
`purpose-lite-v1-distilled-qat-mlx-4e` as the candidate of record.

Direct float16 Core ML conversion subsequently completed on physical Apple Silicon. The
45,218,688-byte ML Program scored **94.98% frozen (890/937)**, **94.98%** macro recall,
and **94.17%** scored-hard accuracy at **1.51 ms p95** with CPU+Neural Engine compute
units. Every per-purpose recall, vague-abstention, tier-drift, and latency gate passed, but
the exact 95% accuracy gate missed by one additional correct prediction. More importantly,
it reached only **97.97%** scorable label agreement with the accepted int8 ONNX artifact
(19/937 disagreements), so the 99.5% parity gate also failed. The compute plan reported
placement for 165 operations: 150 preferred the ANE (**90.91%**), while the ANE accounted
for **59.71%** of estimated cost. These are strong residency and speed results, but the
float16 package is rejected for rollout because the accepted checkpoint's accuracy gain is
quantization-specific. A Core ML-native W8A8 calibration path is now implemented to test
the corresponding Apple quantized computation without retraining or another frozen-set
model selection.

The resulting 22,847,173-byte W8A8 source package compiles to a 22,855,299-byte ML Program,
now integrated as an opaque SwiftPM `.mlmodelc` resource and loaded lazily by
`PurposeMLClassifier`. The native Swift/Core ML frozen test scores **95.41% (894/937)**,
**95.41% macro recall**, **92.24% minimum per-purpose recall**, **94.17% scored-hard
accuracy**, and **90.80%** on the legacy shipped fixtures.
It assigns LOW confidence to **96.56%** of vague-eval prompts and measured **2.81–8.72 ms
p95** across repeated CPU+Neural Engine host runs; all runtime gates enforced by
`NucleicAppTests` pass. Tokenization matches all four exported
Unicode/head-tail goldens exactly. The remaining artifact evidence is Core ML↔ONNX label
parity, a W8A8 compute-plan placement report, and the CPU-only energy comparison; the
default-on integration remains fail-closed behind its independent kill switch while those
rollout measurements are completed.

Work item 8 has now begun with an MLX-native ModernBERT implementation rather than a
PyTorch/MPS training path. `deep_contract.py` pins the 149M base and 395M large upstream
revisions, owns the fixed `1×512` head-tail tokenizer and all four target/metric contracts,
and selects checkpoints with equal weight on scored overall and scored hard-slice primary
accuracy. `deep_model_mlx.py` implements ModernBERT's alternating full/local attention,
RoPE, GeGLU blocks, pretrained prediction head, four task heads, and gradient
checkpointing with Hugging Face-compatible parameter names. `train_deep_mlx.py` adds
hard-slice weighting, imbalance-aware secondary/mixed losses, independent auxiliary-head
reports, calibration, observable progress, and selected-checkpoint state.

The real pinned ModernBERT-base checkpoint passed the cross-framework verifier on a
96-token input that crosses its local-attention half-window: 136 tensors loaded, the MLM
decoder alone was ignored, and Hugging Face↔MLX pooled maximum absolute error was
`6.58e-5` against a `5e-4` failure bound. Tiny-model tests also pass gradient-checkpointed
backpropagation and exact checkpoint round-tripping. A Linux CPU full-shape training
canary was intentionally terminated after more than five minutes in its first two-step
epoch; as with the lite MLX work, CPU is a diagnostic backend, not evidence about Metal
throughput. The next result-bearing action is the documented base Metal run against the
training-only first-prompt augmentation. Large remains gated on base's frozen result.

To reproduce the smoke path:

```bash
python3 -m venv ml/purpose-classifier/.venv
ml/purpose-classifier/.venv/bin/pip install -r ml/purpose-classifier/requirements.txt
ml/purpose-classifier/.venv/bin/python ml/purpose-classifier/train.py \
  --epochs 1 --max-train-records 64 --max-validation-records 64 \
  --output-dir ml/purpose-classifier/outputs/smoke --overwrite-output
ml/purpose-classifier/.venv/bin/python ml/purpose-classifier/eval.py \
  --model-dir ml/purpose-classifier/outputs/smoke/model \
  --calibration ml/purpose-classifier/outputs/smoke/calibration.json \
  --report ml/purpose-classifier/outputs/smoke/frozen-eval.json --no-gate
```

`.artifacts/`, `.venv/`, and `outputs/` are intentionally gitignored. On an accelerator
training machine, install the matching `torch==2.13.0` build from PyTorch's platform
selector, then install `requirements-base.txt` rather than replacing it with the Linux CPU
wheel.

### 11.3 What remains and where to resume

Future classifier work should preserve these constraints and finish item 5's rollout
evidence before changing the selected lite artifact:

1. **Strengthen the narrow PyTorch pass with targeted data.** The dataset owner accepted
   the existing generated labels/difficulty values as-is, so the optional 1,219-row audit
   is no longer a blocker. Exact generator model IDs and sampling parameters were not
   retained; keep that limitation explicit rather than guessing provenance. The
   validation-selected distilled-QAT MLX artifact clears frozen accuracy at 95.20%, but
   only by three records and without making that margin robust. Stop spending frozen-set
   looks on optimizer tweaks. The all-history augmentation improves its float checkpoint
   but regresses the int8 shipping graph to 94.66%, so do not promote or tune against that
   frozen result. Reserve a deterministic real-history holdout before the next data
   experiment, add planning-vs-writing and quick-fix-vs-debugging/refactor coverage, and
   select quantized candidates on validation before evaluating against a newly frozen
   version.
2. **Keep the cached-teacher QAT result as the candidate of record.** The experiment is
   complete and clears the shipping-format accuracy gate. Do not select another optimizer
   continuation against frozen v1. Preserve the selected model, calibration, export
   metrics, tokenizer contract, and float/int8 frozen reports under
   `outputs/purpose-lite-v1-distilled-qat-mlx-4e/`. Do not retry entropy calibration on
   this transformer graph.
3. **Finish hardware-bound evidence.** W8A8 generation and app integration are complete;
   the native runtime clears accuracy, recall, abstention, and latency. Produce the formal
   Core ML↔ONNX label-parity report, run `inspect_coreml.py` (or its native equivalent) on
   the W8A8 package, and complete the CPU-only energy comparison in
   `ENERGY_AND_RESIDENCY.md`. Keep the default-on runtime behind
   `nucleic.purposeClassifierEnabled` until those rollout artifacts are reviewed. Run the
   Windows half on one NPU when that renderer exists.

4. **Run and evaluate purpose-deep base on Metal.** First run
   `verify_deep_mlx.py --variant base`, then use `train_deep_mlx.py --variant base` with
   `.artifacts/dataset-v1-history-first-prompts` as documented in the ML README. Evaluate
   the selected checkpoint without tuning against frozen v1. Do not start large unless
   base establishes useful hard-slice lift; large still needs a two-point hard-slice win
   over base to justify its size.

   The first three-epoch Metal run completed in 4,892 seconds and selected epoch three at
   **74.18%** scored primary validation accuracy and **69.97%** hard-slice accuracy. Its
   calibrated mixed-intent F1 was **51.85%**, vague LOW rate **95.86%**, and difficulty
   MAE **0.1334**. This is not a deep candidate and does not warrant a frozen-set look or
   the large rung. Primary training loss and both selected accuracies improved every epoch,
   however, so the base recipe ended underfit rather than plateaued. The trainer now has a
   strict `--resume-from` continuation path that restores all 144 tensors—including all
   four task heads—and starts a fresh optimizer.

   The conservative three-epoch continuation then selected its third epoch at **80.31%**
   primary and **78.28%** hard-slice validation accuracy, with calibrated mixed-intent F1
   at **67.12%** and difficulty MAE at **0.1317**. This is a useful six-point overall and
   eight-point hard-slice gain, but it remains roughly fourteen points behind purpose-lite
   and rejects the unchanged training recipe. Do not chain another plain continuation.
   The bounded next experiment in the ML README adds prompt-bound purpose-lite teacher
   distillation to the continued checkpoint for two epochs. Deep selection remains purely
   label/hard-slice based, and epoch zero is preserved so distillation cannot replace the
   80.31% checkpoint with a regression. If this does not close most of the primary gap,
   stop optimizer experiments and revisit the representation/objective before any large
   run or frozen evaluation.

Do not start purpose-max from this handoff. It remains optional and depends on deep first
clearing its own comparison gates.
