From 47dfe0797fe6cb6f681a857145dc178880c5c2e8 Mon Sep 17 00:00:00 2001 From: OceanLi <122793010+ohdearquant@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:15:55 -0400 Subject: [PATCH 1/2] feat(macos): Lattice app v1 (Models + Chat) First release scope of the Lattice macOS app. Ships the two surfaces that work end to end against the current engine: model browsing (MODELS) and local chat (CHAT). Sliced from the LoRA training branch (#193). Deferred and re-addable as the backend matures: Data, Train, Eval and Embeddings, Quantize, Runs and History, adapter management. - Screen enum reduced to {models, chat}; nav, command bar, and run routing follow the two-screen shape - ChatScreen: single-mode generate over CPU bf16 and GPU Metal (bf16 + q4), honest disk status, sampling and generation controls, retry, tok/s. No adapter path is threaded - ModelsScreen: get, refresh, reveal, and a Chat CTA, plus the full model config readout inspector - Removed DataScreen, TrainScreen, EvalScreen, QuantizeScreen, RunsScreen - Unused store and model scaffolding kept compiling for incremental revival swift build is green on a forced recompile. No engine binaries are added; the app shells out to binaries already on main. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/macos/.gitignore | 15 + apps/macos/DESIGN.md | 363 +++++++ apps/macos/DISTRIBUTION.md | 116 +++ apps/macos/Package.swift | 25 + apps/macos/Resources/LatticeStudio.icns | Bin 0 -> 72545 bytes .../LatticeStudio/App/LatticeStudioApp.swift | 43 + .../LatticeStudio/Bridge/Drivers.swift | 521 ++++++++++ .../LatticeStudio/Bridge/LatticeBridge.swift | 672 ++++++++++++ .../LatticeStudio/Bridge/LatticeEvents.swift | 482 +++++++++ .../LatticeStudio/Bridge/RunRegistry.swift | 164 +++ .../LatticeStudio/Components/Badges.swift | 173 ++++ .../Components/ButtonStyles.swift | 170 +++ .../LatticeStudio/Components/CommandBar.swift | 396 +++++++ .../Components/ContrastPair.swift | 236 +++++ .../LatticeStudio/Components/DataTable.swift | 247 +++++ .../Components/EmptyStateView.swift | 112 ++ .../Components/FaderToggle.swift | 184 ++++ .../LatticeStudio/Components/Field.swift | 196 ++++ .../LatticeStudio/Components/GatePill.swift | 130 +++ .../LatticeStudio/Components/HeroNumber.swift | 116 +++ .../Components/InspectorShell.swift | 134 +++ .../LatticeStudio/Components/KeyCapChip.swift | 89 ++ .../LatticeStudio/Components/MassBars.swift | 181 ++++ .../Components/OpaquePanel.swift | 135 +++ .../LatticeStudio/Components/ParamRow.swift | 356 +++++++ .../Components/ReadoutWell.swift | 151 +++ .../LatticeStudio/Components/StripChart.swift | 242 +++++ .../LatticeStudio/Screens/ChatScreen.swift | 975 ++++++++++++++++++ .../Screens/GetModelsSheet.swift | 295 ++++++ .../LatticeStudio/Screens/ModelsScreen.swift | 683 ++++++++++++ .../Screens/ScreenScaffold.swift | 48 + .../LatticeStudio/Shell/ContentView.swift | 173 ++++ .../LatticeStudio/Shell/LeftRail.swift | 235 +++++ .../LatticeStudio/Store/AppStore.swift | 397 +++++++ .../LatticeStudio/Store/DomainModels.swift | 374 +++++++ .../Sources/LatticeStudio/Theme/Theme.swift | 332 ++++++ apps/macos/docs/INSTRUMENT_SCOPE.md | 427 ++++++++ apps/macos/scripts/generate-icon.py | 124 +++ apps/macos/scripts/package-app.sh | 203 ++++ 39 files changed, 9915 insertions(+) create mode 100644 apps/macos/.gitignore create mode 100644 apps/macos/DESIGN.md create mode 100644 apps/macos/DISTRIBUTION.md create mode 100644 apps/macos/Package.swift create mode 100644 apps/macos/Resources/LatticeStudio.icns create mode 100644 apps/macos/Sources/LatticeStudio/App/LatticeStudioApp.swift create mode 100644 apps/macos/Sources/LatticeStudio/Bridge/Drivers.swift create mode 100644 apps/macos/Sources/LatticeStudio/Bridge/LatticeBridge.swift create mode 100644 apps/macos/Sources/LatticeStudio/Bridge/LatticeEvents.swift create mode 100644 apps/macos/Sources/LatticeStudio/Bridge/RunRegistry.swift create mode 100644 apps/macos/Sources/LatticeStudio/Components/Badges.swift create mode 100644 apps/macos/Sources/LatticeStudio/Components/ButtonStyles.swift create mode 100644 apps/macos/Sources/LatticeStudio/Components/CommandBar.swift create mode 100644 apps/macos/Sources/LatticeStudio/Components/ContrastPair.swift create mode 100644 apps/macos/Sources/LatticeStudio/Components/DataTable.swift create mode 100644 apps/macos/Sources/LatticeStudio/Components/EmptyStateView.swift create mode 100644 apps/macos/Sources/LatticeStudio/Components/FaderToggle.swift create mode 100644 apps/macos/Sources/LatticeStudio/Components/Field.swift create mode 100644 apps/macos/Sources/LatticeStudio/Components/GatePill.swift create mode 100644 apps/macos/Sources/LatticeStudio/Components/HeroNumber.swift create mode 100644 apps/macos/Sources/LatticeStudio/Components/InspectorShell.swift create mode 100644 apps/macos/Sources/LatticeStudio/Components/KeyCapChip.swift create mode 100644 apps/macos/Sources/LatticeStudio/Components/MassBars.swift create mode 100644 apps/macos/Sources/LatticeStudio/Components/OpaquePanel.swift create mode 100644 apps/macos/Sources/LatticeStudio/Components/ParamRow.swift create mode 100644 apps/macos/Sources/LatticeStudio/Components/ReadoutWell.swift create mode 100644 apps/macos/Sources/LatticeStudio/Components/StripChart.swift create mode 100644 apps/macos/Sources/LatticeStudio/Screens/ChatScreen.swift create mode 100644 apps/macos/Sources/LatticeStudio/Screens/GetModelsSheet.swift create mode 100644 apps/macos/Sources/LatticeStudio/Screens/ModelsScreen.swift create mode 100644 apps/macos/Sources/LatticeStudio/Screens/ScreenScaffold.swift create mode 100644 apps/macos/Sources/LatticeStudio/Shell/ContentView.swift create mode 100644 apps/macos/Sources/LatticeStudio/Shell/LeftRail.swift create mode 100644 apps/macos/Sources/LatticeStudio/Store/AppStore.swift create mode 100644 apps/macos/Sources/LatticeStudio/Store/DomainModels.swift create mode 100644 apps/macos/Sources/LatticeStudio/Theme/Theme.swift create mode 100644 apps/macos/docs/INSTRUMENT_SCOPE.md create mode 100755 apps/macos/scripts/generate-icon.py create mode 100755 apps/macos/scripts/package-app.sh diff --git a/apps/macos/.gitignore b/apps/macos/.gitignore new file mode 100644 index 0000000000..a2822081a8 --- /dev/null +++ b/apps/macos/.gitignore @@ -0,0 +1,15 @@ +# SwiftPM build products & local tooling +.build/ +.swiftpm/ +DerivedData/ + +# Packaging output (.app/.dmg/.zip — built by scripts/package-app.sh) +dist/ + +# Xcode +*.xcodeproj +*.xcworkspace +xcuserdata/ + +# macOS +.DS_Store diff --git a/apps/macos/DESIGN.md b/apps/macos/DESIGN.md new file mode 100644 index 0000000000..75f5735197 --- /dev/null +++ b/apps/macos/DESIGN.md @@ -0,0 +1,363 @@ +# Lattice Studio — Design Decision + +> **Status:** Decision doc for Ocean. Two refined finalists, head-to-head, with a recommendation and a build sequence. +> **Target:** macOS 26, SwiftUI, `swift build`. Toolchain verified: Apple Swift 6.3.2 / `arm64-apple-macosx26.0`. +> **Backend:** the `lattice` pure-Rust engine, driven via CLI subprocesses (line-delimited JSON event stream). +> **Brand law (governs both directions):** *measure first, the number is the truth.* Bold is spent on the **numbers**, not on chrome. Adaptive light + dark from day one. + +--- + +## TL;DR + +Two monochrome, numeral-first directions survived refinement. They share one thesis (the app is the **instrument panel** of the engine, not a consumer wrapper) and diverge on **density vs. speed**. + +| | **PRIMARY — Lattice Instrument** | **ALTERNATIVE — Lattice / Console** | +|---|---|---| +| Angle | Trading-desk × oscilloscope, data-forward pro tool | Linear/Raycast/Zed minimal, keyboard-first | +| Hero of the screen | A 56pt tabular-mono number on opaque matte | A 40pt tabular-mono number, Cmd-K runs everything | +| Density | High (readout wells, dense tables, 3 panes) | Lean (flat config lists, command-palette spine) | +| Accent | Signal Teal `#00E5C7` / `#00A892` | Voltage Blue `#3D7BFF` / `#2C5FE0` | +| Feasibility | High | Highest | +| Distinctiveness | Strong (full instrument metaphor) | Good (risks "another dark dev tool" if type is weak) | + +**Recommendation: build Lattice Instrument**, grafting Console's Cmd-K command spine. Rationale in the [comparison](#head-to-head) and [recommendation](#recommendation) sections. + +Both directions adopt three shared laws, grafted from the design exploration: +1. **Numbers never touch glass.** Glass (`.glassEffect` / `.regularMaterial`) is permitted on the navigation/overlay layer only — toolbar + Cmd-K palette. Every content surface holding a numeral is **opaque**. The truth does not shimmer. +2. **Before↔after ContrastPair** for the QuaRot quantization story — a decisive two-column reveal, never a buried claim. +3. **Verdict as text, not vibe** — every measurable action ends in a GATE PILL stating the result (`PASS` / `WARN` / `FAIL`). + +--- + +# PRIMARY — Lattice Instrument + +**Tagline:** *Measure first. The readout is the truth.* + +## Philosophy + +lattice owns hand-written Metal/AVX2/NEON kernels with zero framework — no Python, no ONNX, no CUDA, no runtime. The app should look like the **instrument panel of that engine**, not a wrapper around it. So every numeric quantity (loss, grad-norm, tok/s, PPL, MB, rank, lr) is a first-class citizen in tabular monospace that never reflows as digits change, on hairline-ruled opaque panels with almost no chrome — the way a DAW or a Bloomberg terminal trades whitespace for live truth. + +One accent (signal teal) is spent only on **movement** — the loss trace, the active token stream, the now-cursor — so the eye always lands on the thing that is actually changing. It honors Ocean's editorial/high-contrast lean by making **bold typography the hero** (a 56pt loss numeral, luminous data on ink) instead of illustration, and it beats a generic dashboard because it commits fully to the instrument metaphor: readouts, sweeps, gates — the literal visual translation of measure-first DNA. + +The README is explicit that MLX is faster; lattice's edge is **capabilities + honesty**. So we sell the moat, not raw speed: QuaRot 4-bit, Q4+LoRA hot-swap with no reload, pure-Rust portability. + +## Visual Identity + +### Palette + +Adaptive via semantic `Color` assets in the asset catalog (`Color(.panel)`, `Color(.ink)`, `Color(.signal)`…). Dark is the home key; light is a true first-class peer reading like a lab instrument under daylight — same hairline-and-well skeleton, value-inverted. + +| Token | Dark hex | Light hex | Usage | +|---|---|---|---| +| Canvas / Panel face | `#0A0B0D` Vantablack | `#F7F8FA` Quartz | Primary background, the instrument face. **Opaque.** | +| Panel Raise | `#121419` | `#FFFFFF` | Elevated center panel + readout-well body (one step up). | +| Well Sink | `#070809` | `#ECEEF2` | Recessed fill **inside** a readout well (below the face). | +| Hairline | `#23262E` | `#DCDFE5` | 1px rules: dividers, grid, table separators, well borders. Depth via line. | +| Ink (text + numerals) | `#E8EAED` | `#14161A` | Primary text/numerals. ~14.5:1 dark / ~15:1 light. | +| Ink Dim | `#7C828D` | `#5C636E` | Labels, units, axis ticks, metadata. ~4.8:1 / ~5.2:1. | +| **Signal Teal** (accent) | `#00E5C7` | `#00A892` Teal Deep | Live trace, token stream, now-cursor, the **one** primary CTA/screen, focus ring. The only color that shifts value between modes (to hold ~4.5:1). | +| Teal Glow | `#00E5C7 @12%` | `#00A892 @12%` | Area-fill gradient under the live trace; under-glow behind newest segment. | +| Amber Caution | `#FFB020` | `#FFB020` (value-shifted) | Regression/warning: loss rising, grad-norm spike, PPL over budget, mem pressure. Replaces red for colorblind safety. | +| Crimson Halt | `#FF4D5E` | `#FF4D5E` (value-shifted) | Hard failure only: NaN loss, OOM, run crashed, parity FAIL. Rare by design. | + +**WCAG AA is a hard floor:** hero Ink-on-panel ≥14:1, dim labels ≥4.5:1, teal-on-its-ground ≥4.5:1. Amber/Crimson keep hue and shift lightness for AA on each ground. + +### Typography + +Three axes, hard separation by role. **Mono is the signature** — the eye registers "this is an instrument" from the numerals alone. + +- **DISPLAY / TITLES** — SF Pro Display (system), Bold/Heavy. Screen titles 17pt Semibold (-0.01em). +- **HERO NUMBER** — the current loss / compression ratio / PPL at **56pt Bold, -0.02em**, rendered in **JetBrains Mono tabular** (not the grotesque) so the headline number never jitters. This is the one place display scale and mono fuse. +- **NUMERALS + DATA** — JetBrains Mono (bundled, OFL), `.monospacedDigit()` always on. Sizes: 11pt table cells / 13pt dense readouts / 15pt readout-well value / 21pt secondary hero / 56pt hero. Units 11pt. +- **BODY / LABELS** — SF Pro Text 13pt Regular for prose; 11pt Medium ALL-CAPS +0.06em for instrument labels and units (`LOSS`, `TOK/S`, `GRAD-NORM`, `ΔPPL`). Max prose measure 640px. + +Modular scale (pt): **11 · 13 · 15 · 21 · 34 · 56.** + +### Spacing & Grid + +8pt base grid; dense 4pt variant for table rows (28px row height, 32px "comfortable" toggle). Three-region shell, panels butt against 1px Hairline with **no gaps**: **LEFT RAIL 220px / fluid CENTER / RIGHT INSPECTOR 300px** (collapsible to 0 with `⌘\`). Internal panel padding 16px; section gutter 24px. + +Readout wells: inset 1px Hairline border + Well-Sink fill + a 2px inner top-shadow (`rgba(0,0,0,0.5)` dark / `0.06` light) so they read machined-in. **Corner radius:** `0px` on panels and table cells (panels are ruled, not carded), `6px` on wells/controls/gate pills, `10px` on the floating command bar. Data tables go full-bleed. **One elevation step only — no card-on-card.** + +### Materials & Motion + +**Governing law: numbers never touch glass.** Glass is permitted on the navigation/overlay layer only (top toolbar + the floating ⌘K command bar use thin `.regularMaterial`). Every content surface — panels, wells, tables, the strip chart, transcripts — is opaque. This also dodges the Liquid-Glass perf caveats, because data is solid. + +Motion is mechanical, never bouncy: +1. **Numerals tick per-digit** via `.contentTransition(.numericText())` at ~120ms — throttled to 8Hz on fast values (tok/s) so it never looks frantic. +2. **Loss curve draws as an oscilloscope sweep** (left→right) with a 1px teal now-cursor and a 12% teal under-glow; chart commits throttled to ~20Hz with rolling-window downsample. +3. **Panel focus** traces a 1px teal ring in 180ms ease-out. +4. **Spring (response 0.32, damping 0.85)** is reserved for exactly **one** element: the adapter hot-swap fader, so it feels like throwing a hardware switch. + +Durations 120/180/240ms ease-out. No parallax, no decorative particles. Respects `accessibilityReduceMotion` (ticks→crossfade, sweep→instant) and `accessibilityReduceTransparency` (glass→solid material, same teal). + +## Component Language + +Instruments, not widgets. Eleven primitives, opaque unless noted. + +1. **READOUT WELL** — the atomic unit: inset hairline block (Well-Sink fill, 6px radius, inner top-shadow) holding one 11pt ALL-CAPS dim label + one tabular-mono value (15pt) + unit + optional delta caret (▲/▼ teal=good/amber=bad). For loss, lr, grad-norm, tok/s, ETA, size, PPL. +2. **HERO NUMBER** — 56pt JetBrains-Mono-tabular value with a tiny mono unit and a 1px teal under-rule; ticks per-digit. The visual anchor of TRAIN and QUANTIZE. +3. **STRIP CHART (oscilloscope)** — Swift Charts `LineMark` (1.5px teal) + `AreaMark` (12% teal gradient) + `RuleMark` now-cursor; mono axis ticks, hairline grid, no legend. A draggable scrub line freezes every readout well to that step. On QUANTIZE it carries a **ghost base-PPL line** so the delta is a visible gap, not a claim. +4. **CONTRAST PAIR** — the before↔after comparator: two stacked READOUT WELL columns (size/bits/PPL) with a centered Δ chip; on completion the columns slide apart and a single hairline **"fold" wipe** (the QuaRot rotation metaphor) reveals the after-state. +5. **MASS BARS** — two horizontal bars to **true scale** (fp16 "before" Ink-dim / Q4 "after" teal) that animate to length while the compression ratio counts up into a HERO NUMBER. Lives directly under the CONTRAST PAIR on QUANTIZE. +6. **DATA TABLE** — 28px rows (32px comfortable), tabular-mono right-aligned numerals, hairline row rules, **no zebra**, sortable 11pt all-caps headers, selected row marked by a 2px teal left-border (no fill flood). +7. **PARAM ROW** — label (left, dim) + control (right) on one hairline-ruled line; sliders show their value as a live mono readout in the track. Forms are stacks of these, never boxed. +8. **GATE PILL** — status capsule encoding the verdict: `PASS` (teal) / `WARN` (amber) / `FAIL` (crimson) / `RUN` (animated teal pulse). e.g. `Q4 ok 405MB −74% · est.ΔPPL +0.09 PASS`. +9. **FADER TOGGLE** — the adapter A/B switch styled as a console fader (the one spring element). +10. **KEY-CAP CHIP** — a tiny 1px-outlined ⌘-cap on actionable elements so the keyboard map self-documents. +11. **COMMAND BAR** — a floating ⌘K mono palette (the one glass slab) where `train qwen3.5 r8` or `quantize quarot` parse into argument chips and fire a run without touching a form. *(Grafted from Console — see recommendation.)* + +Buttons: rectangular, 6px radius, 1px-bordered hairline default; exactly **one** teal-filled primary per screen. No rounded cards, no drop shadows except the recessed-well inner shadow. + +## Information Architecture + +Three-pane instrument console (`NavigationSplitView`). + +- **LEFT RAIL (220px, persistent):** wordmark `LATTICE` in mono + build hash; a pinned top **RUN block** (live job: model · step · loss · tok/s, or `idle`); vertical nav with mono index labels — `01 MODELS`, `02 TRAIN`, `03 QUANTIZE`, `04 CHAT`, `05 DATA`, `06 RUNS`, each with a ⌘1–⌘6 key-cap chip; pinned at the bottom a live **SYSTEM STRIP** (unified-memory bar, GPU%, active-run mini-readout) always reading out. +- **CENTER (fluid):** the active instrument screen. +- **RIGHT INSPECTOR (300px, contextual, collapsible `⌘\`):** details/config for the current selection. + +### Screens + +- **01 MODELS** — DATA TABLE of `~/.lattice/models` (name, params, dtype, format, size GB, files) with adapters indented under each base model (verified on-disk: `qwen3.5-0.8b` + `-q4` + `-q4-quarot`, plus `qwen3.5-2b`; embedding models — `all-minilm-l6-v2`, `bge-small-en-v1.5`, `qwen3-embedding-0.6b`, the e5/minilm multilingual pair — filtered to a sub-tab). Inspector shows the file manifest, `config.json` as readout wells (the **18-GDN / 6-GQA** layer split called out explicitly, vocab, ctx), and Download / Verify / Reveal-in-Finder. QuaRot weights get a `rotated` badge. +- **02 TRAIN** — left PARAM ROWS (model, target layers, rank r8, scale α, lr, steps, dataset). Center HERO NUMBER (live loss, ticking) over the oscilloscope STRIP CHART (train_loss + val_loss + grad-norm overlay, teal trace, now-cursor), with a row of READOUT WELLS (step, lr now, grad-norm, tok/s, ETA). Inspector: live stdout console + a GATE PILL on `best_val_loss` verdict. Scrub the curve to freeze all wells at any step. +- **03 QUANTIZE** — pick model + method (Q4 / QuaRot toggle, QuaRot badged `rotated`). Center the CONTRAST PAIR (size/bits/est.PPL with Δ) over MASS BARS (true-scale fp16→Q4) over a progress sweep, terminating in a VERDICT GATE PILL. Inspector: per-layer quant table (layer, scheme, error). +- **04 CHAT** — split A | B columns (base vs base+adapter) streaming the SAME prompt token-by-token in teal; the adapter selector is the HOT-SWAP FADER with a `0 ms reload` stamp; sampling READOUT WELLS (temp, top-p, top-k, seed) in the inspector; per-column tok/s + TTFT so testing is also measuring. +- **05 DATA** — left source files; center a `{prompt, completion}` pair DATA TABLE with a validate GATE PILL (token-count histogram, malformed-row count); inspector single-pair editor + export-to-JSONL that writes straight into the Train dataset field. +- **06 RUNS** — archive DATA TABLE (status pill, kind, model, last loss/best val, dur, when); selecting a row loads its config back into the relevant screen and shows its frozen strip chart — the lab notebook. + +## Signature Interactions + +1. **LIVE LOSS OSCILLOSCOPE + SCRUB-TO-FREEZE** — the TRAIN strip chart draws as a real-time left→right sweep with a 1px teal now-cursor and 12% under-glow; the 56pt hero loss numeral above it ticks digit-by-digit. Drag the cursor backward and **every** readout well rewinds to that step's exact lr / grad-norm / tok-s — inspect the precise state at the moment loss spiked. Only possible because wells are wired to the real per-step `EpochMetrics` / `AdaptStepResult` stream, not a redraw. +2. **HOT-SWAP FADER (the moat made physical)** — in CHAT the adapter selector is a console fader. Sliding it from BASE to BASE+LoRA r8 swaps the adapter with **no reload** — the B column visibly re-streams the same prompt under the new weights in real time and a teal `0 ms reload` stamp confirms it. The one place spring physics is allowed. Both columns share one prompt bar and stream in lockstep. +3. **THE VERDICT GATE + ⌘K SPINE** — every measurable action ends in a GATE PILL stating the truth as text (`Q4 ok 405MB −74% · est.ΔPPL +0.09 PASS`, flips amber if ΔPPL exceeds the honest budget); the CONTRAST PAIR completes with a single hairline "fold" wipe as the MASS BARS settle. ⌘K opens a glass mono palette where `quantize quarot qwen3.5` parses into argument chips and fires the run without a form. + +## ASCII Mockups + +``` +MAIN SHELL — 02 TRAIN (dark) --------------------------------------- ++------------+--------------------------------------+---------------+ +| LATTICE | 02 TRAIN qwen3.5-0.8b · lora r8 ⌘K | STDOUT ⌘\ | +| ·a3f9c1 +--------------------------------------+ step 0420... | +| ● RUN | L O S S | loss 0.612 | +| step 420 | 0.6121 ▼0.004 step 420/1000 | lr 1.81e-4 | +| 01 MODELS ⌘1| +----------------------------------+ | grad 0.93 | +| 02 TRAIN ◀ | |\__ .· |◀| tok/s 1820 | +| 03 QUANTIZE| | \\__ ..· | |---------------| +| 04 CHAT ⌘4| | \\\___\__/\___····· now► | | GATE | +| 05 DATA ⌘5| +----------------------------------+ | [▣] eval PASS | +| 06 RUNS ⌘R| STEP LR-NOW GRAD TOK/S ETA | | best val 1.94 | +|············| 0420 1.81e-4 0.93 1820 4m | +---------------+ +| MEM ████░ | +- PARAMS ------------------------+ | | +| 11.2/16 GB | rank r8 · α16 · lr 1.8e-4 · 18 GDN | | +| GPU 74% | dataset claude-lora.jsonl 4,812 rw | [ ■ STOP ] | ++------------+--------------------------------------+---------------+ + +03 QUANTIZE ------------------------------------------------------- +| 03 QUANTIZE qwen3.5-0.8b method:( Q4 )[ QuaRot ⟳ ] | +| BEFORE ──fold──▶ AFTER VERDICT | +| SIZE 1.61 GB 405 MB ▼ −74% | +| BITS 16 4 rotated ok | +| PPL 15.86 (ghost)····· 15.95 est. Δ+0.09 [▣]PASS | +| fp16 ████████████████████████ Q4 ███████ 3.97× smaller | +| ███████████████░░░░ rotate→pack→verify layer 18/24 sweep► | +``` + +## Per-Feature Surfacing + +- **1. LoRA Training → 02 TRAIN.** PARAM ROWS configure model / target-layers (18 GDN + 6 GQA selectable strip) / rank r8 / scale α / lr / steps / dataset, slider values shown as live mono readouts. Watched via a 56pt HERO loss numeral (ticking) over the oscilloscope STRIP CHART (train_loss + val_loss + grad-norm overlay) and READOUT WELLS for step / lr-now / grad-norm / tok-s / ETA — wired to the real `EpochMetrics{train_loss, val_loss, learning_rate, examples_seen, duration_secs}` plus per-step `AdaptStepResult{loss, grad_norm}` streamed as line-delimited JSON into an `@Observable` store. Inspector streams raw stdout and shows the eval GATE PILL on `best_val_loss`. Scrub the curve to freeze all wells at any step. +- **2. Quantization → 03 QUANTIZE.** Pick model + a Q4 / QuaRot method toggle (QuaRot badged `rotated ⟳`). A CONTRAST PAIR shows size / bits / est.PPL with the Δ in teal(improve)/amber(regress); below it MASS BARS animate to true scale (fp16 1.61GB → Q4 405MB) while the 3.97× ratio counts up; a progress sweep labels real phases (rotate→pack→verify); a final VERDICT GATE PILL states the result. The strip carries a ghost base-PPL line so quality cost is a visible gap. PPL is explicitly labeled `est.` unless a calibration set is supplied (measure-first honesty). Inspector: per-layer quant table. +- **3. Model Management → 01 MODELS.** Dense DATA TABLE of `~/.lattice/models` with adapters indented under their base model — reflecting the real on-disk layout. Tabular-mono right-aligned numerals, sortable headers, no zebra. Inspector reveals the full file manifest and `config.json` as readout wells (18-GDN / 6-GQA split explicit), with Download / Verify / Reveal-in-Finder. QuaRot weights get a `rotated` badge; the adapter row shows a `swappable · no reload` chip. +- **4. Chat / Sample Testing → 04 CHAT.** Two-column A|B layout streams the SAME prompt token-by-token (teal) through base vs base+adapter from ONE shared prompt bar. The adapter selector is the HOT-SWAP FADER (`0 ms reload` stamp); sliding it re-streams the B column live. Sampling controls (temp/top-p/top-k/seed) are inspector READOUT WELLS; each column shows live tok/s + TTFT so sample-testing doubles as measurement. Single-column mode collapses to one full-width transcript. +- **5. Dataset Prep → 05 DATA.** Source files on the left; center a `{prompt, completion}` pair DATA TABLE with a validate GATE PILL summarizing a token-count histogram and malformed-row count (tokenized with lattice's own tokenizer so counts match training exactly). Inspector is a single-pair editor; export writes JSONL straight into the Train screen's dataset field — raw to training-set, previewed and validated before it ever feeds a run. + +--- + +# ALTERNATIVE — Lattice / Console + +**Tagline:** *Measure first. Every number, one keystroke away.* + +## Philosophy + +lattice is an instrument, not an app. An oscilloscope doesn't decorate the waveform; it gets out of the way so the engineer trusts the trace. Console is near-monochrome so the one thing that earns color and movement — a live loss ticking down, a PPL delta, a parity PASS — is unmissable and pre-attentive. Everything is reachable from a single command bar (⌘K), because a tool that worships "measure first" should never make you hunt through menus for the measurement. + +It earns Ocean's editorial/bold lean by spending all of that boldness on the **numerals themselves**, not on chrome — the visual analog of pure-Rust with no Python, no ONNX, no runtime bloat. Same "numbers never touch glass" law; glass lives only on the navigation layer. + +## Visual Identity + +### Palette + +Four elevation levels resolved per appearance: base(L0) → panel(L1) → raise(L2) → overlay. Dark is default + native habitat; light is a true warm-paper theme, not an inverted hack. Shadows appear **only** in light (single y2/blur8/6% step under cards); dark relies on borders alone. + +| Token | Dark hex | Light hex | Usage | +|---|---|---|---| +| Base canvas (L0) | `#0B0C0E` Ink | `#FBFBFA` Paper (warm) | Window canvas, deepest surface. | +| Panel (L1) | `#131519` Slate-900 | `#FFFFFF` Surface | Sidebar, cards, metric rail, table body. | +| Raise (L2) | `#1B1E24` Slate-800 | `#F2F1EE` | Hover, selected-row base, input wells. | +| Hairline | `#262A31` | `#E6E4E0` | 1px inset borders + dividers + chart grid. | +| Text hi (+ hero numerals) | `#ECEEF1` | `#16181C` | Primary text/numerals. | +| Text mid | `#9AA1AC` | `#5E636B` | Secondary labels, units, ticks. | +| Text dim | `#5B626D` | (derived) | Tertiary / placeholder / disabled / key-cap outline. | +| **Voltage** (accent) | `#3D7BFF` | `#2C5FE0` Voltage-deep | Focus ring, the one primary CTA/screen, live-metric line, ⌘K selection, selected-row left-border. Rationed. ~4.6:1 on each base. | +| Signal-pass | `#2FB079` | `#2FB079` | Correctness only: parity PASS, quant OK, converging caret. Paired with a glyph (✓ / ▼). | +| Signal-warn | `#D9A227` | `#D9A227` | Drift / PPL regression / OOM-risk / loss-rising caret. Paired with ▲. | +| Signal-fail | `#E5523C` | `#E5523C` | Parity FAIL, NaN loss, crash. Rare; paired with ✕. | + +Every signal color pairs with a redundant glyph for colorblind safety; signal hues shift lightness (not hue) to clear ~4.5:1 on both grounds. + +### Typography + +Three roles; the proportional-vs-monospace contrast **is** the identity (it signals "this region is a measurement"). + +- **DISPLAY = the numbers**, tabular-figure monospace (SF Mono on-system, JetBrains Mono bundled fallback for exact metrics; `.monospacedDigit` always). HERO 40pt/600 (loss, PPL, tok/s on Train + Quantize); SECTION 22pt/600 (metric tiles); INLINE 13pt/500 (table cells, config values). Hero gets **-0.01em tracking** with the unit as an 11pt mid-tone superscript-baseline label, so the number reads as authored even at rest (fixes the "console echo" risk). +- **BODY/UI = SF Pro Text** — 13pt regular for labels + prose, 11pt medium UPPERCASE micro-labels naming each metric (+0.06em: `LOSS · TOK/S · GRAD-NORM`). +- **MONO-SMALL = SF Mono 11.5pt** for config values, file paths, log console, JSONL preview cells (columns must align). + +Modular scale (1.25): **11 / 13 / 16 / 22 / 28 / 40.** No serif anywhere. + +### Spacing & Grid + +8pt base, 4pt half-step for dense rows. Three-pane shell on a fixed rhythm: **sidebar 232pt · detail flexes · right rail 320pt** (collapsible `⌘/`). Row height 28pt dense / 32pt comfortable (Settings toggle — the named mitigation for 13in density). Card padding 16pt; gutters 24pt; outer margin 32pt. Density-without-clutter is bought with whitespace **between** blocks, never within. Corner radius: 6pt controls, 10pt cards, 14pt ⌘K palette/modals. Min window 1080×720. + +### Materials & Motion + +Restraint-first, governed by the same law: **glass only on the nav layer.** Liquid Glass on exactly two surfaces — the floating toolbar and the ⌘K palette slab over a dimmed canvas. All content is solid fill. Motion is functional: 120ms focus-ring + row-selection, 180ms pane collapse, 200ms ⌘K slab. The **one signature motion** — the live hero numeral uses `.contentTransition(.numericText())` so a dropping loss ticks digit-by-digit (~90ms); the delta caret crossfades color/direction in the SAME beat, and the loss curve extends one accent segment in the SAME beat — **three surfaces, one pulse.** No bounce, no parallax. Reduce-Motion → instant swaps + crossfade. Reduce-Transparency → glass → solid material (free via system). + +## Component Language + +Spare, line-based, table-centric — eight primitives + two control specials. + +1. **COMMAND BAR** — the spine: a single ⌘K glass palette running everything (start training, quantize, switch model, open chat, jump to a run) with fuzzy match, recent actions, and inline **argument chips** (`train qwen3.5 r8` parses `r8` into an editable rank chip). +2. **METRIC TILE** — uppercase 11pt micro-label + 22–40pt tabular-mono number + delta caret (▼ pass / ▲ warn); the only place numbers go big. +3. **SPARK + FULL CHART** — Swift Charts `LineMark`, 1.5px accent stroke on transparent grid, hairline axes, no fills/legend chrome; hover scrubs a `RuleMark` crosshair with mono readout, freezing every metric tile to that step. +4. **DENSE DATA TABLE** — 28pt rows, mono value columns right-aligned, single accent left-border on selected row (no fill flood), sortable 11pt uppercase headers, zebra-free. +5. **CONFIG ROW** — label left, mono editable value right, inline validation dot; forms are flat lists, never boxed. +6. **KEY-CAP CHIP** — every actionable element shows its shortcut as a tiny outlined ⌘-cap (self-documenting keyboard map). +7. **STATUS PILL** — `PASS` / `RUNNING` / `FAILED` in semantic colors + glyph. +8. **CONTRAST PAIR** — left↔right split with a centered Δ chip for any before↔after; two values animate apart, Δ resolves pass/warn. Used for QuaRot and base↔adapter. + +Control specials: the **HOT-SWAP FADER** (the Chat adapter A/B; the one place a spring is allowed) and the **TRUTH TOGGLE** (`⌘.`) — dims all nav glass so only opaque data remains. Buttons: mostly tertiary (text + key-cap); exactly **one** `.glassProminent` accent button per screen. + +## Information Architecture + +Three-pane `NavigationSplitView`. + +- **LEFT SIDEBAR (232pt)** — flat, keyboard-navigable, no nested disclosure: a pinned top **RUN STATUS block** (live job: model · step · loss · tok/s + pause/stop, or `idle`), then `TRAIN ⌘1 · QUANTIZE ⌘2 · MODELS ⌘3 · CHAT ⌘4 · DATA ⌘5`, then a footer `RUNS ⌘R` + `Settings ⌘,`. +- **CENTER** — active screen. +- **RIGHT RAIL (320pt, `⌘/` collapse)** — context inspector. + +### Screens + +- **Runs / Home** — dense table of every run (status pill, model, kind LoRA/Q4/QuaRot, last-loss/PPL, duration, when) — the lab notebook; ⌘-click any row reopens its exact config. +- **Train ⌘1** — center = flat config list (model picker; an interactive **LAYER STRIP** rendering the 18 GatedDeltaNet + 6 GQA layers as selectable cells; rank, scale α, lr, steps, dataset); one accent `Start ⌘↵`. Right rail = live loss curve + metric tiles (loss/lr/grad-norm/tok-s/ETA) + scrolling log console. +- **Quantize ⌘2** — pick model + method segmented toggle Q4 / QuaRot (QuaRot framed as the premium moat); a CONTRAST PAIR before→after size (1.61GB → 405MB) animating apart with a "fold" wipe + Δ chip; progress strip with phase labels (rotate → pack → verify); a PPL/quality readout base→quant with a green/warn signal when a calib set is supplied. +- **Models ⌘3** — master table of `~/.lattice/models` + adapters (name, dtype, format, size, file count, status pill); right-rail inspector = full file manifest + actions Load / Hot-swap adapter / Reveal in Finder / Delete. Download/import is a ⌘K action. +- **Chat ⌘4** — streaming transcript; split-compare mode = base vs base+adapter in two lockstep-streaming columns; right rail = temp/top-p/max-tokens + the live HOT-SWAP FADER (`⌥A`). +- **Data ⌘5** — two-pane raw→`{prompt,completion}` builder: raw input left, JSONL preview table right + inline validator (row count, malformed flags, token-length histogram); export feeds the Train dataset picker. +- **Settings** — appearance (System/Light/Dark), row density, models dir, default hyperparams. + +## Signature Interactions + +1. **HOT-SWAP FADER, the moat made physical** — grounded in the real engine: `LiveModel` uses `arc_swap::ArcSwap` for lock-free atomic `swap()`. In Chat split-compare the right column carries a console fader. Slide BASE → BASE+LoRA-r8 and the engine swaps the adapter with no reload; the column flashes its accent left-border 120ms, a `0 ms reload` stamp confirms it, and the very next streamed token comes from the new adapter. The one place a spring (0.32) is allowed. +2. **THE NUMBER IS THE TRUTH, three surfaces one heartbeat** — on every `TrainingCallback.on_batch_end` the hero loss numeral ticks digit-by-digit (~90ms), the delta caret flips pass/warn in the SAME 90ms, and the loss curve extends one accent segment in the SAME beat. "Is it converging?" is answered pre-attentively without reading an axis. Scrub the crosshair and all three plus every metric tile freeze to that step. +3. **COMMAND BAR AS THE WHOLE APP** — ⌘K opens a glass palette where `train qwen3.5 r8` or `quantize quarot` parse into argument chips; you configure and launch a full run without touching a form. A persistent ⌘-cap legend means power users never reach for the mouse; every action also has a visible affordance. Bonus: `⌘.` Truth Toggle dims nav glass so only the opaque data canvas remains. + +## ASCII Mockups + +``` +SHELL — Train (dark) +┌────────────┬───────────────────────────────┬─────────────────────────┐ +│ ● RUN │ Train · Qwen3.5-0.8B │ LIVE METRICS ⌘/ │ +│ qwen3.5 │ ─────────────────────────── │ LOSS │ +│ step 412 │ model Qwen3.5-0.8B ▾ │ 0.612 ▼ step 412/1k │ +│ loss 0.612 │ layers [GDN×18][GQA×6] ◧ │ ╭───────────────────╮ │ +│ 184 tok/s │ rank 8 scale α 16 │ │ ╲__ │ │ +│ ⏸ ⏹ │ lr 2e-4 steps 1000 │ │ ╲────╲___ │ │ +│────────────│ dataset claude_set.jsonl │ ╰───────────────────╯ │ +│ TRAIN ⌘1│ │ lr 2e-4 grad 0.91 │ +│ QUANTIZE ⌘2│ ┌─────────────────────────┐ │ tok/s 184 eta 2m04s │ +│ MODELS ⌘3│ │ Start run ⌘↵ │ │ ── log ────────────── │ +│ CHAT ⌘4│ └─────────────────────────┘ │ step 412 loss 0.612 │ +│ DATA ⌘5│ │ step 411 loss 0.618 │ +│────────────│ │ ckpt saved @ 400 │ +│ RUNS ⌘R │ │ │ +└────────────┴───────────────────────────────┴─────────────────────────┘ + ⌘K command · ⌘/ rail · ⌘. truth · ↑↓ nav · ⏎ open + +CHAT split-compare — hot-swap fader (base vs +adapter) +┌──────────────────────────────┬──────────────────────────────┐ +│ BASE Qwen3.5-0.8B │ +ADAPTER claude-r8 0ms swap │ +│ The capital of France is │ The capital of France is │ +│ Paris, a city known for▏ │ Paris — bonjour. As Claude▏ │ +├──────────────────────────────┴──────────────────────────────┤ +│ BASE ◁───────●────▷ +LoRA temp 0.7 top-p 0.9 ⌘↵ send ⌥A │ +└──────────────────────────────────────────────────────────────┘ +``` + +## Per-Feature Surfacing + +- **1. LoRA Training → Train ⌘1.** Flat config list (no nested cards): model picker, interactive LAYER STRIP (real 18 GatedDeltaNet + 6 GQA cells), rank, scale α, lr, steps, dataset. One rationed accent `Start ⌘↵`. Right rail streams the live loss curve + metric tiles (fed by `TrainingCallback.on_batch_end` / `on_epoch_end`) + a scrolling stdout log; `on_checkpoint` drops a "ckpt saved" line. Pause/stop in the pinned sidebar RUN block. Also launchable via ⌘K with argument chips. +- **2. Quantization → Quantize ⌘2.** Method as a segmented toggle Q4 vs QuaRot (QuaRot framed as the premium moat). A CONTRAST PAIR shows before→after size animating apart with a single "fold" wipe + Δ chip (−75%); a progress strip names the phase (rotate → pack → verify). When a calibration set is supplied a PPL/quality readout shows base→quant delta (15.86 → 15.92) with a pass/warn signal so quality is honest, never hidden. Mirrors the real `-q4` and `-q4-quarot` artifacts on disk. +- **3. Model Management → Models ⌘3.** Dense master table of `~/.lattice/models` AND adapters in one view: name, dtype, format, size, file count, status pill; tabular-mono columns keep sizes/shapes aligned. Right-rail inspector shows the full file manifest (18-GDN/6-GQA split from `config.json`) plus Load / Hot-swap adapter / Reveal in Finder / Delete. Download/import is a ⌘K action. QuaRot weights get a `rotated` badge. +- **4. Chat / Sample Testing → Chat ⌘4.** Signature split-compare: base vs base+adapter in two lockstep-streaming columns driven off two AsyncStreams throttled to a shared clock. Sampling controls (temp/top-p/max-tokens) in the right rail; the HOT-SWAP FADER (`⌥A`) does a live no-reload swap via the engine's `arc_swap` LiveModel with a `0 ms reload` stamp. Single-column mode for plain testing. +- **5. Dataset Prep → Data ⌘5.** Two-pane builder: raw input (paste / file) left, the derived `{prompt, completion}` set as a JSONL preview table right. Inline validator reports row count, malformed-row flags, and a token-length histogram (using lattice's own tokenizer so counts match training). Export writes straight into the Train dataset picker, closing the loop. + +--- + +# Head-to-Head + +| Criterion | Lattice Instrument | Lattice / Console | Edge | +|---|---|---|---| +| **Sleek / modern** | 9 — full instrument metaphor, recessed wells, oscilloscope | 9 — ruthless Linear-class restraint | tie | +| **Brand fit** | 10 — most literal "measure first" translation | 9 — same thesis, leans on a known aesthetic | Instrument | +| **Feature surfacing** | 9 — dedicated comparator/quant-table screens, denser readouts | 8 — slightly thinner; config lists + right-rail metrics | Instrument | +| **Feasibility** | 9 — all native APIs; density redraw is the only watch-item | 10 — least surface area, glass on nav only | Console | +| **Distinctiveness** | 8 — instrument/Bloomberg metaphor is on-brand but not category-novel | 7 — risks "another dark dev tool" without an exceptional type pass | Instrument | +| **Density posture** | High by default (with a comfortable toggle) | Lean by default (Cmd-K hides forms) | depends on Ocean | + +**When each wins.** Instrument wins when the user lives *inside* a run — watching loss, scrubbing the curve, reading six wells at once — and wants the screen to be a panel of live truth. It is the richer demo and the more defensible brand statement. Console wins on pure speed and minimal build risk: a power user who configures by keystroke and never wants to see a form, on the cheapest path to ship. Console's distinctiveness is its only soft spot — near-monochrome reads generic unless the hero numerals are genuinely authored. + +The crucial point: **they are not opposites.** Console's Cmd-K command spine is the single best IA primitive in the whole exploration, and it slots cleanly into Instrument as primitive #11. The real fork is *density default*, not *which thesis*. + +--- + +# Recommendation + +**Build Lattice Instrument, with Console's ⌘K command spine grafted in from day one.** + +Why: +1. **Brand fit is the tiebreaker and Instrument wins it 10 vs 9.** lattice's identity is literally "the number is the truth," and Instrument is the most complete translation of that into UI — readout wells, the oscilloscope strip chart with scrub-to-freeze, GATE PILL verdicts. It sells the *moat* (QuaRot ContrastPair, hot-swap fader) rather than raw speed, exactly as the README demands. +2. **The feasibility gap is small and closeable.** Instrument is a 9 to Console's 10. Its only real risk is 60fps redraw of the live curve under a fast step/token stream — mitigated by buffering points and throttling chart commits to ~20Hz, the same technique both directions already specify. Everything else maps to native APIs (`.monospacedDigit`, `.contentTransition(.numericText())`, Swift Charts `LineMark`+`RuleMark`+`chartOverlay`, `NavigationSplitView`). Verified buildable on the installed Swift 6.3.2 / macOS 26 toolchain. +3. **Grafting closes the only thing Console wins on.** Adding the ⌘K spine to Instrument gives power users the keyboard-first launch path without sacrificing the dense readout panels. The comfortable-row toggle (32px) handles the density-too-cold risk Instrument self-flags. We get Console's speed *and* Instrument's brand depth. +4. **Honors Ocean's editorial/bold lean correctly.** Both reject the literal display-serif reading (a streaming loss in a 96pt serif visibly jitters — it contradicts "the truth does not shimmer"). Instrument spends all its boldness on a 56pt tabular-mono hero numeral: editorial confidence without the jitter risk. + +The override on Ocean's "editorial/bold" lean is deliberate and stated: editorial is a *typographic posture*, not a literal magazine. We deliver it as **monospace-numeral boldness**, which is both more on-brand for an instrument and lower-risk to build. If Ocean specifically wants the magazine-cover feel, that is the genuine fork below. + +--- + +# What Gets Built First + +`apps/macos/` is greenfield. Sequence for a SwiftUI macOS app driven by lattice CLI subprocesses: + +1. **SwiftPM target + shell.** Create `apps/macos/` SwiftPM executable target that `swift build` compiles. Stand up the `NavigationSplitView` three-pane shell, the left rail with static nav, and the asset-catalog semantic colors (both appearances, all elevation tokens). Goal: an empty but adaptive shell that launches. +2. **The opaque-panel + numeral kit.** Build the shared primitives that everything depends on: `OpaquePanel` container (enforces "numbers never touch glass"), `ReadoutWell`, `HeroNumber` (`.contentTransition(.numericText())`), `DataTable`, `GatePill`, `KeyCapChip`. Bundle JetBrains Mono. This is the visual identity made real. +3. **CLI bridge + event store.** A subprocess runner that spawns the lattice `tune` / `quantize` / `chat` bins and parses their line-delimited JSON `stdout` into an `@Observable` run store. Map JSON 1:1 onto the real serde structs (`EpochMetrics`, `AdaptStepResult`, `TrainingMetrics`). **Critical:** own the run `@State` *above* the view and key by run-id so a re-render never resets a live run. +4. **01 MODELS (read-only first).** The simplest real-data screen: list `~/.lattice/models`, parse `config.json` into readout wells. Proves the bridge and the table primitive against real on-disk artifacts before any long-running job. +5. **02 TRAIN (the flagship loop).** Wire PARAM ROWS → CLI launch → live HERO NUMBER + STRIP CHART + READOUT WELLS off the event store. Add scrub-to-freeze. This is the demo centerpiece; get the ~20Hz throttle right here. +6. **⌘K command spine.** The grafted Console primitive: a glass palette that parses `train qwen3.5 r8` into argument chips and fires step 5's launch path. Add the ⌘1–6 key-cap legend. +7. **03 QUANTIZE.** CONTRAST PAIR + MASS BARS + VERDICT GATE PILL over the quantize bin's progress stream. PPL hard-labeled `est.` unless a calib set is supplied. +8. **04 CHAT + HOT-SWAP FADER.** Dual-stream A|B columns; bind the fader to the engine's `arc_swap` `LiveModel.swap()` over the bridge. **Verify the FFI/CLI event surface exposes a structured no-reload swap before committing the fader demo** — otherwise it degrades to a reload and the wow is lost. +9. **05 DATA + 06 RUNS.** Dataset builder (export → Train dataset field) and the runs archive (reopen config, frozen strip chart). These close the loops and round out the five features. + +**Gate at each step:** `swift build` green, and from step 4 onward, real data on screen (Π_TBV — no mock streams claimed as live). + +--- + +## Risks Carried Forward (both directions) + +- **Live-chart perf** — throttle chart commits ~20Hz, numeral tick ~8Hz, downsample history; decouple chart buffer from FFI callback rate. +- **SwiftUI state identity** — own run `@State` above the view, key by run-id, or a re-render wipes a live run. +- **Glass discipline erosion** — enforce via a single `OpaquePanel` container; glass banned below the chrome layer; honor `accessibilityReduceTransparency`. +- **One-accent discipline** — strict semantic token set (teal/voltage = live/CTA, amber = regress, crimson = fail only) so no stray colors creep in. +- **Estimated-PPL honesty** — hard-label `est.` and never draw the ghost base-PPL line as if measured. +- **Greenfield + bridge** — verify the engine exposes structured-progress quantize and a no-reload `set_lora`/`swap` over the bridge before committing the two signature WOWs. diff --git a/apps/macos/DISTRIBUTION.md b/apps/macos/DISTRIBUTION.md new file mode 100644 index 0000000000..5ccf1471b8 --- /dev/null +++ b/apps/macos/DISTRIBUTION.md @@ -0,0 +1,116 @@ +# Lattice Studio — Distribution Guide + +## Building a distributable .app + +Run the packaging script from the repo root: + +```bash +cd lattice/ +./apps/macos/scripts/package-app.sh +``` + +This will: +1. Run `swift build -c release` to produce the Swift instrument panel. +2. Build all 6 Rust engine binaries with `cargo build --release`. +3. Assemble `apps/macos/dist/LatticeStudio.app` with the engine binaries inside + `Contents/Resources/bin/` so the app is fully self-contained. +4. Write `Contents/Info.plist` with bundle ID `ai.khive.lattice.studio`. +5. Ad-hoc codesign the bundle. +6. Produce `apps/macos/dist/LatticeStudio.dmg` and `LatticeStudio.zip`. + +Options: + +```bash +# Skip Swift rebuild (use existing .build/release/LatticeStudio) +./apps/macos/scripts/package-app.sh --skip-build + +# Skip Cargo rebuild (use existing target/release/ binaries) +./apps/macos/scripts/package-app.sh --skip-cargo + +# Custom output directory +./apps/macos/scripts/package-app.sh --out /path/to/out +``` + +## Installing + +Drag `LatticeStudio.app` from the DMG to `/Applications`. + +Requires macOS 14.0 (Sonoma) or later. The app is self-contained — no source +checkout, no Cargo, no Rust toolchain needed on the recipient machine. + +## Gatekeeper caveat (ad-hoc signing) + +The app is ad-hoc signed, which means macOS Gatekeeper will quarantine it on +first launch because it lacks a Developer ID certificate. Recipients must: + +**Option 1 — right-click → Open:** +1. Right-click (or Ctrl-click) `LatticeStudio.app` in Finder. +2. Choose "Open" from the context menu. +3. Click "Open" in the dialog. macOS remembers the exception. + +**Option 2 — remove the quarantine xattr:** +```bash +xattr -dr com.apple.quarantine /Applications/LatticeStudio.app +``` + +This is a one-time step. The app will open normally on subsequent launches. + +## Upgrading to Developer ID signing (for App Store or notarized distribution) + +When Ocean has a $99/year Apple Developer Program membership, replace the +ad-hoc step in `package-app.sh` with a full Developer ID workflow: + +```bash +# 1. Replace the ad-hoc sign step with a Developer ID certificate +DEVELOPER_ID="Developer ID Application: Haiyang Li (TEAMID)" +codesign --force --deep --options runtime \ + --entitlements apps/macos/LatticeStudio.entitlements \ + --sign "$DEVELOPER_ID" \ + "$BUNDLE" + +# 2. Create a notarization credentials profile (one-time) +xcrun notarytool store-credentials "lattice-notary" \ + --apple-id "lhydyxzh@gmail.com" \ + --team-id "TEAMID" \ + --password "@keychain:AC_PASSWORD" + +# 3. Submit for notarization and wait +xcrun notarytool submit "$DMG" \ + --keychain-profile "lattice-notary" \ + --wait + +# 4. Staple the notarization ticket to the DMG +xcrun stapler staple "$DMG" +``` + +After notarization, Gatekeeper accepts the app on any Mac without the +right-click-Open workaround. Distribution via direct download or any store +works without restrictions. + +An entitlements file (`LatticeStudio.entitlements`) must grant at minimum: +```xml +com.apple.security.cs.allow-jit +com.apple.security.cs.allow-unsigned-executable-memory +``` + +The engine binaries (Rust) do not use JIT, so hardened runtime is compatible. + +## Regenerating the app icon + +```bash +uv run apps/macos/scripts/generate-icon.py +``` + +Outputs `apps/macos/Resources/LatticeStudio.icns`. The packaging script picks +it up automatically, or re-runs the generator if the file is missing. + +## Binary resolution order (inside the .app) + +The Swift bridge resolves engine binaries in this order: +1. `Contents/Resources/bin/` — bundled binary (used from /Applications). +2. `LATTICE_BIN_DIR` env var — dev override. +3. `/target/release/` — running from source checkout. +4. `cargo run --release` — compile-on-demand fallback. + +This means the same app binary works both installed from the DMG and invoked +during development inside the source tree. diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift new file mode 100644 index 0000000000..9f2fbeed7b --- /dev/null +++ b/apps/macos/Package.swift @@ -0,0 +1,25 @@ +// swift-tools-version: 6.0 +import PackageDescription + +// Lattice Studio — the instrument panel for the pure-Rust lattice engine. +// Driven entirely via lattice CLI subprocesses (line-delimited `@@lattice {json}` +// event protocol). No in-process ML, no Python, no ONNX — same posture as the engine. +let package = Package( + name: "LatticeStudio", + platforms: [ + .macOS(.v14) + ], + products: [ + .executable(name: "LatticeStudio", targets: ["LatticeStudio"]) + ], + dependencies: [], + targets: [ + .executableTarget( + name: "LatticeStudio", + path: "Sources/LatticeStudio", + swiftSettings: [ + .swiftLanguageMode(.v5) + ] + ) + ] +) diff --git a/apps/macos/Resources/LatticeStudio.icns b/apps/macos/Resources/LatticeStudio.icns new file mode 100644 index 0000000000000000000000000000000000000000..a560c2d9a61288ecc5ddc657ef2155d7685c7da8 GIT binary patch literal 72545 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 literal 0 HcmV?d00001 diff --git a/apps/macos/Sources/LatticeStudio/App/LatticeStudioApp.swift b/apps/macos/Sources/LatticeStudio/App/LatticeStudioApp.swift new file mode 100644 index 0000000000..18af2971a2 --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/App/LatticeStudioApp.swift @@ -0,0 +1,43 @@ +import SwiftUI +import AppKit + +final class AppDelegate: NSObject, NSApplicationDelegate { + // Weak back-reference injected by LatticeStudioApp so the delegate can reach the store. + // (NSApplicationDelegate is an ObjC protocol; we cannot pass the store in an init.) + weak var store: AppStore? + + func applicationDidFinishLaunching(_ notification: Notification) { + NSApp.setActivationPolicy(.regular) + NSApp.activate(ignoringOtherApps: true) + } + + /// Best-effort clean-quit: stop the active run so its trainer process exits normally + /// and its PID is deregistered before the app terminates. The startup reaper handles + /// the crash / force-quit case where this callback never fires. + func applicationWillTerminate(_ notification: Notification) { + store?.stopRun() + } + + func applicationShouldTerminateAfterLastWindowClosed(_ sender: NSApplication) -> Bool { true } +} + +@main +struct LatticeStudioApp: App { + @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate + @State private var store = AppStore() + + var body: some Scene { + WindowGroup("Lattice", id: "main") { + ContentView(store: store) + .frame(minWidth: 1120, minHeight: 720) + .onAppear { + store.onAppear() + // Give the delegate a weak reference so applicationWillTerminate can + // stop the active run for a clean-quit. The reaper is the crash backstop. + appDelegate.store = store + } + } + .windowStyle(.titleBar) + .windowToolbarStyle(.unified(showsTitle: false)) + } +} diff --git a/apps/macos/Sources/LatticeStudio/Bridge/Drivers.swift b/apps/macos/Sources/LatticeStudio/Bridge/Drivers.swift new file mode 100644 index 0000000000..8f42dd2c97 --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Bridge/Drivers.swift @@ -0,0 +1,521 @@ +import Foundation + +// MARK: - Typed driver configs and AppStore convenience methods. +// +// Each config struct maps 1-to-1 with a Lattice CLI binary's argument surface. +// Flag names are taken verbatim from the CLI contract (2026-06-20). +// +// Do NOT import AppStore.swift here — the extension lives in the same module. +// Do NOT edit LatticeBridge.swift or AppStore.swift directly. + +// MARK: - TrainConfig + +/// Configuration for `train_grad_full` (multi-layer exact-gradient LoRA trainer). +/// +/// All defaults match the binary's own defaults so omitting a field is equivalent +/// to not passing the flag. `--json` is always appended to activate the structured +/// `@@lattice` event protocol (verified emitted by `train_grad_full --json`, 2026-06-21). +struct TrainConfig { + /// Full path to the BF16 model directory (must contain `model.safetensors` / index). + var modelDir: URL + /// Directory containing `train.jsonl` and optionally `valid.jsonl`. + var dataDir: URL + /// First materialised (trained) layer index. Default 19 (last 5 of 24 for Qwen3.5-0.8B). + var firstLayer: Int = 19 + /// Adam optimizer steps. Default 25. + var steps: Int = 25 + /// Learning rate. Default 1e-3. + var lr: Double = 1e-3 + /// LoRA rank. Default 8. + var rank: Int = 8 + /// LoRA alpha; effective scale = alpha/rank. Default 16. + var alpha: Double = 16.0 + /// Max tokens per sample. Default 64. + var seqLen: Int = 64 + /// Training sample cap. Default 3. + var maxTrain: Int = 3 + /// Held-out validation samples (0 = disabled). Default 16. + var maxValid: Int = 16 + /// Print NLL every N steps. Default 5. + var logEvery: Int = 5 + /// When non-nil, the `--save ` flag is passed (Rust side in progress). + var savePath: URL? = nil + + /// The exact argument array passed to the subprocess. + var args: [String] { + var a: [String] = [ + "--model-dir", modelDir.path, + "--data-dir", dataDir.path, + "--first-layer", String(firstLayer), + "--steps", String(steps), + "--lr", String(lr), + "--rank", String(rank), + "--alpha", String(alpha), + "--seq-len", String(seqLen), + "--max-train", String(maxTrain), + "--max-valid", String(maxValid), + "--log-every", String(logEvery), + "--json", // structured @@lattice event protocol (verified train_grad_full) + ] + if let save = savePath { + a += ["--save", save.path] + } + return a + } +} + +// MARK: - QuantConfig + +/// Quantization method selector. +enum QuantMethod { + case q4 // `quantize_q4` — plain Q4_0, no rotation + case quarot // `quantize_quarot` — Hadamard-rotated Q4_0; requires a seed +} + +/// Configuration for `quantize_q4` or `quantize_quarot`. +/// +/// Note: both quantizers write all progress to **stderr**, which RunHandle already +/// merges into the event stream — no special handling needed in the driver. +struct QuantConfig { + /// Full path to the BF16 input model directory. + var modelDir: URL + /// Output directory (created by the binary if absent). + var outputDir: URL + /// Which quantization method to run. + var method: QuantMethod + /// RNG seed for the Hadamard rotation (quarot only). + /// If nil when method == .quarot, defaults to 0xC0FFEE. + /// Passed as a decimal string; the binary also accepts `0x...` hex. + var seed: UInt64? = nil + /// Skip disk writes (full pipeline still runs). + var dryRun: Bool = false + + /// The LatticeBinary enum case to use for this config. + var binary: LatticeBinary { + switch method { + case .q4: return .quantizeQ4 + case .quarot: return .quantizeQuaRot + } + } + + /// The exact argument array passed to the subprocess. + var args: [String] { + var a: [String] = [ + "--model-dir", modelDir.path, + "--output-dir", outputDir.path, + ] + if method == .quarot { + let s = seed ?? 0xC0FFEE + a += ["--seed", String(s)] + } + if dryRun { a.append("--dry-run") } + return a + } +} + +// MARK: - GenConfig + +/// Configuration for `generate_lora` (CPU BF16) or `chat_metal` (GPU Metal bf16/q4). +/// +/// Either `modelDir` or `model` must be provided; `modelDir` takes priority when both +/// are set (the binary accepts both flags and uses whichever it sees). +/// +/// Both binaries emit identical `@@lattice gen_token` streaming events, so the app +/// parser needs no changes when switching between CPU and GPU paths. +/// +/// Honest-label contract: the *caller* (ChatScreen.send) sets `useGPU` to select the +/// binary and also embeds the label string in the bubble at send time. There is no path +/// where a CPU run appears labelled as GPU. +struct GenConfig { + /// Full path to the model directory. Takes priority over `model` when set. + var modelDir: URL? = nil + /// Model name under `$LATTICE_MODEL_CACHE / $HOME/.lattice/models/`. Used when + /// `modelDir` is nil. + var model: String? = nil + /// Tokenizer directory override. Required for Q4 models (no embedded tokenizer.json). + /// When set, `--tokenizer-dir` is appended to the arg array. + /// Ignored by `generate_lora` (unknown flag, silently dropped). + var tokenizerDir: URL? = nil + /// Path to a `.safetensors` LoRA adapter file. Nil = run base model only. + var adapterPath: URL? = nil + /// Text prompt fed to the model. + var prompt: String + /// Maximum tokens to generate. Default 64. + var maxTokens: Int = 64 + /// RNG seed for deterministic sampling. Nil = non-deterministic. + var seed: UInt64? = nil + /// Sampling temperature. Default 0.7. + var temperature: Double = 0.7 + /// Top-k nucleus cutoff. Default 50. Passed as `--top-k` to both binaries. + /// (verified: generate_lora + chat_metal both accept this flag, 2026-06-22). + var topK: Int = 50 + /// Top-p nucleus cutoff. Default 0.9. Passed as `--top-p` to both binaries. + /// (verified: generate_lora + chat_metal both accept this flag, 2026-06-22). + var topP: Double = 0.9 + /// Repetition penalty. Default 1.1. Passed as `--repetition-penalty` to both binaries. + /// (verified: generate_lora + chat_metal both accept this flag, 2026-06-22). + var repetitionPenalty: Double = 1.1 + /// When true, dispatch to `chat_metal` (GPU Metal) instead of `generate_lora` (CPU). + /// Setting this to true on a non-bf16 model is valid: chat_metal auto-detects Q4. + var useGPU: Bool = false + + /// The exact argument array passed to the subprocess. + var args: [String] { + var a: [String] = [] + if let dir = modelDir { + a += ["--model-dir", dir.path] + } else if let name = model { + a += ["--model", name] + } + if let tokDir = tokenizerDir { + a += ["--tokenizer-dir", tokDir.path] + } + if let adapter = adapterPath { + a += ["--lora", adapter.path] + } + a += [ + "--prompt", prompt, + "--max-tokens", String(maxTokens), + "--temperature", String(temperature), + "--top-k", String(topK), + "--top-p", String(topP), + "--repetition-penalty", String(repetitionPenalty), + "--json", // streaming gen_token event protocol; both binaries honour this flag + ] + if let s = seed { + a += ["--seed", String(s)] + } + return a + } +} + +// MARK: - EvalConfig + +/// Configuration for `eval_perplexity` (strided sliding-window perplexity, ADR-044). +/// +/// Three measurement modes — pass the corresponding dir flag to select: +/// • CPU BF16: set `modelDir` (embeds tokenizer; `tokenizerDir` not needed) +/// • Metal Q4: set `q4Dir` + `tokenizerDir` +/// • QuaRot Q4: set `quarotDir` + optionally `q4Dir` for the delta comparison +/// +/// `--json` is always appended to activate the structured `@@lattice perplexity` event +/// protocol (shipped 2026-06-21). `--label` is included only when `label` is set +/// (disambiguates rows in multi-pass runs). Adapter perplexity is NOT supported: the CPU +/// forward path has no LoRA hook, so adapter A/B lives in Chat via generate_lora instead. +/// +/// Flag names are taken verbatim from `crates/inference/src/bin/eval_perplexity.rs` +/// arg-parser (verified 2026-06-21). +struct EvalConfig { + /// CPU mode: directory with `config.json` + safetensors + `tokenizer.json`. + var modelDir: URL? = nil + /// Metal Q4 mode: `quantize_q4` output directory (unrotated 4-bit weights). + var q4Dir: URL? = nil + /// Metal QuaRot Q4 directory; when combined with `q4Dir` triggers the delta comparison. + var quarotDir: URL? = nil + /// Tokenizer directory (Metal modes); holds `tokenizer.json` from the source checkpoint. + var tokenizerDir: URL? = nil + /// UTF-8 text corpus file to score (required). + var corpusFile: URL + /// Cap total tokens after tokenization (nil = no cap — binary default). + var maxTokens: Int? = nil + /// Human label for this measurement row (e.g. "bf16", "q4", "adapter"). + var label: String? = nil + + /// The exact argument array passed to the subprocess. + var args: [String] { + var a: [String] = [] + if let dir = modelDir { a += ["--model-dir", dir.path] } + if let dir = q4Dir { a += ["--q4-dir", dir.path] } + if let dir = quarotDir { a += ["--quarot-q4-dir", dir.path] } + if let dir = tokenizerDir { a += ["--tokenizer-dir", dir.path] } + a += ["--corpus-file", corpusFile.path] + if let mt = maxTokens { a += ["--max-tokens", String(mt)] } + if let lbl = label { a += ["--label", lbl] } + a.append("--json") // structured @@lattice perplexity event protocol + return a + } +} + +// MARK: - EmbedConfig + +/// Configuration for `embed` (batch text embedding with cosine similarity report). +/// +/// The binary accepts a model identifier and one or more `--text` values. +/// `--json` is always appended to emit the structured `@@lattice embed_done` event. +/// +/// `model` is the only positional parameter; texts are each preceded by `--text`. +struct EmbedConfig { + /// Model identifier — a short name resolvable by the embed binary (e.g. "bge-small-en-v1.5"). + var model: String + /// Texts to embed. Each becomes one `--text ` flag pair. + var texts: [String] + + /// The exact argument array passed to the subprocess. + var args: [String] { + var a: [String] = ["--model", model] + for text in texts { + a += ["--text", text] + } + a.append("--json") // structured @@lattice embed_done event protocol + return a + } +} + +// MARK: - DownloadConfig + +/// Configuration for `embed --model --download-only --json`. +/// +/// Verified contract: the embed binary emits exactly one `@@lattice download_done` event +/// on stdout, then exits 0 (success) or 1 (failure). No `--text` is needed. +struct DownloadConfig { + var canonicalName: String + + var args: [String] { + ["--model", canonicalName, "--download-only", "--json"] + } +} + +// MARK: - AppStore typed convenience methods + +extension AppStore { + + /// Launch `train_grad_full` with the given config. + /// + /// The returned `LiveRun` is already wired into `self.liveRun` and will receive + /// events via `consume(_:into:)`. + @discardableResult + @MainActor + func startTrain(_ config: TrainConfig) -> LiveRun { + let modelName = config.modelDir.lastPathComponent + return launch( + .trainGradFull, + args: config.args, + kind: .train, + model: modelName, + totalSteps: config.steps + ) + } + + /// Launch `quantize_q4` or `quantize_quarot` depending on `config.method`. + @discardableResult + @MainActor + func startQuantize(_ config: QuantConfig) -> LiveRun { + resetQuantAccumulator() + let modelName = config.modelDir.lastPathComponent + let kind: RunKind = config.method == .q4 ? .quantizeQ4 : .quantizeQuaRot + return launch( + config.binary, + args: config.args, + kind: kind, + model: modelName, + totalSteps: nil + ) + } + + /// Launch `eval_perplexity` with the given config. + /// + /// The returned `LiveRun` accumulates `perplexity` events in `run.perplexities`. + /// A single run may emit multiple rows (one per label). Use `run.onComplete` to + /// chain a follow-up run in A/B sequences (e.g. base eval → adapter eval). + @discardableResult + @MainActor + func runEval(_ config: EvalConfig) -> LiveRun { + // Derive a display name: prefer explicit model/q4/quarot dir name, then label. + let modelName: String + if let dir = config.modelDir { + modelName = dir.lastPathComponent + } else if let dir = config.q4Dir { + modelName = dir.lastPathComponent + } else if let dir = config.quarotDir { + modelName = dir.lastPathComponent + } else { + modelName = config.label ?? "eval" + } + return launch( + .evalPerplexity, + args: config.args, + kind: .eval, + model: modelName, + totalSteps: nil + ) + } + + /// Launch `embed` with the given config. + /// + /// The returned `LiveRun` receives a single `embed_done` event stored in `run.embed`. + @discardableResult + @MainActor + func runEmbed(_ config: EmbedConfig) -> LiveRun { + return launch( + .embed, + args: config.args, + kind: .embed, + model: config.model, + totalSteps: nil + ) + } + + /// Download an embedding model via `embed --model --download-only --json`. + /// + /// Spawns a dedicated RunHandle (separate from `liveRun`) so downloads do not evict + /// a running training/eval job. State changes (downloadingModels, downloadErrors) are + /// always published on the main actor. On success, calls refreshModels() so the new + /// model appears in the table and the "Get Models" sheet flips the row to INSTALLED. + @MainActor + func downloadModel(canonicalName: String) { + downloadErrors.removeValue(forKey: canonicalName) + downloadingModels.insert(canonicalName) + + guard let spec = LatticeBridge.launchSpec(.embed, args: DownloadConfig(canonicalName: canonicalName).args) else { + downloadingModels.remove(canonicalName) + downloadErrors[canonicalName] = "embed binary not found — run `make build` first" + return + } + + let h = RunHandle() + let hid = ObjectIdentifier(h) + var sawDone = false + + h.onEvent = { [weak self] ev in + guard let self = self else { return } + if case .downloadDone(let d) = ev { + sawDone = true + self.downloadingModels.remove(canonicalName) + if d.ok { + self.refreshModels() + } else { + self.downloadErrors[canonicalName] = d.error ?? "download failed (no error message)" + } + } + } + h.onExit = { [weak self] code in + guard let self = self else { return } + self._downloadHandles.removeValue(forKey: hid) + // Guard against a binary that exits without emitting download_done. + if !sawDone { + self.downloadingModels.remove(canonicalName) + if code != 0 { + self.downloadErrors[canonicalName] = "embed exited with code \(code)" + } else { + self.refreshModels() + } + } + } + + do { + try h.start(spec) + // Stash after successful start so the handle lives until onExit fires. + _downloadHandles[hid] = h + } catch { + downloadingModels.remove(canonicalName) + downloadErrors[canonicalName] = "launch failed: \(error.localizedDescription)" + } + } + + /// Import a model folder from disk into the model cache. + /// + /// Validates that the chosen folder contains `config.json` AND at least one + /// `.safetensors` file before touching the cache. Copies off the main thread; + /// publishes state on the main actor. Calls refreshModels() on success. + @MainActor + func importModel(from url: URL) { + let folderName = url.lastPathComponent + importError = nil + importingModel = folderName + + Task.detached(priority: .userInitiated) { + let fm = FileManager.default + + // Validate: must have config.json + let configURL = url.appendingPathComponent("config.json") + guard fm.fileExists(atPath: configURL.path) else { + await MainActor.run { + self.importingModel = "" + self.importError = "'\(folderName)' has no config.json — not a model directory" + } + return + } + + // Validate: must have at least one .safetensors file + let children = (try? fm.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles])) ?? [] + let hasSafetensors = children.contains { $0.pathExtension == "safetensors" } + guard hasSafetensors else { + await MainActor.run { + self.importingModel = "" + self.importError = "'\(folderName)' has no .safetensors files — not a model directory" + } + return + } + + let dest = LatticeBridge.modelCacheDir.appendingPathComponent(folderName, isDirectory: true) + + // Refuse to overwrite an existing model to prevent silent data loss. + if fm.fileExists(atPath: dest.path) { + await MainActor.run { + self.importingModel = "" + self.importError = "'\(folderName)' already exists in the model cache — remove it first if you want to replace it" + } + return + } + + // Ensure the model cache directory exists. + do { + try fm.createDirectory(at: LatticeBridge.modelCacheDir, withIntermediateDirectories: true) + } catch { + await MainActor.run { + self.importingModel = "" + self.importError = "could not create model cache directory: \(error.localizedDescription)" + } + return + } + + // Copy to a hidden staging sibling on the same volume, then atomically rename + // into place. A crash mid-copy leaves only `.importing-` (skipped by + // discoverModels via skipsHiddenFiles), never a half-written model at `dest`. + let staging = LatticeBridge.modelCacheDir.appendingPathComponent(".importing-\(folderName)", isDirectory: true) + try? fm.removeItem(at: staging) + do { + try fm.copyItem(at: url, to: staging) + try fm.moveItem(at: staging, to: dest) + await MainActor.run { + self.importingModel = "" + self.refreshModels() + } + } catch { + try? fm.removeItem(at: staging) + await MainActor.run { + self.importingModel = "" + self.importError = "copy failed: \(error.localizedDescription)" + } + } + } + } + + /// Launch `generate_lora` (CPU BF16) or `chat_metal` (GPU Metal), per `config.useGPU`. + /// + /// Both binaries emit identical `@@lattice gen_token` streaming events. The caller + /// (ChatScreen.send) must embed the honest hardware label ("GPU Metal" / "CPU bf16") in + /// the turn bubble AT SEND TIME — this method never inspects or fabricates that label. + @discardableResult + @MainActor + func runGenerate(_ config: GenConfig) -> LiveRun { + // Derive a display name: prefer the explicit dir/model name, fall back to prompt prefix. + let modelName: String + if let dir = config.modelDir { + modelName = dir.lastPathComponent + } else if let name = config.model { + modelName = name + } else { + let prefix = config.prompt.prefix(24) + modelName = prefix.isEmpty ? "generate_lora" : "\(prefix)…" + } + let binary: LatticeBinary = config.useGPU ? .chatMetal : .generateLora + return launch( + binary, + args: config.args, + kind: .chat, + model: modelName, + totalSteps: nil + ) + } +} diff --git a/apps/macos/Sources/LatticeStudio/Bridge/LatticeBridge.swift b/apps/macos/Sources/LatticeStudio/Bridge/LatticeBridge.swift new file mode 100644 index 0000000000..70168305ff --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Bridge/LatticeBridge.swift @@ -0,0 +1,672 @@ +import Foundation + +// MARK: - Which lattice binary, and how to build/run it. +// +// Every binary is reachable two ways: a prebuilt artifact in `target/release` (preferred, +// instant), or `cargo run --release` (fallback, compiles on first use). Compile-time +// `--features` only matter for the cargo fallback — a prebuilt binary already has them baked in. + +enum LatticeBinary { + case trainGradFull // lattice-tune, features: train-backward + case quantizeQ4 // lattice-inference + case quantizeQuaRot // lattice-inference + case generateLora // lattice-tune, features: safetensors,inference-hook + case chatMetal // lattice-inference, features: f16,metal-gpu — GPU Metal inference + case lattice // lattice-inference — `chat` / `serve` subcommands + case qwen35Generate // lattice-inference + case evalPerplexity // lattice-inference — strided sliding-window perplexity (ADR-044) + case embed // lattice-embed — batch text embedding with cosine report + + var binName: String { + switch self { + case .trainGradFull: "train_grad_full" + case .quantizeQ4: "quantize_q4" + case .quantizeQuaRot: "quantize_quarot" + case .generateLora: "generate_lora" + case .chatMetal: "chat_metal" + case .lattice: "lattice" + case .qwen35Generate: "qwen35_generate" + case .evalPerplexity: "eval_perplexity" + case .embed: "embed" + } + } + var crate: String { + switch self { + case .trainGradFull, .generateLora: "lattice-tune" + case .embed: "lattice-embed" + default: "lattice-inference" + } + } + var features: [String] { + switch self { + case .trainGradFull: ["train-backward"] + case .generateLora: ["safetensors", "inference-hook"] + case .evalPerplexity: ["f16", "metal-gpu"] + case .chatMetal: ["f16", "metal-gpu"] + default: [] + } + } +} + +struct LaunchSpec { + var executable: URL + var arguments: [String] + var cwd: URL? +} + +enum LatticeBridge { + + // MARK: Path resolution + + static var repoRoot: URL? { + if let env = ProcessInfo.processInfo.environment["LATTICE_REPO_ROOT"] { + return URL(fileURLWithPath: env, isDirectory: true) + } + // Walk up from the cwd first (dev / CLI / `swift run` launch), then from the app + // bundle's own location. A Finder-launched .app has cwd=/, so the cwd walk fails; + // but a packaged .app living inside a checkout (apps/macos/dist/LatticeStudio.app) + // still finds the repo root via its bundle path — which is what makes adapter + // discovery work in the packaged app. A .app moved outside any checkout (e.g. + // /Applications) resolves neither and returns nil: honest, there is no source tree. + for start in [ + URL(fileURLWithPath: FileManager.default.currentDirectoryPath, isDirectory: true), + Bundle.main.bundleURL + ] { + if let root = ascendToRepoRoot(from: start) { return root } + } + return nil + } + + /// Ascend up to 10 levels from `start` looking for a directory that holds both + /// `crates/` and `Cargo.toml` — the lattice workspace root. + private static func ascendToRepoRoot(from start: URL) -> URL? { + var dir = start + for _ in 0..<10 { + let crates = dir.appendingPathComponent("crates", isDirectory: true) + let cargo = dir.appendingPathComponent("Cargo.toml") + if FileManager.default.fileExists(atPath: crates.path), + FileManager.default.fileExists(atPath: cargo.path) { + return dir + } + let parent = dir.deletingLastPathComponent() + if parent == dir { break } + dir = parent + } + return nil + } + + static var modelCacheDir: URL { + if let env = ProcessInfo.processInfo.environment["LATTICE_MODEL_CACHE"] { + return URL(fileURLWithPath: env, isDirectory: true) + } + return FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".lattice/models", isDirectory: true) + } + + static func prebuiltBinary(_ bin: LatticeBinary) -> URL? { + let fm = FileManager.default + // (a) Bundled binary — preferred when running as a .app from /Applications. + if let resourceURL = Bundle.main.resourceURL { + let u = resourceURL.appendingPathComponent("bin/\(bin.binName)") + if fm.isExecutableFile(atPath: u.path) { return u } + } + // (b) Explicit LATTICE_BIN_DIR override — dev convenience. + if let dir = ProcessInfo.processInfo.environment["LATTICE_BIN_DIR"] { + let u = URL(fileURLWithPath: dir).appendingPathComponent(bin.binName) + if fm.isExecutableFile(atPath: u.path) { return u } + } + // (c) Repo-relative target/release — running from source checkout. + if let root = repoRoot { + let u = root.appendingPathComponent("target/release/\(bin.binName)") + if fm.isExecutableFile(atPath: u.path) { return u } + } + return nil + } + + private static var cargoURL: URL? { + let candidates = [ + FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(".cargo/bin/cargo"), + URL(fileURLWithPath: "/opt/homebrew/bin/cargo"), + URL(fileURLWithPath: "/usr/local/bin/cargo") + ] + return candidates.first { FileManager.default.isExecutableFile(atPath: $0.path) } + } + + /// Produce a launch spec, preferring a prebuilt artifact and falling back to `cargo run`. + static func launchSpec(_ bin: LatticeBinary, args: [String]) -> LaunchSpec? { + if let exe = prebuiltBinary(bin) { + return LaunchSpec(executable: exe, arguments: args, cwd: repoRoot) + } + guard let cargo = cargoURL, let root = repoRoot else { return nil } + var cargoArgs = ["run", "--release", "-p", bin.crate, "--bin", bin.binName] + if !bin.features.isEmpty { + cargoArgs += ["--features", bin.features.joined(separator: ",")] + } + cargoArgs += ["--"] + args + return LaunchSpec(executable: cargo, arguments: cargoArgs, cwd: root) + } + + // MARK: Model discovery — scan the cache into ModelInfo + indented adapters. + + static func discoverModels() -> [ModelInfo] { + let fm = FileManager.default + let root = modelCacheDir + guard let entries = try? fm.contentsOfDirectory(at: root, includingPropertiesForKeys: [.isDirectoryKey], options: [.skipsHiddenFiles]) else { + return [] + } + var models: [ModelInfo] = [] + for entry in entries { + guard (try? entry.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true else { continue } + if let m = inspectModelDir(entry) { models.append(m) } + } + return models.sorted { $0.name < $1.name } + } + + static func inspectModelDir(_ dir: URL) -> ModelInfo? { + let fm = FileManager.default + guard let files = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.fileSizeKey], options: [.skipsHiddenFiles]) else { + return nil + } + let names = Set(files.map { $0.lastPathComponent }) + let hasSafetensors = names.contains("model.safetensors") || names.contains("model.safetensors.index.json") + let hasQ4 = files.contains { $0.pathExtension == "q4" } + guard hasSafetensors || hasQ4 else { return nil } + + let name = dir.lastPathComponent + let lower = name.lowercased() + var format: ModelFormat = hasQ4 ? .q4 : .bf16 + if hasQ4 && lower.contains("quarot") { format = .quarot } + let isEmbedding = ["minilm", "bge", "embedding", "e5", "gte"].contains { lower.contains($0) } + if isEmbedding { format = .embedding } + + var size: Int64 = 0 + for f in files { + size += Int64((try? f.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0) + } + + var hidden: Int? = nil + var vocab: Int? = nil + var layerSummary: String? = nil + var contextLen: Int? = nil + var attnHeads: Int? = nil + var kvHeads: Int? = nil + var headDim: Int? = nil + var gdnKeyHeads: Int? = nil + var gdnValueHeads: Int? = nil + var intermediateSize: Int? = nil + var dtype = format == .bf16 ? "BF16" : "Q4_0" + if let rawCfg = readConfig(dir.appendingPathComponent("config.json")) { + // Some models (e.g. MLX VLM repacks) nest text fields under `text_config`. + // Prefer the nested dict when present; fall back to top-level. + let cfg: [String: Any] + if let nested = rawCfg["text_config"] as? [String: Any] { + cfg = nested + } else { + cfg = rawCfg + } + hidden = cfg["hidden_size"] as? Int + vocab = cfg["vocab_size"] as? Int + // Real max context (HF `max_position_embeddings`). `cfg` is the nested + // text_config for qwen3.5 or the top-level dict for flat configs, so one + // read covers both layouts. Stays nil (CTX well hidden) when no config.json. + contextLen = cfg["max_position_embeddings"] as? Int + // Attention head config — same nested-resolved cfg; honest-nil for flat/absent configs. + attnHeads = cfg["num_attention_heads"] as? Int + kvHeads = cfg["num_key_value_heads"] as? Int + headDim = cfg["head_dim"] as? Int + gdnKeyHeads = cfg["linear_num_key_heads"] as? Int + gdnValueHeads = cfg["linear_num_value_heads"] as? Int + intermediateSize = cfg["intermediate_size"] as? Int + // Derive layer summary from real `layer_types` array when available. + if let layerTypes = cfg["layer_types"] as? [String] { + // Count each type and surface all non-zero counts. + var gdn = 0 + var gqa = 0 + var other: [String: Int] = [:] + for t in layerTypes { + switch t { + case "linear_attention": gdn += 1 + case "full_attention": gqa += 1 + default: + other[t, default: 0] += 1 + } + } + var parts: [String] = [] + if gdn > 0 { parts.append("\(gdn) GDN") } + if gqa > 0 { parts.append("\(gqa) GQA") } + for (typeName, count) in other.sorted(by: { $0.key < $1.key }) { + parts.append("\(count) \(typeName)") + } + layerSummary = parts.joined(separator: " · ") + } else if let nl = cfg["num_hidden_layers"] as? Int { + // No layer_types available; surface the raw count honestly. + layerSummary = "\(nl) layers" + } + // (If neither field is present, layerSummary stays nil and renders "—".) + if let dt = rawCfg["torch_dtype"] as? String { dtype = dt.uppercased() } + } + + let params = parseParamCount(from: name) + // Scan for adapters: loose .safetensors in the model dir (excluding model weight + // shards), plus anything in an `adapters/` subdirectory. + let looseAdapters = discoverAdapters(in: dir) + let adapterSubdir = dir.appendingPathComponent("adapters", isDirectory: true) + let subdirAdapters = discoverAdapters(in: adapterSubdir) + let adapters = (looseAdapters + subdirAdapters).sorted { $0.name < $1.name } + return ModelInfo( + name: name, path: dir, format: format, params: params, dtype: dtype, + sizeBytes: size, fileCount: files.count, hasTokenizer: names.contains("tokenizer.json"), + layerSummary: layerSummary, hidden: hidden, vocab: vocab, contextLength: contextLen, + attnHeads: attnHeads, kvHeads: kvHeads, headDim: headDim, + gdnKeyHeads: gdnKeyHeads, gdnValueHeads: gdnValueHeads, + intermediateSize: intermediateSize, + isEmbedding: isEmbedding, adapters: adapters + ) + } + + static func readConfig(_ url: URL) -> [String: Any]? { + guard let data = try? Data(contentsOf: url), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + return obj + } + + /// "qwen3.5-0.8b" -> "0.8B". Catches B/M suffix with optional decimal. + static func parseParamCount(from name: String) -> String? { + let pattern = #"(\d+(?:\.\d+)?)\s*([bBmM])"# + guard let re = try? NSRegularExpression(pattern: pattern) else { return nil } + let range = NSRange(name.startIndex..., in: name) + guard let m = re.firstMatch(in: name, range: range), + let numR = Range(m.range(at: 1), in: name), + let unitR = Range(m.range(at: 2), in: name) else { return nil } + return "\(name[numR])\(name[unitR].uppercased())" + } + + /// Return true if a filename looks like a sharded model weight, not an adapter. + /// Matches: model.safetensors, model.safetensors.index.json, + /// model-00001-of-00002.safetensors, model.safetensors-00001-of-00001.safetensors + private static func isModelWeight(_ filename: String) -> Bool { + let lower = filename.lowercased() + // Exact base weight + if lower == "model.safetensors" { return true } + // Shard patterns: model-00001-of-00002.safetensors + if lower.hasPrefix("model-") && lower.hasSuffix(".safetensors") { return true } + // Double-extension shard: model.safetensors-00001-of-00001.safetensors + if lower.hasPrefix("model.safetensors-") && lower.hasSuffix(".safetensors") { return true } + return false + } + + /// Read the JSON header from a `.safetensors` file and return the `__metadata__` map. + /// + /// safetensors binary layout: bytes [0..8) = little-endian UInt64 N, bytes [8..8+N) = UTF-8 + /// JSON. The JSON object may contain a `"__metadata__"` key whose value is [String: String]. + /// Only the 8-byte length prefix and the N header bytes are read — the tensor payload is + /// never touched, so this is safe even for multi-hundred-MB adapter files. + /// + /// Returns nil on any failure (truncated file, bad JSON, missing key, N out of bounds). + static func readSafetensorsMetadata(_ url: URL) -> [String: String]? { + guard let handle = try? FileHandle(forReadingFrom: url) else { return nil } + defer { try? handle.close() } + + // Read the 8-byte little-endian header length. + guard let lenData = try? handle.read(upToCount: 8), lenData.count == 8 else { return nil } + let n = lenData.withUnsafeBytes { $0.loadUnaligned(as: UInt64.self).littleEndian } + + // Sanity-bound: reject empty or suspiciously large headers. + guard n > 0, n <= 100_000_000 else { return nil } + + guard let headerData = try? handle.read(upToCount: Int(n)), headerData.count == Int(n) else { + return nil + } + guard let obj = try? JSONSerialization.jsonObject(with: headerData) as? [String: Any], + let meta = obj["__metadata__"] as? [String: String] + else { return nil } + + return meta + } + + /// Read adapter metadata from a PEFT-format `adapter_config.json` sibling file. + /// + /// Handles the standard PEFT keys (`r`, `lora_alpha`, `target_modules`). Used as a + /// fallback when the safetensors `__metadata__` block is absent or empty, for adapters + /// that were produced outside of lattice (e.g. imported from Hugging Face). + private static func readPeftAdapterConfig(sibling url: URL) -> (rank: Int?, alpha: Double?, targetModules: String?)? { + let configURL = url.deletingLastPathComponent().appendingPathComponent("adapter_config.json") + guard let data = try? Data(contentsOf: configURL), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + + let rank = obj["r"] as? Int + let alpha: Double? + if let a = obj["lora_alpha"] as? Double { + alpha = a + } else if let a = obj["lora_alpha"] as? Int { + alpha = Double(a) + } else { + alpha = nil + } + let targetModules: String? + if let mods = obj["target_modules"] as? [String], !mods.isEmpty { + targetModules = mods.joined(separator: ", ") + } else { + targetModules = nil + } + + return (rank: rank, alpha: alpha, targetModules: targetModules) + } + + /// Read adapter metadata from an MLX-LM format `adapter_config.json` in a directory. + /// + /// Handles MLX-LM keys: + /// - `lora_parameters.rank` (Int) + /// - `lora_parameters.scale` (Double or Int) + /// - `lora_parameters.dropout` (Double or Int) + /// - `model` (String → baseModel) + /// - `num_layers` (Int) + /// + /// Returns nil when the file is absent, unreadable, or not a dictionary. + /// All individual fields can be independently nil (honest-nil policy). + static func readMlxAdapterConfig(dir: URL) -> (rank: Int?, scale: Double?, dropout: Double?, baseModel: String?, numLayers: Int?)? { + let configURL = dir.appendingPathComponent("adapter_config.json") + guard let data = try? Data(contentsOf: configURL), + let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] + else { return nil } + + var rank: Int? = nil + var scale: Double? = nil + var dropout: Double? = nil + + if let loraParams = obj["lora_parameters"] as? [String: Any] { + rank = loraParams["rank"] as? Int + if let s = loraParams["scale"] as? Double { + scale = s + } else if let s = loraParams["scale"] as? Int { + scale = Double(s) + } + if let d = loraParams["dropout"] as? Double { + dropout = d + } else if let d = loraParams["dropout"] as? Int { + dropout = Double(d) + } + } + + let baseModel = obj["model"] as? String + let numLayers = obj["num_layers"] as? Int + + return (rank: rank, scale: scale, dropout: dropout, baseModel: baseModel, numLayers: numLayers) + } + + /// Scan a directory of adapter packages (subdirectories) into [AdapterInfo]. + /// + /// A subdirectory qualifies as an adapter package if it contains: + /// - `adapter_config.json`, OR + /// - Any file matching `adapters.safetensors`, `adapter.safetensors`, or `*_adapters.safetensors` + /// + /// Metadata resolution order: + /// 1. `readMlxAdapterConfig` from `adapter_config.json` (MLX-LM format) + /// 2. `readPeftAdapterConfig` from sibling `adapter_config.json` (PEFT fallback, for rank/alpha) + /// 3. All config fields remain nil when neither source is present (honest). + /// + /// sizeBytes = sum of all `.safetensors` in the subdir. + /// checkpointCount = count of files matching `*_adapters.safetensors`. + /// Results are sorted by name. + static func discoverAdapterPackages(in root: URL) -> [AdapterInfo] { + let fm = FileManager.default + guard let entries = try? fm.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey, .fileSizeKey], + options: [.skipsHiddenFiles] + ) else { return [] } + + var result: [AdapterInfo] = [] + + for entry in entries { + guard (try? entry.resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true else { continue } + + guard let children = try? fm.contentsOfDirectory( + at: entry, + includingPropertiesForKeys: [.fileSizeKey], + options: [.skipsHiddenFiles] + ) else { continue } + + let childNames = Set(children.map { $0.lastPathComponent }) + + // Qualification: has config or any qualifying safetensors file + let hasConfig = childNames.contains("adapter_config.json") + let hasSafetensors = children.contains { f in + let name = f.lastPathComponent + return name == "adapters.safetensors" + || name == "adapter.safetensors" + || (name.hasSuffix("_adapters.safetensors") && f.pathExtension == "safetensors") + } + guard hasConfig || hasSafetensors else { continue } + + // Size = sum of all .safetensors in the subdir + var sizeBytes: Int64 = 0 + var checkpointCount = 0 + for child in children where child.pathExtension == "safetensors" { + sizeBytes += Int64((try? child.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0) + if child.lastPathComponent.hasSuffix("_adapters.safetensors") { + checkpointCount += 1 + } + } + + // Metadata resolution + var rank: Int? = nil + var alpha: Double? = nil + var scale: Double? = nil + var targetModules: String? = nil + var baseModel: String? = nil + var numLayers: Int? = nil + + if let mlx = readMlxAdapterConfig(dir: entry) { + rank = mlx.rank + scale = mlx.scale + baseModel = mlx.baseModel + numLayers = mlx.numLayers + // MLX format has no alpha/targetModules; leave them nil + } else { + // PEFT fallback: pick any safetensors file in the dir as a sibling anchor + if let anySt = children.first(where: { $0.pathExtension == "safetensors" }), + let peft = readPeftAdapterConfig(sibling: anySt) { + rank = peft.rank + alpha = peft.alpha + targetModules = peft.targetModules + } + } + + var info = AdapterInfo( + name: entry.lastPathComponent, + path: entry, + rank: rank, + alpha: alpha, + targetModules: targetModules, + sizeBytes: sizeBytes, + baseModel: baseModel, + scale: scale, + numLayers: numLayers, + checkpointCount: checkpointCount + ) + info.weightFile = AdapterInfo.resolveWeightFile(at: entry) + result.append(info) + } + + return result.sorted { $0.name < $1.name } + } + + /// Attach globally-discovered adapter packages (from `/adapters`) to the + /// local models they are compatible with, so per-model UI — chiefly the Chat A/B + /// adapter picker, which reads `selectedModel.adapters` — can offer them. + /// + /// The global scan (`discoverAdapterPackages`) already surfaces every adapter in the + /// MODELS table, but those adapters carry no model association, so the per-model + /// picker stayed empty. This bridges that gap. + /// + /// Compatibility policy (Ocean, 2026-06-21 — "compatible-only"): + /// - An adapter that declares a base model (MLX `model` field, e.g. + /// "Qwen/Qwen3.5-0.8B") attaches ONLY to the local model whose name equals the + /// normalized base (org prefix stripped + lowercased → "qwen3.5-0.8b"). This + /// deliberately excludes the q4 / quarot variants: `generate_lora` is bf16-only. + /// - An adapter with NO declared base (lattice-native packages) attaches as a + /// permissive fallback to every non-embedding bf16 model in the qwen3.5 family, + /// since its true base is unknown and bf16 is the only loadable target. + /// + /// Existing per-model adapters (from the model-cache scan) are preserved; the merge + /// de-dupes by adapter path and keeps the list sorted by name. A model that matches + /// no adapter is returned unchanged. + static func associateAdapters(_ adapters: [AdapterInfo], into models: [ModelInfo]) -> [ModelInfo] { + guard !adapters.isEmpty else { return models } + + func normalizedBase(_ s: String) -> String { + (s.split(separator: "/").last.map(String.init) ?? s).lowercased() + } + let declared = adapters.filter { ($0.baseModel?.isEmpty == false) } + let undeclared = adapters.filter { ($0.baseModel?.isEmpty != false) } + + return models.map { model in + let modelKey = model.name.lowercased() + var matched: [AdapterInfo] = [] + for a in declared where normalizedBase(a.baseModel ?? "") == modelKey { + matched.append(a) + } + if !model.isEmbedding, model.format == .bf16, modelKey.contains("qwen3.5") { + matched.append(contentsOf: undeclared) + } + guard !matched.isEmpty else { return model } + + var merged = model.adapters + let existing = Set(merged.map { $0.path.path }) + for a in matched where !existing.contains(a.path.path) { + merged.append(a) + } + var copy = model + copy.adapters = merged.sorted { $0.name < $1.name } + return copy + } + } + + /// Scan a directory of `.safetensors` adapter files into AdapterInfo. + /// Excludes sharded model weights (model*.safetensors, model-*-of-*.safetensors, + /// model.safetensors-*-of-*.safetensors) which are base model files, not adapters. + /// + /// Metadata resolution order for each adapter file: + /// 1. `__metadata__` block in the safetensors header (lattice-native format). + /// 2. Sibling `adapter_config.json` using PEFT keys `r`/`lora_alpha`/`target_modules` + /// (for externally-imported adapters only). + /// 3. All three fields stay nil when neither source is present (honest result). + static func discoverAdapters(in dir: URL) -> [AdapterInfo] { + let fm = FileManager.default + guard let files = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: [.fileSizeKey], options: [.skipsHiddenFiles]) else { + return [] + } + return files + .filter { $0.pathExtension == "safetensors" && !isModelWeight($0.lastPathComponent) } + .map { f in + let size = Int64((try? f.resourceValues(forKeys: [.fileSizeKey]))?.fileSize ?? 0) + + // --- Metadata resolution: safetensors __metadata__ first --- + var rank: Int? = nil + var alpha: Double? = nil + var targetModules: String? = nil + + if let meta = readSafetensorsMetadata(f), !meta.isEmpty { + rank = meta["rank"].flatMap { Int($0) } + alpha = meta["alpha"].flatMap { Double($0) } + // Normalize: split on commas, trim whitespace, rejoin. + if let raw = meta["target_modules"], !raw.isEmpty { + let parts = raw.split(separator: ",").map { String($0).trimmingCharacters(in: .whitespaces) }.filter { !$0.isEmpty } + if !parts.isEmpty { targetModules = parts.joined(separator: ", ") } + } + } else if let peft = readPeftAdapterConfig(sibling: f) { + // --- Fallback: PEFT adapter_config.json for imported adapters --- + rank = peft.rank + alpha = peft.alpha + targetModules = peft.targetModules + } + + var info = AdapterInfo(name: f.deletingPathExtension().lastPathComponent, path: f, + rank: rank, alpha: alpha, targetModules: targetModules, sizeBytes: size) + info.weightFile = AdapterInfo.resolveWeightFile(at: f) + return info + }.sorted { $0.name < $1.name } + } +} + +// MARK: - A single running lattice subprocess: streaming, pausable, killable. + +final class RunHandle { + private let process = Process() + private let outPipe = Pipe() + private let inPipe = Pipe() + private var buffer = Data() + private(set) var isPaused = false + + var onEvent: ((LatticeEvent) -> Void)? + var onExit: ((Int32) -> Void)? + + var isRunning: Bool { process.isRunning } + /// The OS-assigned process identifier. Valid after `start(_:)` returns without throwing. + var pid: Int32 { process.processIdentifier } + + func start(_ spec: LaunchSpec, env: [String: String]? = nil) throws { + process.executableURL = spec.executable + process.arguments = spec.arguments + if let cwd = spec.cwd { process.currentDirectoryURL = cwd } + + var environment = ProcessInfo.processInfo.environment + env?.forEach { environment[$0.key] = $0.value } + process.environment = environment + + process.standardOutput = outPipe + process.standardError = outPipe + process.standardInput = inPipe + + outPipe.fileHandleForReading.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { return } + self?.ingest(data) + } + process.terminationHandler = { [weak self] proc in + self?.outPipe.fileHandleForReading.readabilityHandler = nil + let code = proc.terminationStatus + DispatchQueue.main.async { self?.onExit?(code) } + } + try process.run() + } + + private func ingest(_ data: Data) { + buffer.append(data) + let newline: UInt8 = 0x0A + while let nl = buffer.firstIndex(of: newline) { + let lineData = buffer.subdata(in: buffer.startIndex.."} + // + // Emitted exactly once per `embed --download-only --json` invocation. + // `ok` is required. `error` is present only on failure. + struct DownloadDone: Codable, Equatable { + var model: String? + var ok: Bool + var error: String? + } +} + +/// Tagged envelope used to peek the `ev` discriminator before decoding the full payload. +private struct EventTag: Codable { var ev: String } + +enum LatticeEventParser { + /// Parse a single raw stdout/stderr line into an event. + /// + /// Priority: + /// 1. `@@lattice {json}` sentinel — structured JSON protocol (future --json mode). + /// 2. Human-readable stdout patterns emitted by today's binaries (fallback). + /// 3. Anything else → `.status(line)` so the UI can display it as a log line. + static func parse(line rawLine: String) -> LatticeEvent? { + let line = stripANSI(rawLine.trimmingCharacters(in: .whitespacesAndNewlines)) + guard !line.isEmpty else { return nil } + + // --- Path 1: structured JSON protocol --- + if line.hasPrefix(kLatticeEventPrefix) { + return parseJSON(String(line.dropFirst(kLatticeEventPrefix.count))) + } + + // --- Path 2: human-readable fallback --- + if let ev = HumanLineParser.parse(line) { return ev } + + // --- Path 3: raw status --- + return .status(line) + } + + // MARK: - JSON path + + private static func parseJSON(_ jsonText: String) -> LatticeEvent { + guard let data = jsonText.data(using: .utf8) else { return .unknown(jsonText) } + let decoder = JSONDecoder() + guard let tag = try? decoder.decode(EventTag.self, from: data) else { return .unknown(jsonText) } + + switch tag.ev { + case "train_step": return (try? decoder.decode(LatticeEvent.TrainStep.self, from: data)).map(LatticeEvent.trainStep) ?? .unknown(jsonText) + case "train_eval": return (try? decoder.decode(LatticeEvent.TrainEval.self, from: data)).map(LatticeEvent.trainEval) ?? .unknown(jsonText) + case "train_done": return (try? decoder.decode(LatticeEvent.TrainDone.self, from: data)).map(LatticeEvent.trainDone) ?? .unknown(jsonText) + case "quant_layer": return (try? decoder.decode(LatticeEvent.QuantLayer.self, from: data)).map(LatticeEvent.quantLayer) ?? .unknown(jsonText) + case "quant_done": return (try? decoder.decode(LatticeEvent.QuantDone.self, from: data)).map(LatticeEvent.quantDone) ?? .unknown(jsonText) + case "gen_token": return (try? decoder.decode(LatticeEvent.GenToken.self, from: data)).map(LatticeEvent.genToken) ?? .unknown(jsonText) + case "perplexity": return (try? decoder.decode(LatticeEvent.Perplexity.self, from: data)).map(LatticeEvent.perplexity) ?? .unknown(jsonText) + case "embed_done": return (try? decoder.decode(LatticeEvent.EmbedDone.self, from: data)).map(LatticeEvent.embedDone) ?? .unknown(jsonText) + case "download_done": return (try? decoder.decode(LatticeEvent.DownloadDone.self, from: data)).map(LatticeEvent.downloadDone) ?? .unknown(jsonText) + default: return .unknown(jsonText) + } + } + + // MARK: - ANSI strip + + /// Remove ANSI escape sequences (e.g. colour codes) from a line before parsing. + static func stripANSI(_ s: String) -> String { + // Matches ESC [ ... m and other common CSI sequences. + guard s.contains("\u{1B}") else { return s } + var result = "" + result.reserveCapacity(s.count) + var iter = s.unicodeScalars.makeIterator() + while let c = iter.next() { + if c == "\u{1B}" { + // Consume until the terminating letter (A-Z, a-z, or for OSC: BEL/ST) + while let n = iter.next() { + if n.value >= 0x40 && n.value <= 0x7E { break } // final byte + if n == "\u{07}" { break } // BEL (OSC terminator) + } + } else { + result.unicodeScalars.append(c) + } + } + return result + } +} + +// MARK: - Human-readable line parser + +/// Converts the current human-readable stdout from train_grad_full, quantize_q4, and +/// quantize_quarot into the same LatticeEvent cases used by the JSON protocol. +/// Returns nil for lines that don't match any known pattern (caller emits .status). +/// +/// Accumulator for quantization summary fields (shared across the line stream for one job). +/// QuantDone is emitted when both size fields and ratio have been seen. +final class QuantAccumulator { + var beforeMB: Double? + var afterMB: Double? + var ratio: Double? + var maxAbs: Double? + var verdict: String? + + /// Call with each parsed summary field. Returns a .quantDone event when the + /// accumulator is complete (has at least before, after, and ratio). + func update(beforeMB: Double? = nil, afterMB: Double? = nil, + ratio: Double? = nil, maxAbs: Double? = nil, + verdict: String? = nil) -> LatticeEvent? { + if let v = beforeMB { self.beforeMB = v } + if let v = afterMB { self.afterMB = v } + if let v = ratio { self.ratio = v } + if let v = maxAbs { self.maxAbs = v } + if let v = verdict { self.verdict = v } + // Emit once both size fields and a terminal metric are in. The terminal metric is + // the compression ratio for Q4 (its last summary line) OR the forward-equivalence + // max_abs for QuaRot (its last line, after Compression). A QuaRot dry-run writes + // nothing, so its Compression line is "N/A" and ratio stays nil — without the + // max_abs branch that run would never complete in the UI (the original bug). + guard let b = self.beforeMB, let a = self.afterMB, + (self.ratio != nil || self.maxAbs != nil) else { return nil } + return .quantDone(LatticeEvent.QuantDone( + before_mb: b, after_mb: a, ratio: self.ratio, + verdict: self.verdict, max_abs: self.maxAbs, est_ppl_delta: nil + )) + } + + func reset() { + beforeMB = nil; afterMB = nil; ratio = nil; maxAbs = nil; verdict = nil + } +} + +/// Shared per-process accumulator. RunHandle is one-process-per-instance, so we +/// keep a single thread-local-style global here; the main-thread delivery in RunHandle +/// means there is no data race. +private var _quantAccumulator = QuantAccumulator() + +/// Reset the shared quant accumulator at the start of every quantization run. +/// Called by the Store layer before launching a new quant subprocess. +func resetQuantAccumulator() { _quantAccumulator.reset() } + +enum HumanLineParser { + // MARK: Entry point + + static func parse(_ line: String) -> LatticeEvent? { + if let ev = parseTrainStep(line) { return ev } + if let ev = parseTrainDone(line) { return ev } + if let ev = parseQuantLayer(line) { return ev } + if let ev = parseQuantSummary(line){ return ev } + return nil + } + + // MARK: - train_grad_full step lines + + // With validation: + // " step 5 train NLL: 3.9876 held-out NLL: 4.4321 (train d -0.1358)" + // " step 0 train NLL: 4.1234 held-out NLL: 4.5678" + // Without validation: + // " step 0 train NLL: 4.1234" + // " step 10 train NLL: 3.80 (delta from base: -0.32)" + private static let reTrainStepFull = try! NSRegularExpression( + pattern: #"^\s*step\s+(\d+)\s+train NLL:\s*([0-9]+(?:\.[0-9]+)?)\s+held-out NLL:\s*([0-9]+(?:\.[0-9]+)?)"# + ) + private static let reTrainStepNoVal = try! NSRegularExpression( + pattern: #"^\s*step\s+(\d+)\s+train NLL:\s*([0-9]+(?:\.[0-9]+)?)"# + ) + + private static func parseTrainStep(_ line: String) -> LatticeEvent? { + // Full variant first (with held-out NLL). + if let m = reTrainStepFull.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) { + guard let stepR = Range(m.range(at: 1), in: line), + let lossR = Range(m.range(at: 2), in: line), + let valR = Range(m.range(at: 3), in: line), + let step = Int(line[stepR]), + let loss = Double(line[lossR]), + let valLoss = Double(line[valR]) else { return nil } + return .trainStep(LatticeEvent.TrainStep( + step: step, loss: loss, lr: nil, grad_norm: nil, tok_s: nil, + val_loss: valLoss, eta_s: nil + )) + } + // No-val variant. + if let m = reTrainStepNoVal.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) { + guard let stepR = Range(m.range(at: 1), in: line), + let lossR = Range(m.range(at: 2), in: line), + let step = Int(line[stepR]), + let loss = Double(line[lossR]) else { return nil } + return .trainStep(LatticeEvent.TrainStep( + step: step, loss: loss, lr: nil, grad_norm: nil, tok_s: nil, + val_loss: nil, eta_s: nil + )) + } + return nil + } + + // MARK: - train_grad_full done lines + + // With validation: + // "=== done: train 4.1234→3.1234 (-1.0000) | held-out 4.5678→4.1000 (-0.4678) in 12.3s ===" + // Without validation: + // "=== done: base NLL 4.1234 → final NLL 3.1234 (-1.0000) in 12.3s ===" + // Unicode right arrow (→ U+2192) or ASCII "->" both accepted. + private static let reDoneFull = try! NSRegularExpression( + pattern: #"^=== done: train ([0-9]+\.[0-9]+)[-→>]+([0-9]+\.[0-9]+)\s*\([^)]+\)\s*\|\s*held-out ([0-9]+\.[0-9]+)[-→>]+([0-9]+\.[0-9]+)\s*\([^)]+\)\s*in ([0-9]+(?:\.[0-9]+)?)s"# + ) + private static let reDoneNoVal = try! NSRegularExpression( + pattern: #"^=== done: base NLL ([0-9]+\.[0-9]+)[-→> ]+final NLL ([0-9]+\.[0-9]+)\s*\([^)]+\)\s*in ([0-9]+(?:\.[0-9]+)?)s"# + ) + + private static func parseTrainDone(_ line: String) -> LatticeEvent? { + guard line.hasPrefix("===") && line.contains("done:") else { return nil } + + if let m = reDoneFull.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) { + guard let baseR = Range(m.range(at: 1), in: line), + let finR = Range(m.range(at: 2), in: line), + let _ = Range(m.range(at: 3), in: line), // held-out base (not stored) + let bestR = Range(m.range(at: 4), in: line), + let durR = Range(m.range(at: 5), in: line), + let baseNLL = Double(line[baseR]), + let finalNLL = Double(line[finR]), + let bestVal = Double(line[bestR]), + let dur = Double(line[durR]) else { return nil } + return .trainDone(LatticeEvent.TrainDone( + base_nll: baseNLL, final_nll: finalNLL, + best_val: bestVal, duration_s: dur, saved: nil + )) + } + if let m = reDoneNoVal.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) { + guard let baseR = Range(m.range(at: 1), in: line), + let finR = Range(m.range(at: 2), in: line), + let durR = Range(m.range(at: 3), in: line), + let baseNLL = Double(line[baseR]), + let finalNLL = Double(line[finR]), + let dur = Double(line[durR]) else { return nil } + return .trainDone(LatticeEvent.TrainDone( + base_nll: baseNLL, final_nll: finalNLL, + best_val: nil, duration_s: dur, saved: nil + )) + } + return nil + } + + // MARK: - quantize per-tensor lines + + // "[1/24] Q4_0 model.layers.0...weight shape=[2048, 1024] 178.0MB→22.2MB 0.45s" + // "[2/24] F16 model.layers.0.input_layernorm.weight shape=[1024] 0.0MB ..." + // The size part is optional (F16 lines may omit the → part). + private static let reQuantLayer = try! NSRegularExpression( + pattern: #"^\s*\[(\d+)/(\d+)\]\s+(\S+)\s+(\S+)\s+shape=\[[^\]]+\](?:\s+([0-9]+(?:\.[0-9]+)?)(MB|GB)[-→>]+([0-9]+(?:\.[0-9]+)?)(MB|GB))?"# + ) + + private static func parseQuantLayer(_ line: String) -> LatticeEvent? { + guard line.contains("/") && line.contains("shape=") else { return nil } + guard let m = reQuantLayer.firstMatch(in: line, range: NSRange(line.startIndex..., in: line)) else { return nil } + guard let iR = Range(m.range(at: 1), in: line), + let nR = Range(m.range(at: 2), in: line), + let schemeR = Range(m.range(at: 3), in: line), + let nameR = Range(m.range(at: 4), in: line), + let i = Int(line[iR]), + let n = Int(line[nR]) else { return nil } + + let scheme = String(line[schemeR]) + let name = String(line[nameR]) + + var beforeMB: Double? = nil + var afterMB: Double? = nil + + // Groups 5-8: before value, before unit, after value, after unit (all optional) + if m.range(at: 5).location != NSNotFound, + let bValR = Range(m.range(at: 5), in: line), + let bUnitR = Range(m.range(at: 6), in: line), + let aValR = Range(m.range(at: 7), in: line), + let aUnitR = Range(m.range(at: 8), in: line), + let bVal = Double(line[bValR]), + let aVal = Double(line[aValR]) { + let bUnit = String(line[bUnitR]) + let aUnit = String(line[aUnitR]) + beforeMB = bUnit == "GB" ? bVal * 1024.0 : bVal + afterMB = aUnit == "GB" ? aVal * 1024.0 : aVal + } + + return .quantLayer(LatticeEvent.QuantLayer( + i: i, n: n, name: name, scheme: scheme, + before_mb: beforeMB, after_mb: afterMB + )) + } + + // MARK: - quantize summary lines (accumulate → emit quantDone) + + // q4 summary: + // "Input size: 27.00 GB" + // "Output size: 6.75 GB" + // "Ratio: 4.00x (25.0%)" + // quarot summary: + // "Input bytes: 27648.00 MB" + // "Output bytes: 6912.00 MB" + // "Compression: 4.00x (25.0%)" + // "Forward-equiv: max_abs=1.234e-06, mean_abs=4.567e-07 (tol=1e-05, ...)" + private static let reInputSize = try! NSRegularExpression(pattern: #"Input (?:size|bytes):\s*([0-9]+(?:\.[0-9]+)?)\s*(MB|GB)"#) + private static let reOutputSize = try! NSRegularExpression(pattern: #"Output (?:size|bytes):\s*([0-9]+(?:\.[0-9]+)?)\s*(MB|GB)"#) + private static let reRatio = try! NSRegularExpression(pattern: #"(?:Ratio|Compression):\s*([0-9]+(?:\.[0-9]+)?)x"#) + private static let reForwardEquiv = try! NSRegularExpression(pattern: #"Forward-equiv:.*max_abs=([0-9]+(?:\.[0-9]+)?(?:e[+-]?[0-9]+)?)(?:.*tol=([0-9]+(?:\.[0-9]+)?(?:e[+-]?[0-9]+)?))?"#) + + private static func parseQuantSummary(_ line: String) -> LatticeEvent? { + let ns = NSRange(line.startIndex..., in: line) + + if let m = reInputSize.firstMatch(in: line, range: ns), + let vR = Range(m.range(at: 1), in: line), + let uR = Range(m.range(at: 2), in: line), + let v = Double(line[vR]) { + let mb = String(line[uR]) == "GB" ? v * 1024.0 : v + return _quantAccumulator.update(beforeMB: mb) + } + if let m = reOutputSize.firstMatch(in: line, range: ns), + let vR = Range(m.range(at: 1), in: line), + let uR = Range(m.range(at: 2), in: line), + let v = Double(line[vR]) { + let mb = String(line[uR]) == "GB" ? v * 1024.0 : v + return _quantAccumulator.update(afterMB: mb) + } + if let m = reRatio.firstMatch(in: line, range: ns), + let vR = Range(m.range(at: 1), in: line), + let v = Double(line[vR]) { + return _quantAccumulator.update(ratio: v) + } + if let m = reForwardEquiv.firstMatch(in: line, range: ns), + let vR = Range(m.range(at: 1), in: line), + let v = Double(line[vR]) { + var derived: String? = nil + if m.range(at: 2).location != NSNotFound, + let tolR = Range(m.range(at: 2), in: line), + let tol = Double(line[tolR]) { + derived = v <= tol ? "PASS" : "FAIL" + } + return _quantAccumulator.update(maxAbs: v, verdict: derived) + } + return nil + } +} + +// MARK: - DEBUG self-test + +#if DEBUG +/// Parses 4 representative lines and returns whether all decoded to expected cases. +/// Compile-time correct; call from unit tests or debug console. +func _latticeParserSelfTest() -> Bool { + var ok = true + + let r1 = LatticeEventParser.parse(line: " step 5 train NLL: 3.9876 held-out NLL: 4.4321 (train d -0.1358)") + if case .trainStep(let s) = r1, s.step == 5, abs(s.loss - 3.9876) < 1e-9, s.val_loss == 4.4321 {} else { ok = false } + + let r2 = LatticeEventParser.parse(line: " step 0 train NLL: 4.1234") + if case .trainStep(let s) = r2, s.step == 0, abs(s.loss - 4.1234) < 1e-9, s.val_loss == nil {} else { ok = false } + + let r3 = LatticeEventParser.parse(line: "=== done: train 4.1234\u{2192}3.1234 (-1.0000) | held-out 4.5678\u{2192}4.1000 (-0.4678) in 12.3s ===") + if case .trainDone(let d) = r3, let b = d.base_nll, abs(b - 4.1234) < 1e-9, + let bv = d.best_val, abs(bv - 4.1000) < 1e-9 {} else { ok = false } + + let r4 = LatticeEventParser.parse(line: " [1/24] Q4_0 model.layers.0.self_attn.q_proj.weight shape=[2048, 1024] 178.0MB\u{2192}22.2MB 0.45s") + if case .quantLayer(let q) = r4, q.i == 1, q.n == 24, q.scheme == "Q4_0", + let bm = q.before_mb, abs(bm - 178.0) < 0.01 {} else { ok = false } + + return ok +} +#endif diff --git a/apps/macos/Sources/LatticeStudio/Bridge/RunRegistry.swift b/apps/macos/Sources/LatticeStudio/Bridge/RunRegistry.swift new file mode 100644 index 0000000000..cb4c1543da --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Bridge/RunRegistry.swift @@ -0,0 +1,164 @@ +import Darwin +import Foundation + +// MARK: - PID registry for active trainer subprocesses. +// +// Survives app crashes: each active run is written to a per-PID JSON file before launch and +// deleted on exit. On next startup, reapOrphans() reads the directory and kills any trainer +// processes that outlived the app session. +// +// Storage layout (single-instance assumed — two live app instances are out of scope): +// /LatticeStudio/active-runs/.json +// +// The "active-runs" subdirectory lives under the SAME base that AppStore already uses for +// runs.json. Both share AppStore.appSupportDir so the path never diverges. + +// MARK: Persisted entry + +/// Minimal descriptor written for each active subprocess. +struct ActiveRunEntry: Codable { + let pid: Int32 + let binPath: String + let kind: String + let startedAt: Date +} + +// MARK: Registry + +enum RunRegistry { + + // MARK: Directory resolution + + /// The `active-runs` subdirectory under the shared LatticeStudio application support dir. + /// Returns nil if the app support dir is inaccessible; all callers handle nil gracefully. + private static var registryDir: URL? { + guard let base = AppStore.appSupportDir else { return nil } + let dir = base.appendingPathComponent("active-runs", isDirectory: true) + // Create on first access — best-effort, failure propagates as nil. + do { + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + } catch { + print("[RunRegistry] could not create registry dir: \(error)") + return nil + } + return dir + } + + private static func entryURL(for pid: Int32) -> URL? { + registryDir?.appendingPathComponent("\(pid).json") + } + + // MARK: Registration + + /// Write a PID entry before the subprocess is considered launched. + /// Failure is best-effort: a missing entry is still caught on next reap. + static func register(pid: Int32, binPath: String, kind: String, startedAt: Date) { + guard let url = entryURL(for: pid) else { return } + let entry = ActiveRunEntry(pid: pid, binPath: binPath, kind: kind, startedAt: startedAt) + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + guard let data = try? encoder.encode(entry) else { return } + do { + try data.write(to: url, options: .atomic) + } catch { + print("[RunRegistry] failed to register pid \(pid): \(error)") + } + } + + /// Remove the PID entry after the subprocess terminates. + /// Safe to call on a pid that was never registered (or already deregistered). + static func deregister(pid: Int32) { + guard let url = entryURL(for: pid) else { return } + try? FileManager.default.removeItem(at: url) + } + + // MARK: Startup reaper + + /// Scan the registry dir and terminate any orphaned trainer processes left over from a + /// previous app session (crash, force-quit, etc.). + /// + /// Safety gate: before sending SIGTERM we verify via proc_pidpath that the running + /// process at that PID is the same executable recorded at registration time. + /// If the PID was recycled by an unrelated process we remove the stale file and move on. + /// + /// Returns the count of processes actually killed (i.e. matching exe-path and successfully + /// signalled). Processes that are already dead result in stale-file cleanup only. + /// + /// Note: assumes single-instance app. A second live instance would incorrectly appear as + /// orphaned training runs at startup of the first instance; that scenario is out of scope. + @discardableResult + static func reapOrphans() -> Int { + guard let dir = registryDir else { return 0 } + let fm = FileManager.default + guard let contents = try? fm.contentsOfDirectory(at: dir, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles]) else { + return 0 + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + var killed = 0 + + for fileURL in contents { + guard fileURL.pathExtension == "json" else { continue } + guard let data = try? Data(contentsOf: fileURL), + let entry = try? decoder.decode(ActiveRunEntry.self, from: data) else { + // Corrupt or undecodable entry — remove to avoid accumulation. + try? fm.removeItem(at: fileURL) + continue + } + + let pid = entry.pid + + // send(0) probes liveness without delivering a signal. + if kill(pid, 0) != 0 { + // Process is already gone — clean up the stale registry file. + try? fm.removeItem(at: fileURL) + continue + } + + // Process is alive. Verify the executable path matches before signalling. + // PROC_PIDPATHINFO_MAXSIZE (4 * MAXPATHLEN) is 4096 on Darwin. The C macro is + // not importable into Swift ("structure not supported"), so use the literal. + let bufSize = 4096 + var buf = [Int8](repeating: 0, count: bufSize) + let ret = proc_pidpath(pid, &buf, UInt32(bufSize)) + let livePath: String? + if ret > 0 { + livePath = String(cString: buf) + } else { + // proc_pidpath failed (sandbox, race, etc.) — fail safe: do not kill. + print("[RunRegistry] proc_pidpath failed for pid \(pid), skipping kill (safe)") + try? fm.removeItem(at: fileURL) + continue + } + + guard livePath == entry.binPath else { + // PID was recycled by a different process — do NOT kill anything. + print("[RunRegistry] pid \(pid) recycled: live=\(livePath ?? "nil") expected=\(entry.binPath), skipping kill") + try? fm.removeItem(at: fileURL) + continue + } + + // Exe-path matches — this is genuinely our orphaned trainer. Terminate it. + print("[RunRegistry] reaping orphaned trainer pid \(pid) (\(entry.kind))") + kill(pid, SIGTERM) + + // Poll briefly (up to 1 s in 100 ms increments) for graceful exit before SIGKILL. + let deadline = Date().addingTimeInterval(1.0) + while Date() < deadline { + Thread.sleep(forTimeInterval: 0.1) + if kill(pid, 0) != 0 { break } // gone + } + if kill(pid, 0) == 0 { + // Still alive after grace period. + print("[RunRegistry] pid \(pid) did not exit after SIGTERM, sending SIGKILL") + kill(pid, SIGKILL) + } + + try? fm.removeItem(at: fileURL) + killed += 1 + } + + return killed + } +} diff --git a/apps/macos/Sources/LatticeStudio/Components/Badges.swift b/apps/macos/Sources/LatticeStudio/Components/Badges.swift new file mode 100644 index 0000000000..f88207b044 --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Components/Badges.swift @@ -0,0 +1,173 @@ +import SwiftUI + +// MARK: - Badges +// +// Graphite Signal Lab badge vocabulary — Section D "Badges and pills". +// +// Design laws (spec §D): +// - FormatBadge: height 20, h-pad 7, radius 4 (Theme.Radius.badge), 11pt medium, neutral fill/border. +// Avoid assigning a different bright color to every model format. +// - StatusBadge: height 22, 6pt semantic dot, ink text (AA on the tinted fill), +// fill=semantic@12%, border=semantic@28%. +// Only Running gets motion: opacity pulse 65%→100% over 1.2s (spec §F). + +// MARK: - FormatBadge + +/// Neutral pill labelling a model format: BF16, Q4, EMBED, ADAPTER, etc. +/// +/// ```swift +/// FormatBadge("BF16") +/// FormatBadge("Q4") +/// FormatBadge("EMBED") +/// ``` +struct FormatBadge: View { + let label: String + + init(_ label: String) { + self.label = label + } + + var body: some View { + Text(label) + .font(Theme.Fonts.micro) + .tracking(0.2) + .foregroundStyle(Theme.Palette.inkDim) + .padding(.horizontal, 7) + .frame(height: 20) + .background(Theme.Palette.surfaceRaised) + .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.badge, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Theme.Radius.badge, style: .continuous) + .strokeBorder(Theme.Palette.borderStandard, lineWidth: 1) + ) + } +} + +// MARK: - StatusBadge + +/// Semantic status indicator: colored dot + label, fill/border at semantic opacity. +/// +/// ```swift +/// StatusBadge(.idle) +/// StatusBadge(.running) +/// StatusBadge(.success) +/// StatusBadge(.warning) +/// StatusBadge(.error) +/// ``` +struct StatusBadge: View { + enum Status { + case idle + case running + case success + case warning + case error + + var label: String { + switch self { + case .idle: "Idle" + case .running: "Running" + case .success: "Complete" + case .warning: "Warning" + case .error: "Failed" + } + } + + var color: Color { + switch self { + case .idle: Theme.Palette.idle + case .running: Theme.Palette.running + case .success: Theme.Palette.success + case .warning: Theme.Palette.warning + case .error: Theme.Palette.error + } + } + + var isRunning: Bool { self == .running } + } + + let status: Status + + @State private var dotOpacity: Double = 1.0 + + init(_ status: Status) { + self.status = status + } + + var body: some View { + HStack(spacing: 6) { + // 6pt semantic dot — Running pulses 65%→100% over 1.2s + Circle() + .fill(status.color) + .frame(width: 6, height: 6) + .opacity(dotOpacity) + .onAppear { + guard status.isRunning else { return } + withAnimation( + .easeInOut(duration: 1.2) + .repeatForever(autoreverses: true) + ) { + dotOpacity = 0.65 + } + } + .onChange(of: status.isRunning) { _, nowRunning in + if nowRunning { + withAnimation( + .easeInOut(duration: 1.2) + .repeatForever(autoreverses: true) + ) { + dotOpacity = 0.65 + } + } else { + withAnimation(.easeOut(duration: Theme.Motion.metric)) { + dotOpacity = 1.0 + } + } + } + + Text(status.label) + .font(Theme.Fonts.controlText) + .foregroundStyle(Theme.Palette.ink) + } + .padding(.horizontal, 8) + .frame(height: 22) + .background(status.color.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.badge, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Theme.Radius.badge, style: .continuous) + .strokeBorder(status.color.opacity(0.28), lineWidth: 1) + ) + } +} + +// MARK: - Previews + +#Preview("Badges") { + VStack(alignment: .leading, spacing: Theme.Space.md) { + Text("FORMAT BADGES") + .instrumentLabel() + + HStack(spacing: Theme.Space.sm) { + FormatBadge("BF16") + FormatBadge("Q4") + FormatBadge("EMBED") + FormatBadge("ADAPTER") + FormatBadge("F32") + } + + Divider() + .padding(.vertical, Theme.Space.xs) + + Text("STATUS BADGES") + .instrumentLabel() + + HStack(spacing: Theme.Space.sm) { + StatusBadge(.idle) + StatusBadge(.running) + StatusBadge(.success) + StatusBadge(.warning) + StatusBadge(.error) + } + } + .padding(Theme.Space.lg) + .background(Theme.Palette.canvas) +} diff --git a/apps/macos/Sources/LatticeStudio/Components/ButtonStyles.swift b/apps/macos/Sources/LatticeStudio/Components/ButtonStyles.swift new file mode 100644 index 0000000000..d791ac64e0 --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Components/ButtonStyles.swift @@ -0,0 +1,170 @@ +import SwiftUI + +// MARK: - ButtonStyles +// +// Graphite Signal Lab button vocabulary — Section D "Primary button" + "Secondary button". +// +// Design laws (spec §D): +// - Height 30pt standard; use frame(height: 34) for rail-footer placement. +// - Horizontal padding 12pt, symbol gap 6pt, radius 6pt (Theme.Radius.control). +// - Text 12pt medium (Theme.Fonts.controlText). +// - Symbol: 13pt medium (see Label use in previews). +// - Native keyboard activation and focusability preserved via ButtonStyle (not a custom control). +// - Hover approximated with .onHover + state; SwiftUI on macOS 14 has no hover modifier on ButtonStyle. +// +// Types are named Lattice* to avoid collision with any private screen-local button styles. + +// MARK: - LatticePrimaryButtonStyle + +/// Accent-filled primary button: accent fill, onAccent text. +/// +/// ```swift +/// Button("Run") { /* … */ } +/// .buttonStyle(LatticePrimaryButtonStyle()) +/// +/// // Rail-footer variant (34pt height): +/// Button("Run") { /* … */ } +/// .buttonStyle(LatticePrimaryButtonStyle(height: 34)) +/// ``` +struct LatticePrimaryButtonStyle: ButtonStyle { + var height: CGFloat = Theme.Space.controlHeight // 30; pass 34 for rail footer + + func makeBody(configuration: Configuration) -> some View { + LatticePrimaryButtonBody(configuration: configuration, height: height) + } +} + +private struct LatticePrimaryButtonBody: View { + let configuration: ButtonStyleConfiguration + let height: CGFloat + + @Environment(\.isEnabled) private var isEnabled + @State private var isHovered = false + + private var fillColor: Color { + if configuration.isPressed { return Theme.Palette.accentActive } + if isHovered { return Theme.Palette.accentHover } + return Theme.Palette.accent + } + + var body: some View { + configuration.label + .font(Theme.Fonts.controlText) + .foregroundStyle( + isEnabled + ? Theme.Palette.onAccent + : Theme.Palette.textDisabled + ) + .padding(.horizontal, 12) + .frame(height: height) + .background( + isEnabled ? fillColor : Theme.Palette.surfaceDisabled, + in: RoundedRectangle(cornerRadius: Theme.Radius.control, style: .continuous) + ) + .animation(.easeOut(duration: Theme.Motion.hover), value: isHovered) + .animation(.easeOut(duration: Theme.Motion.press), value: configuration.isPressed) + .onHover { isHovered = $0 } + } +} + +// MARK: - LatticeSecondaryButtonStyle + +/// Raised-surface secondary button: surfaceRaised fill, standard border, ink text. +/// +/// ```swift +/// Button("Reveal") { /* … */ } +/// .buttonStyle(LatticeSecondaryButtonStyle()) +/// ``` +struct LatticeSecondaryButtonStyle: ButtonStyle { + var height: CGFloat = Theme.Space.controlHeight // 30; pass 34 for rail footer + + func makeBody(configuration: Configuration) -> some View { + LatticeSecondaryButtonBody(configuration: configuration, height: height) + } +} + +private struct LatticeSecondaryButtonBody: View { + let configuration: ButtonStyleConfiguration + let height: CGFloat + + @Environment(\.isEnabled) private var isEnabled + @State private var isHovered = false + + private var fillColor: Color { + if configuration.isPressed { return Theme.Palette.panel } + if isHovered { return Theme.Palette.surfaceHover } + return Theme.Palette.surfaceRaised + } + + var body: some View { + configuration.label + .font(Theme.Fonts.controlText) + .foregroundStyle( + isEnabled + ? Theme.Palette.ink + : Theme.Palette.textDisabled + ) + .padding(.horizontal, 12) + .frame(height: height) + .background( + isEnabled ? fillColor : Theme.Palette.surfaceDisabled, + in: RoundedRectangle(cornerRadius: Theme.Radius.control, style: .continuous) + ) + .overlay( + RoundedRectangle(cornerRadius: Theme.Radius.control, style: .continuous) + .strokeBorder(Theme.Palette.borderStandard, lineWidth: 1) + ) + .animation(.easeOut(duration: Theme.Motion.hover), value: isHovered) + .animation(.easeOut(duration: Theme.Motion.press), value: configuration.isPressed) + .onHover { isHovered = $0 } + } +} + +// MARK: - Previews + +#Preview("ButtonStyles") { + VStack(alignment: .leading, spacing: Theme.Space.md) { + Text("PRIMARY") + .instrumentLabel() + + HStack(spacing: Theme.Space.sm) { + Button("Run") {} + .buttonStyle(LatticePrimaryButtonStyle()) + + Button { } label: { + Label("Run", systemImage: "play.fill") + } + .buttonStyle(LatticePrimaryButtonStyle()) + + Button("Rail Footer") {} + .buttonStyle(LatticePrimaryButtonStyle(height: 34)) + + Button("Disabled") {} + .buttonStyle(LatticePrimaryButtonStyle()) + .disabled(true) + } + + Text("SECONDARY") + .instrumentLabel() + .padding(.top, Theme.Space.xs) + + HStack(spacing: Theme.Space.sm) { + Button("Reveal") {} + .buttonStyle(LatticeSecondaryButtonStyle()) + + Button { } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .buttonStyle(LatticeSecondaryButtonStyle()) + + Button("Rail Footer") {} + .buttonStyle(LatticeSecondaryButtonStyle(height: 34)) + + Button("Disabled") {} + .buttonStyle(LatticeSecondaryButtonStyle()) + .disabled(true) + } + } + .padding(Theme.Space.lg) + .background(Theme.Palette.canvas) +} diff --git a/apps/macos/Sources/LatticeStudio/Components/CommandBar.swift b/apps/macos/Sources/LatticeStudio/Components/CommandBar.swift new file mode 100644 index 0000000000..6265997819 --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Components/CommandBar.swift @@ -0,0 +1,396 @@ +import SwiftUI + +// MARK: - Primitive 12: CommandBar (grafted from Console) +// +// A floating ⌘K mono palette. The ONE glass surface allowed. +// +// Glass law: `.regularMaterial` always; `.glassEffect` gated behind +// `if #available(macOS 26.0, *)` with `.regularMaterial` as the fallback. +// +// Behavior: +// - Types input (e.g. "train qwen3.5 r8") into a mono text field +// - Matches against provided CommandSpec list (fuzzy prefix match) +// - Arguments after the command name appear as editable ArgChip tokens +// - onRun called with (command: String, args: [String]) +// - ⏎ fires the first match; ⌫ removes last arg chip; ⎋ dismisses +// - Corner radius: Theme.Radius.commandBar = 10px + +// MARK: - Command spec model + +/// Defines a command available in the CommandBar. +struct CommandSpec: Identifiable, Equatable { + let id: String + let title: String // e.g. "train" + let args: [String] // default arg placeholders, e.g. ["qwen3.5", "r8"] + let description: String + + init(title: String, args: [String] = [], description: String = "") { + self.id = title + self.title = title + self.args = args + self.description = description + } +} + +// MARK: - Argument chip (inline editable token) + +/// An editable argument chip within the command bar input line. +struct ArgChip: View { + let text: String + let isFocused: Bool + let onRemove: () -> Void + + var body: some View { + HStack(spacing: 3) { + Text(text) + .font(Theme.Fonts.readout) + .foregroundStyle(Theme.Palette.ink) + .monospacedDigit() + } + .padding(.horizontal, Theme.Space.sm) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(isFocused ? Theme.Palette.signal.opacity(0.15) : Theme.Palette.wellSink) + .overlay( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .strokeBorder( + isFocused ? Theme.Palette.signal : Theme.Palette.hairline, + lineWidth: 1 + ) + ) + ) + } +} + +// MARK: - CommandBar + +/// The floating ⌘K command palette. The only glass surface in the instrument. +/// +/// Wire into ContentView via `AppStore.commandBarOpen` + `.sheet` or `.overlay`. +/// +/// ```swift +/// CommandBar( +/// isPresented: $store.commandBarOpen, +/// commands: [ +/// CommandSpec(title: "train", args: ["qwen3.5", "r8"], description: "Start a LoRA training run"), +/// CommandSpec(title: "quantize", args: ["quarot", "qwen3.5"], description: "Quantize a model"), +/// CommandSpec(title: "chat", args: ["qwen3.5"], description: "Open chat"), +/// ], +/// onRun: { cmd, args in print("run:", cmd, args) } +/// ) +/// ``` +struct CommandBar: View { + @Binding var isPresented: Bool + let commands: [CommandSpec] + let onRun: (String, [String]) -> Void + + @State private var inputText: String = "" + @State private var resolvedArgs: [String] = [] + @State private var selectedCommandIndex: Int = 0 + @FocusState private var fieldFocused: Bool + + @Environment(\.accessibilityReduceTransparency) private var reduceTransparency + + // MARK: Parsing + + private var tokens: [String] { + inputText + .trimmingCharacters(in: .whitespaces) + .split(separator: " ", omittingEmptySubsequences: true) + .map(String.init) + } + + private var commandToken: String { tokens.first ?? "" } + + private var typedArgs: [String] { Array(tokens.dropFirst()) } + + private var filteredCommands: [CommandSpec] { + let q = commandToken.lowercased() + guard !q.isEmpty else { return commands } + return commands.filter { + $0.title.lowercased().hasPrefix(q) || + $0.title.lowercased().contains(q) + } + } + + private var activeCommand: CommandSpec? { + guard !filteredCommands.isEmpty else { return nil } + let idx = min(selectedCommandIndex, filteredCommands.count - 1) + return filteredCommands[idx] + } + + /// Merge typed args with command defaults (typed overrides defaults positionally). + private var effectiveArgs: [String] { + guard let cmd = activeCommand else { return typedArgs } + var result = cmd.args + for (i, typed) in typedArgs.enumerated() { + if i < result.count { + result[i] = typed + } else { + result.append(typed) + } + } + return result + } + + // MARK: Body + + var body: some View { + if isPresented { + // Full-screen dimmed overlay + ZStack { + Color.black.opacity(0.4) + .ignoresSafeArea() + .onTapGesture { dismiss() } + + // The palette slab + palette + .frame(maxWidth: 540) + .shadow(color: .black.opacity(0.4), radius: 24, x: 0, y: 8) + } + .onAppear { + fieldFocused = true + selectedCommandIndex = 0 + inputText = "" + resolvedArgs = [] + } + } + } + + private var palette: some View { + VStack(spacing: 0) { + // Input line + inputLine + + // Divider + Theme.Palette.hairline.frame(height: 1) + + // Results list + if !filteredCommands.isEmpty { + resultsList + } else { + noMatchRow + } + } + .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.commandBar, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Theme.Radius.commandBar, style: .continuous) + .strokeBorder(Theme.Palette.hairline, lineWidth: 1) + ) + .background(glassBackground) + } + + // MARK: Glass background + + @ViewBuilder + private var glassBackground: some View { + if reduceTransparency { + // Solid fallback for accessibilityReduceTransparency + RoundedRectangle(cornerRadius: Theme.Radius.commandBar, style: .continuous) + .fill(Theme.Palette.panel) + } else { + // macOS 26+: .glassEffect (liquid glass); macOS 14-25: .regularMaterial + if #available(macOS 26.0, *) { + // Liquid Glass — the spec-blessed glass surface + RoundedRectangle(cornerRadius: Theme.Radius.commandBar, style: .continuous) + .fill(.regularMaterial) // glassEffect not in SwiftUI API at macOS 14 base; use material + } else { + RoundedRectangle(cornerRadius: Theme.Radius.commandBar, style: .continuous) + .fill(.regularMaterial) + } + } + } + + // MARK: Input line + + private var inputLine: some View { + HStack(spacing: Theme.Space.sm) { + // ⌘K glyph + Text("⌘") + .font(Theme.Fonts.readout) + .foregroundStyle(Theme.Palette.signal) + + // Command token + arg chips + text field + HStack(spacing: Theme.Space.xs) { + // If a command is recognized, show its name as a dim chip + if let cmd = activeCommand, !commandToken.isEmpty, cmd.title.hasPrefix(commandToken) || commandToken == cmd.title { + Text(cmd.title) + .font(Theme.Fonts.readout) + .foregroundStyle(Theme.Palette.ink) + .monospacedDigit() + } + + // Arg chips for typed args (beyond first token) + ForEach(Array(typedArgs.enumerated()), id: \.offset) { idx, arg in + ArgChip( + text: arg, + isFocused: false, + onRemove: { + // Remove last word from input + let parts = inputText.split(separator: " ").dropLast() + inputText = parts.joined(separator: " ") + } + ) + } + + // Placeholder arg hints (from command spec, beyond what's typed) + if let cmd = activeCommand { + let hintArgs = cmd.args.dropFirst(typedArgs.count) + ForEach(Array(hintArgs.enumerated()), id: \.offset) { _, hint in + Text(hint) + .font(Theme.Fonts.readout) + .foregroundStyle(Theme.Palette.inkDim) + .monospacedDigit() + .padding(.horizontal, Theme.Space.xs) + } + } + } + + // Actual text field (mono) + TextField("", text: $inputText) + .font(Theme.Fonts.readout) + .foregroundStyle(Theme.Palette.ink) + .monospacedDigit() + .textFieldStyle(.plain) + .focused($fieldFocused) + .onSubmit { fireRun() } + .onKeyPress(.escape) { + dismiss() + return .handled + } + .onKeyPress(.upArrow) { + selectedCommandIndex = max(0, selectedCommandIndex - 1) + return .handled + } + .onKeyPress(.downArrow) { + selectedCommandIndex = min(filteredCommands.count - 1, selectedCommandIndex + 1) + return .handled + } + + Spacer() + + // Return key hint + KeyCapChip("⏎") + } + .padding(.horizontal, Theme.Space.lg) + .frame(height: 48) + } + + // MARK: Results list + + private var resultsList: some View { + VStack(spacing: 0) { + ForEach(Array(filteredCommands.enumerated()), id: \.element.id) { idx, cmd in + commandRow(cmd: cmd, isSelected: idx == selectedCommandIndex) + .onTapGesture { + selectedCommandIndex = idx + fireRun() + } + } + } + } + + private func commandRow(cmd: CommandSpec, isSelected: Bool) -> some View { + HStack(spacing: Theme.Space.sm) { + // 2px teal accent on selected + Rectangle() + .fill(isSelected ? Theme.Palette.signal : .clear) + .frame(width: 2) + + VStack(alignment: .leading, spacing: 2) { + // Command title + arg chips + HStack(spacing: Theme.Space.sm) { + Text(cmd.title) + .font(Theme.Fonts.readout) + .foregroundStyle(Theme.Palette.ink) + .monospacedDigit() + + ForEach(cmd.args, id: \.self) { arg in + ArgChip(text: arg, isFocused: false, onRemove: {}) + } + } + + if !cmd.description.isEmpty { + Text(cmd.description) + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + } + } + .padding(.horizontal, Theme.Space.md) + + Spacer() + } + .frame(height: Theme.Space.rowHeightComfortable) + .background(isSelected ? Theme.Palette.signal.opacity(0.08) : .clear) + .overlay(alignment: .bottom) { + Theme.Palette.hairline.frame(height: 1) + } + } + + private var noMatchRow: some View { + HStack { + Text("no match") + .font(Theme.Fonts.readout) + .foregroundStyle(Theme.Palette.inkDim) + Spacer() + } + .frame(height: Theme.Space.rowHeightComfortable) + .padding(.horizontal, Theme.Space.lg) + } + + // MARK: Actions + + private func fireRun() { + guard let cmd = activeCommand else { return } + onRun(cmd.title, effectiveArgs) + dismiss() + } + + private func dismiss() { + inputText = "" + isPresented = false + } +} + +// MARK: - Default command set + +extension CommandSpec { + /// Standard commands wired to AppStore screens. + /// Arg hints use generic placeholders so the palette never implies a specific model + /// is installed. ContentView.handleCommand matches args against store.models at runtime. + static let latticeDefaults: [CommandSpec] = [ + CommandSpec(title: "models", args: [], description: "Browse downloaded models"), + CommandSpec(title: "data", args: [], description: "Manage training datasets"), + CommandSpec(title: "train", args: ["", ""], description: "Start a LoRA training run"), + CommandSpec(title: "chat", args: [""], description: "Open chat / A-B compare"), + CommandSpec(title: "stop", args: [], description: "Stop the current run"), + ] +} + +// MARK: - Previews + +#Preview("CommandBar") { + @Previewable @State var isPresented: Bool = true + + return ZStack { + Theme.Palette.canvas + .ignoresSafeArea() + + // Simulated background content + VStack { + Text("LATTICE INSTRUMENT") + .font(Theme.Fonts.title) + .foregroundStyle(Theme.Palette.ink) + } + + CommandBar( + isPresented: Binding(get: { isPresented }, set: { isPresented = $0 }), + commands: CommandSpec.latticeDefaults, + onRun: { cmd, args in + print("run: \(cmd) \(args.joined(separator: " "))") + } + ) + } + .frame(width: 640, height: 400) +} diff --git a/apps/macos/Sources/LatticeStudio/Components/ContrastPair.swift b/apps/macos/Sources/LatticeStudio/Components/ContrastPair.swift new file mode 100644 index 0000000000..8578d57aab --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Components/ContrastPair.swift @@ -0,0 +1,236 @@ +import SwiftUI + +// MARK: - Primitive 9: ContrastPair +// +// Before↔after comparator for the QuaRot quantization story. +// +// Design spec: +// - Two stacked ReadoutWell columns (size / bits / PPL) with a centered Δ chip +// - On completion: columns slide apart + a single hairline "fold" wipe reveals after-state +// - The Δ chip encodes direction: teal = improvement, amber = regression +// - Animation: 180ms ease-out (mechanical), respects accessibilityReduceMotion + +/// A single metric row within the ContrastPair. +struct ContrastMetric: Identifiable { + let id: String + let label: String + let beforeValue: String + let afterValue: String + let beforeUnit: String + let afterUnit: String + let delta: String // e.g. "−74%", "+0.09" + let deltaGood: Bool // teal if true, amber if false (good = smaller for size, bigger for PPL budget) + + init( + label: String, + beforeValue: String, + afterValue: String, + beforeUnit: String = "", + afterUnit: String = "", + delta: String, + deltaGood: Bool = true + ) { + self.id = label + self.label = label + self.beforeValue = beforeValue + self.afterValue = afterValue + self.beforeUnit = beforeUnit + self.afterUnit = afterUnit + self.delta = delta + self.deltaGood = deltaGood + } +} + +/// Before↔after comparator: two ReadoutWell columns + centered Δ chip. +/// On `isComplete = true` the after-column slides in with a "fold" wipe. +/// +/// ```swift +/// ContrastPair( +/// metrics: [ +/// ContrastMetric(label: "SIZE", beforeValue: "1.61", afterValue: "0.41", +/// beforeUnit: "GB", afterUnit: "GB", delta: "−74%", deltaGood: true), +/// ContrastMetric(label: "BITS", beforeValue: "16", afterValue: "4", +/// delta: "−75%", deltaGood: true), +/// ContrastMetric(label: "PPL", beforeValue: "15.86", afterValue: "15.95", +/// delta: "+0.09", deltaGood: false), +/// ], +/// isComplete: $isComplete +/// ) +/// ``` +struct ContrastPair: View { + let metrics: [ContrastMetric] + @Binding var isComplete: Bool + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var revealProgress: Double = 0.0 // 0→1 drives the fold wipe + + var body: some View { + HStack(alignment: .top, spacing: 0) { + // BEFORE column + beforeColumn + .frame(maxWidth: .infinity) + + // Center fold divider + Δ chips + deltaColumn + .frame(width: 80) + + // AFTER column — revealed by fold wipe + afterColumn + .frame(maxWidth: .infinity) + .opacity(isComplete ? 1.0 : 0.0) + .offset(x: isComplete ? 0 : 20) + .clipped() + } + .onChange(of: isComplete) { _, newValue in + if newValue { + if reduceMotion { + revealProgress = 1.0 + } else { + withAnimation(.easeOut(duration: Theme.Motion.focus)) { + revealProgress = 1.0 + } + } + } else { + revealProgress = 0.0 + } + } + } + + private var beforeColumn: some View { + VStack(alignment: .leading, spacing: 0) { + Text("BEFORE") + .instrumentLabel() + .padding(.horizontal, Theme.Space.md) + .padding(.top, Theme.Space.sm) + .padding(.bottom, Theme.Space.xs) + + ForEach(metrics) { metric in + VStack(alignment: .leading, spacing: 2) { + Text(metric.label) + .instrumentLabel() + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(metric.beforeValue) + .font(Theme.Fonts.wellValue) + .foregroundStyle(Theme.Palette.ink) + .monospacedDigit() + if !metric.beforeUnit.isEmpty { + Text(metric.beforeUnit) + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + } + } + } + .padding(.horizontal, Theme.Space.md) + .padding(.vertical, Theme.Space.sm) + .overlay(alignment: .bottom) { + Theme.Palette.hairline.frame(height: 1) + } + } + } + .readoutWellSurface() + } + + private var afterColumn: some View { + VStack(alignment: .leading, spacing: 0) { + Text("AFTER") + .instrumentLabel() + .padding(.horizontal, Theme.Space.md) + .padding(.top, Theme.Space.sm) + .padding(.bottom, Theme.Space.xs) + + ForEach(metrics) { metric in + VStack(alignment: .leading, spacing: 2) { + Text(metric.label) + .instrumentLabel() + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text(metric.afterValue) + .font(Theme.Fonts.wellValue) + .foregroundStyle(Theme.Palette.ink) + .monospacedDigit() + .contentTransition(reduceMotion ? .identity : .numericText()) + if !metric.afterUnit.isEmpty { + Text(metric.afterUnit) + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + } + } + } + .padding(.horizontal, Theme.Space.md) + .padding(.vertical, Theme.Space.sm) + .overlay(alignment: .bottom) { + Theme.Palette.hairline.frame(height: 1) + } + } + } + .readoutWellSurface() + } + + private var deltaColumn: some View { + VStack(alignment: .center, spacing: 0) { + // Header spacer to align with before/after columns + Text("──fold──▶") + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim.opacity(isComplete ? 0.0 : 1.0)) + .padding(.horizontal, Theme.Space.xs) + .padding(.top, Theme.Space.sm) + .padding(.bottom, Theme.Space.xs) + .animation(.easeOut(duration: Theme.Motion.focus), value: isComplete) + + ForEach(metrics) { metric in + // Δ chip + let deltaColor = metric.deltaGood ? Theme.Palette.signal : Theme.Palette.amber + Text(metric.delta) + .font(Theme.Fonts.cell) + .foregroundStyle(deltaColor) + .monospacedDigit() + .padding(.horizontal, Theme.Space.xs) + .padding(.vertical, Theme.Space.sm) + .frame(height: 52) // approximate height of a metric row + .overlay(alignment: .bottom) { + Theme.Palette.hairline.frame(height: 1) + } + } + } + } +} + +// MARK: - Previews + +#Preview("ContrastPair") { + @Previewable @State var isComplete: Bool = false + + return VStack(spacing: Theme.Space.lg) { + ContrastPair( + metrics: [ + ContrastMetric( + label: "SIZE", + beforeValue: "1.61", afterValue: "0.41", + beforeUnit: "GB", afterUnit: "GB", + delta: "−74%", deltaGood: true + ), + ContrastMetric( + label: "BITS", + beforeValue: "16", afterValue: "4", + delta: "−75%", deltaGood: true + ), + ContrastMetric( + label: "PPL (est.)", + beforeValue: "15.86", afterValue: "15.95", + delta: "+0.09", deltaGood: false + ), + ], + isComplete: Binding(get: { isComplete }, set: { isComplete = $0 }) + ) + .padding(Theme.Space.lg) + .instrumentPanel() + + Button(isComplete ? "Reset" : "Complete (fold wipe)") { + isComplete.toggle() + } + .font(Theme.Fonts.body) + .foregroundStyle(Theme.Palette.signal) + } + .padding(Theme.Space.xl) + .background(Theme.Palette.canvas) + .frame(width: 480) +} diff --git a/apps/macos/Sources/LatticeStudio/Components/DataTable.swift b/apps/macos/Sources/LatticeStudio/Components/DataTable.swift new file mode 100644 index 0000000000..8530f57657 --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Components/DataTable.swift @@ -0,0 +1,247 @@ +import SwiftUI + +// MARK: - Primitive 5: DataTable +// +// Generic, column-descriptor-driven table. +// +// Design spec: +// - Rows at Theme.Space.rowHeight (28px) or rowHeightComfortable (32px) +// - Tabular-mono right-aligned numerals +// - 1px hairline row rules (no zebra) +// - Sortable 11pt all-caps headers (tap header to sort; second tap reverses) +// - Selected row: 2px teal left-border accent (NO fill flood) +// - No external borders — table is "full-bleed" within its panel + +/// Alignment role for a column's cell content. +enum ColumnAlignment { + case leading // text: labels, names + case trailing // numerals: sizes, counts, metrics (tabular-mono) + case center +} + +/// Descriptor for a single table column. +struct ColumnDef { + let id: String + let header: String + let alignment: ColumnAlignment + let minWidth: CGFloat + let value: (Row) -> String + let isNumeric: Bool + // When non-nil and it returns a status for a row, that cell renders a StatusBadge + // instead of plain text (used for the Runs STATUS column). Default nil ⇒ text cell, + // so existing columns (e.g. Models) are byte-identical. + let badge: ((Row) -> StatusBadge.Status?)? + + init( + id: String, + header: String, + alignment: ColumnAlignment = .trailing, + minWidth: CGFloat = 80, + isNumeric: Bool = true, + badge: ((Row) -> StatusBadge.Status?)? = nil, + value: @escaping (Row) -> String + ) { + self.id = id + self.header = header + self.alignment = alignment + self.minWidth = minWidth + self.isNumeric = isNumeric + self.value = value + self.badge = badge + } +} + +/// Generic instrument data table. +/// +/// ```swift +/// DataTable( +/// rows: store.models, +/// columns: [ +/// ColumnDef(id:"name", header:"MODEL", alignment:.leading, isNumeric:false) { $0.name }, +/// ColumnDef(id:"size", header:"SIZE GB") { String(format:"%.2f", Double($0.sizeBytes)/1e9) }, +/// ], +/// selectedID: $selectedID, +/// comfortable: store.rowComfortable +/// ) +/// ``` +struct DataTable: View where Row.ID: Hashable { + let rows: [Row] + let columns: [ColumnDef] + @Binding var selectedID: Row.ID? + var comfortable: Bool = false + + // Sort state + @State private var sortColumnID: String? = nil + @State private var sortAscending: Bool = true + + // Hover state — a single raised-surface wash on the hovered row + @State private var hoveredID: Row.ID? = nil + + private var rowHeight: CGFloat { + comfortable ? Theme.Space.rowHeightComfortable : Theme.Space.rowHeight + } + + private var sortedRows: [Row] { + guard let colID = sortColumnID, + let col = columns.first(where: { $0.id == colID }) + else { return rows } + + return rows.sorted { a, b in + let va = col.value(a) + let vb = col.value(b) + // Numeric sort when column is numeric + if col.isNumeric, let da = Double(va), let db = Double(vb) { + return sortAscending ? da < db : da > db + } + return sortAscending ? va < vb : va > vb + } + } + + var body: some View { + VStack(spacing: 0) { + // Header row + headerRow + + // Data rows + ForEach(Array(sortedRows.enumerated()), id: \.element.id) { _, row in + dataRow(row: row) + } + } + } + + private var headerRow: some View { + HStack(spacing: 0) { + ForEach(columns, id: \.id) { col in + Button { + if sortColumnID == col.id { + sortAscending.toggle() + } else { + sortColumnID = col.id + sortAscending = true + } + } label: { + HStack(spacing: 2) { + Text(col.header) + .instrumentLabel() + .frame(maxWidth: .infinity, alignment: frameAlignment(col.alignment)) + + // Sort indicator + if sortColumnID == col.id { + Text(sortAscending ? "↑" : "↓") + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.signal) + } + } + .padding(.horizontal, Theme.Space.sm) + .frame(minWidth: col.minWidth, maxWidth: .infinity, alignment: frameAlignment(col.alignment)) + } + .buttonStyle(.plain) + .frame(minWidth: col.minWidth, maxWidth: .infinity) + } + } + .frame(height: rowHeight) + .overlay(alignment: .bottom) { + Theme.Palette.hairline.frame(height: 1) + } + } + + private func dataRow(row: Row) -> some View { + let isSelected = selectedID == row.id + let isHovered = !isSelected && hoveredID == row.id + + return HStack(spacing: 0) { + // 2pt signal leading marker on the selected row. + Rectangle() + .fill(isSelected ? Theme.Palette.signal : .clear) + .frame(width: 2) + + ForEach(columns, id: \.id) { col in + if let badge = col.badge, let status = badge(row) { + StatusBadge(status) + .padding(.horizontal, Theme.Space.sm) + .frame(minWidth: col.minWidth, maxWidth: .infinity, alignment: frameAlignment(col.alignment)) + } else { + Text(col.value(row)) + .font(col.isNumeric ? Theme.Fonts.cell : Theme.Fonts.body) + .foregroundStyle(Theme.Palette.ink) + .monospacedDigit() + .lineLimit(1) + .truncationMode(.tail) + .padding(.horizontal, Theme.Space.sm) + .frame(minWidth: col.minWidth, maxWidth: .infinity, alignment: frameAlignment(col.alignment)) + } + } + } + .frame(height: rowHeight) + // Selected: selectionFill (teal @12%) under the 2pt marker. Hover: one raised wash. + .background(rowFill(isSelected: isSelected, isHovered: isHovered)) + .contentShape(Rectangle()) + .onTapGesture { + selectedID = isSelected ? nil : row.id + } + .onHover { inside in + if inside { hoveredID = row.id } + else if hoveredID == row.id { hoveredID = nil } + } + .overlay(alignment: .bottom) { + Theme.Palette.hairline.frame(height: 1) + } + } + + @ViewBuilder + private func rowFill(isSelected: Bool, isHovered: Bool) -> some View { + if isSelected { + Theme.Palette.selectionFill + } else if isHovered { + Theme.Palette.surfaceHover + } else { + Color.clear + } + } + + private func frameAlignment(_ col: ColumnAlignment) -> Alignment { + switch col { + case .leading: .leading + case .trailing: .trailing + case .center: .center + } + } +} + +// MARK: - Previews + +#Preview("DataTable") { + @Previewable @State var selected: String? = "1" + + struct SampleRow: Identifiable { + let id: String + let name: String + let params: String + let format: String + let sizeGB: String + let files: String + } + + let rows: [SampleRow] = [ + SampleRow(id: "1", name: "qwen3.5-0.8b", params: "0.8B", format: "BF16", sizeGB: "1.61", files: "4"), + SampleRow(id: "2", name: "qwen3.5-0.8b-q4", params: "0.8B", format: "Q4", sizeGB: "0.41", files: "3"), + SampleRow(id: "3", name: "qwen3.5-0.8b-quarot", params: "0.8B", format: "QuaRot Q4", sizeGB: "0.40", files: "5"), + SampleRow(id: "4", name: "qwen3.5-2b", params: "2B", format: "BF16", sizeGB: "4.07", files: "4"), + ] + + return DataTable( + rows: rows, + columns: [ + ColumnDef(id: "name", header: "MODEL", alignment: .leading, minWidth: 180, isNumeric: false) { $0.name }, + ColumnDef(id: "params", header: "PARAMS", minWidth: 60) { $0.params }, + ColumnDef(id: "format", header: "FORMAT", alignment: .leading, minWidth: 80, isNumeric: false) { $0.format }, + ColumnDef(id: "size", header: "SIZE GB", minWidth: 70) { $0.sizeGB }, + ColumnDef(id: "files", header: "FILES", minWidth: 50) { $0.files }, + ], + selectedID: Binding(get: { selected }, set: { selected = $0 }), + comfortable: false + ) + .instrumentPanel() + .background(Theme.Palette.canvas) + .frame(width: 560, height: 200) +} diff --git a/apps/macos/Sources/LatticeStudio/Components/EmptyStateView.swift b/apps/macos/Sources/LatticeStudio/Components/EmptyStateView.swift new file mode 100644 index 0000000000..5de3d4f277 --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Components/EmptyStateView.swift @@ -0,0 +1,112 @@ +import SwiftUI + +// MARK: - EmptyStateView +// +// Graphite Signal Lab empty state — Section D "Empty states". +// +// Design laws (spec §D): +// - NO enclosing border around the whole empty state. +// - Max width 360pt (Theme.Space.emptyStateMaxWidth). +// - Symbol: 28pt, textTertiary. +// - Title: 15pt semibold (Theme.Fonts.display(15,.semibold)), ink. +// - Body: 12pt secondary (Theme.Fonts.caption / textSecondary), 18pt line target. +// - Optional primary action button 16pt below body (LatticePrimaryButtonStyle). +// - Caller is responsible for placing this view at ~38–42% usable height. +// - `ContentUnavailableView` is usable but limited; custom VStack is preferred (spec note). + +/// A screen-specific empty state: symbol, title, body, optional primary action. +/// +/// ```swift +/// EmptyStateView( +/// systemImage: "clock.arrow.circlepath", +/// title: "No runs yet", +/// message: "Training, quantization, and model tests are recorded here." +/// ) +/// +/// // With action: +/// EmptyStateView( +/// systemImage: "tablecells", +/// title: "No dataset scanned", +/// message: "Choose a directory containing JSONL, JSON, or supported dataset files.", +/// actionLabel: "Choose Directory" +/// ) { +/// openPanel() +/// } +/// ``` +struct EmptyStateView: View { + let systemImage: String + let title: String + let message: String + var actionLabel: String? = nil + var action: (() -> Void)? = nil + + var body: some View { + VStack(spacing: 0) { + // Symbol — 28pt, textTertiary + Image(systemName: systemImage) + .font(.system(size: 28, weight: .regular)) + .foregroundStyle(Theme.Palette.textTertiary) + + // Title — 15pt semibold, ink + Text(title) + .font(Theme.Fonts.display(15, .semibold)) + .foregroundStyle(Theme.Palette.ink) + .multilineTextAlignment(.center) + .padding(.top, Theme.Space.space3) // 12pt + + // Body — 12pt regular, secondary, ~18pt line target via lineSpacing + Text(message) + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.Palette.textSecondary) + .multilineTextAlignment(.center) + .lineSpacing(4) // 11pt font + 4pt spacing ≈ 15pt leading; close to 18pt target + .padding(.top, Theme.Space.space2) // 8pt + + // Optional primary action — 16pt separation below body + if let label = actionLabel, let handler = action { + Button(label, action: handler) + .buttonStyle(LatticePrimaryButtonStyle()) + .padding(.top, Theme.Space.space4) // 16pt + } + } + .frame(maxWidth: Theme.Space.emptyStateMaxWidth) + } +} + +// MARK: - Previews + +#Preview("EmptyStateView") { + VStack(spacing: Theme.Space.xl) { + // No action + EmptyStateView( + systemImage: "clock.arrow.circlepath", + title: "No runs yet", + message: "Training, quantization, and model tests are recorded here." + ) + + Divider() + + // With primary action + EmptyStateView( + systemImage: "tablecells", + title: "No dataset scanned", + message: "Choose a directory containing JSONL, JSON, or supported dataset files.", + actionLabel: "Choose Directory" + ) { + // action placeholder + } + + Divider() + + // Chat empty state + EmptyStateView( + systemImage: "text.bubble", + title: "Model ready", + message: "Ask a question or run an A/B adapter comparison.", + actionLabel: "Start Chat" + ) {} + } + .padding(Theme.Space.xl) + .frame(width: 600, height: 700) + .background(Theme.Palette.canvas) +} diff --git a/apps/macos/Sources/LatticeStudio/Components/FaderToggle.swift b/apps/macos/Sources/LatticeStudio/Components/FaderToggle.swift new file mode 100644 index 0000000000..c43703bb3d --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Components/FaderToggle.swift @@ -0,0 +1,184 @@ +import SwiftUI + +// MARK: - Primitive 11: FaderToggle +// +// The A/B adapter selector styled as a console fader. +// This is the ONE element where Theme.Motion.faderSpring is allowed. +// +// Design spec: +// - Console fader metaphor: a slider-like control, left=BASE, right=BASE+LoRA +// - Spring physics: Animation.spring(response: 0.32, dampingFraction: 0.85) +// - Hairline track, teal thumb/knob when on B side +// - Labels: A label (left) and B label (right) +// - Current selection text below +// - Respects accessibilityReduceMotion (spring → instant snap) + +/// The A/B adapter console fader. The one place spring physics is permitted. +/// +/// ```swift +/// FaderToggle( +/// labelA: "BASE", +/// labelB: "BASE + LoRA r8", +/// isOnB: $isAdapterActive +/// ) +/// ``` +struct FaderToggle: View { + let labelA: String + let labelB: String + @Binding var isOnB: Bool + + var onSwap: (() -> Void)? = nil + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isDragging: Bool = false + @State private var dragOffset: CGFloat = 0 + + // Thumb size + private let thumbW: CGFloat = 28 + private let thumbH: CGFloat = 48 + private let trackH: CGFloat = 4 + + var body: some View { + VStack(alignment: .leading, spacing: Theme.Space.sm) { + // Track + thumb + GeometryReader { geo in + let trackWidth = geo.size.width + let travelRange = trackWidth - thumbW + let targetX: CGFloat = isOnB ? travelRange : 0 + + ZStack(alignment: .leading) { + // Track + RoundedRectangle(cornerRadius: 2) + .fill(Theme.Palette.wellSink) + .frame(height: trackH) + .overlay( + RoundedRectangle(cornerRadius: 2) + .strokeBorder(Theme.Palette.hairline, lineWidth: 1) + ) + .frame(maxWidth: .infinity) + .padding(.horizontal, thumbW / 2) + + // Teal active fill (left portion for B side) + if isOnB { + RoundedRectangle(cornerRadius: 2) + .fill(Theme.Palette.signal.opacity(0.4)) + .frame(width: targetX + thumbW / 2, height: trackH) + .padding(.leading, thumbW / 2) + } + + // Fader thumb (knob) + ZStack { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(isOnB ? Theme.Palette.signal : Theme.Palette.panel) + .overlay( + RoundedRectangle(cornerRadius: 4, style: .continuous) + .strokeBorder( + isOnB ? Theme.Palette.signal : Theme.Palette.hairline, + lineWidth: 1 + ) + ) + .shadow(color: .black.opacity(0.3), radius: 2, x: 0, y: 1) + + // Grip lines on the thumb + VStack(spacing: 3) { + ForEach(0..<3, id: \.self) { _ in + Theme.Palette.hairline + .frame(width: 14, height: 1) + } + } + } + .frame(width: thumbW, height: thumbH) + .offset(x: isDragging ? dragOffset : targetX) + .animation( + isDragging + ? nil + : (reduceMotion ? nil : Theme.Motion.faderSpring), + value: isOnB + ) + .gesture( + DragGesture() + .onChanged { val in + isDragging = true + let clamped = max(0, min(travelRange, targetX + val.translation.width)) + dragOffset = clamped + } + .onEnded { val in + isDragging = false + let finalX = max(0, min(travelRange, targetX + val.translation.width)) + let shouldBeB = finalX > travelRange / 2 + if shouldBeB != isOnB { + isOnB = shouldBeB + onSwap?() + } + } + ) + .onTapGesture { + withAnimation(reduceMotion ? nil : Theme.Motion.faderSpring) { + isOnB.toggle() + } + onSwap?() + } + } + .frame(height: thumbH) + } + .frame(height: thumbH) + + // A/B labels + status line + HStack { + Text(labelA) + .font(Theme.Fonts.cell) + .foregroundStyle(isOnB ? Theme.Palette.inkDim : Theme.Palette.ink) + + Spacer() + + // Selection stamp — shown when B side is active. + // The fader sets adapterPath for the NEXT generation; there is no hot-swap. + if isOnB { + Text("applies next send") + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.signal) + } + + Spacer() + + Text(labelB) + .font(Theme.Fonts.cell) + .foregroundStyle(isOnB ? Theme.Palette.ink : Theme.Palette.inkDim) + } + .frame(height: Theme.Space.rowHeight) + } + .padding(.horizontal, Theme.Space.lg) + .padding(.vertical, Theme.Space.md) + } +} + +// MARK: - Previews + +#Preview("FaderToggle") { + @Previewable @State var isOnB: Bool = false + + return VStack(spacing: Theme.Space.xl) { + Text("HOT-SWAP FADER") + .instrumentLabel() + .padding(.horizontal, Theme.Space.lg) + .padding(.top, Theme.Space.lg) + + FaderToggle( + labelA: "BASE", + labelB: "BASE + LoRA r8", + isOnB: Binding(get: { isOnB }, set: { isOnB = $0 }), + onSwap: { + // adapter swap callback + } + ) + + Text(isOnB ? "Active: BASE + LoRA r8 · adapter applies next send" : "Active: BASE") + .font(Theme.Fonts.readout) + .foregroundStyle(Theme.Palette.inkDim) + .padding(.horizontal, Theme.Space.lg) + .padding(.bottom, Theme.Space.lg) + } + .instrumentPanel() + .background(Theme.Palette.canvas) + .frame(width: 400) +} diff --git a/apps/macos/Sources/LatticeStudio/Components/Field.swift b/apps/macos/Sources/LatticeStudio/Components/Field.swift new file mode 100644 index 0000000000..7633a15381 --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Components/Field.swift @@ -0,0 +1,196 @@ +import SwiftUI + +// MARK: - Field +// +// Graphite Signal Lab text and numeric field vocabulary — Section D "Text fields and numeric fields". +// +// Design laws (spec §D): +// - Height 28–30pt, horizontal inset 8pt, radius 6pt (Theme.Radius.control). +// - Fill: Theme.Palette.wellSink (surfaceInset / recessed) or surfaceRaised. +// - Border: 1px borderStandard (white 8%). +// - Placeholder: textTertiary. +// - Focus: border accent 70% + subtle 2pt outer ring via .focusRing palette token. +// - Error: border error 70% + 11pt caption beneath. +// - Numeric variant: SF Mono (Fonts.tableNumeric), right-aligned, default width ~80. +// +// Implementation note (spec): "A plain field plus @FocusState when exact custom styling is required." +// We wrap TextField and apply all styling externally; no custom text engine. + +// MARK: - LatticeField (text) + +/// A styled single-line text field. +/// +/// ```swift +/// @State private var name = "" +/// LatticeField("Model name", text: $name) +/// +/// // With error: +/// LatticeField("Path", text: $path, errorMessage: pathError) +/// ``` +struct LatticeField: View { + let prompt: String + @Binding var text: String + var errorMessage: String? = nil + var height: CGFloat = 30 + + @FocusState private var isFocused: Bool + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + ZStack { + // Fill surface + RoundedRectangle(cornerRadius: Theme.Radius.control, style: .continuous) + .fill(Theme.Palette.wellSink) + + // Border: focus ring → accent 70%, error → error 70%, default → white 8% + RoundedRectangle(cornerRadius: Theme.Radius.control, style: .continuous) + .strokeBorder(borderColor, lineWidth: isFocused ? 1.5 : 1) + + // Focus outer ring (2pt, accent 55%) + if isFocused && errorMessage == nil { + RoundedRectangle(cornerRadius: Theme.Radius.control + 2, style: .continuous) + .strokeBorder(Theme.Palette.focusRing, lineWidth: 2) + .padding(-3) + } + + TextField(prompt, text: $text) + .textFieldStyle(.plain) + .font(Theme.Fonts.body) + .foregroundStyle(Theme.Palette.ink) + .focused($isFocused) + .padding(.horizontal, 8) + } + .frame(height: height) + .animation(.easeOut(duration: Theme.Motion.hover), value: isFocused) + + // Error caption + if let msg = errorMessage { + Text(msg) + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.Palette.error) + } + } + } + + private var borderColor: Color { + if let _ = errorMessage { return Theme.Palette.error.opacity(0.70) } + if isFocused { return Theme.Palette.accent.opacity(0.70) } + return Theme.Palette.borderStandard + } +} + +// MARK: - LatticeNumericField + +/// A styled numeric text field: SF Mono, right-aligned, fixed width. +/// +/// ```swift +/// @State private var steps = "500" +/// LatticeNumericField("", text: $steps) +/// +/// // Wider (e.g., for seeds): +/// LatticeNumericField("Seed", text: $seed, width: 120) +/// ``` +struct LatticeNumericField: View { + let prompt: String + @Binding var text: String + var width: CGFloat = 80 + var errorMessage: String? = nil + var height: CGFloat = 30 + + @FocusState private var isFocused: Bool + + var body: some View { + VStack(alignment: .trailing, spacing: 3) { + ZStack { + RoundedRectangle(cornerRadius: Theme.Radius.control, style: .continuous) + .fill(Theme.Palette.wellSink) + + RoundedRectangle(cornerRadius: Theme.Radius.control, style: .continuous) + .strokeBorder(borderColor, lineWidth: isFocused ? 1.5 : 1) + + if isFocused && errorMessage == nil { + RoundedRectangle(cornerRadius: Theme.Radius.control + 2, style: .continuous) + .strokeBorder(Theme.Palette.focusRing, lineWidth: 2) + .padding(-3) + } + + TextField(prompt, text: $text) + .textFieldStyle(.plain) + .font(Theme.Fonts.tableNumeric) + .monospacedDigit() + .foregroundStyle(Theme.Palette.ink) + .multilineTextAlignment(.trailing) + .focused($isFocused) + .padding(.horizontal, 8) + } + .frame(width: width, height: height) + .animation(.easeOut(duration: Theme.Motion.hover), value: isFocused) + + if let msg = errorMessage { + Text(msg) + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.Palette.error) + } + } + } + + private var borderColor: Color { + if let _ = errorMessage { return Theme.Palette.error.opacity(0.70) } + if isFocused { return Theme.Palette.accent.opacity(0.70) } + return Theme.Palette.borderStandard + } +} + +// MARK: - Previews + +#Preview("Field") { + VStack(alignment: .leading, spacing: Theme.Space.md) { + Text("TEXT FIELD") + .instrumentLabel() + + LatticeField(prompt: "Model name or path", text: .constant("")) + + LatticeField(prompt: "Dataset path", text: .constant("/Users/lion/data/train.jsonl")) + + LatticeField(prompt: "With error", text: .constant("bad-path"), errorMessage: "File not found") + + Divider() + .padding(.vertical, Theme.Space.xs) + + Text("NUMERIC FIELD") + .instrumentLabel() + + HStack(alignment: .top, spacing: Theme.Space.sm) { + VStack(alignment: .leading, spacing: 4) { + Text("Steps") + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.Palette.textSecondary) + LatticeNumericField(prompt: "500", text: .constant("500")) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Rank") + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.Palette.textSecondary) + LatticeNumericField(prompt: "8", text: .constant("8"), width: 60) + } + + VStack(alignment: .leading, spacing: 4) { + Text("LR") + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.Palette.textSecondary) + LatticeNumericField(prompt: "5e-4", text: .constant("5e-4"), width: 88) + } + + VStack(alignment: .leading, spacing: 4) { + Text("Seed (error)") + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.Palette.textSecondary) + LatticeNumericField(prompt: "", text: .constant("abc"), width: 88, errorMessage: "Must be integer") + } + } + } + .padding(Theme.Space.lg) + .frame(width: 480) + .background(Theme.Palette.canvas) +} diff --git a/apps/macos/Sources/LatticeStudio/Components/GatePill.swift b/apps/macos/Sources/LatticeStudio/Components/GatePill.swift new file mode 100644 index 0000000000..d3298525ca --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Components/GatePill.swift @@ -0,0 +1,130 @@ +import SwiftUI + +// MARK: - Primitive 6: GatePill +// +// Status capsule encoding the verdict of any measurable action. +// +// States: +// PASS — teal fill +// WARN — amber fill +// FAIL — crimson fill +// RUN — animated teal pulse (running job) +// +// Design spec: +// - Corner radius: Theme.Radius.pill = 6px +// - Mono text (Theme.Fonts.cell, 11pt) +// - Background fills use signal/amber/crimson +// - RUN state: repeating opacity animation (pulse) +// - Respects accessibilityReduceMotion + +/// The verdict of a measurable gate. +enum GateStatus { + case pass + case warn + case fail + case run + + var label: String { + switch self { + case .pass: "PASS" + case .warn: "WARN" + case .fail: "FAIL" + case .run: "RUN" + } + } + + var fillColor: Color { + switch self { + case .pass: Theme.Palette.signal + case .warn: Theme.Palette.amber + case .fail: Theme.Palette.crimson + case .run: Theme.Palette.signal + } + } + + // High-contrast text: canvas (dark/light) on the status fill + var textColor: Color { Theme.Palette.canvas } +} + +/// A PASS / WARN / FAIL / RUN status capsule. +/// +/// ```swift +/// GatePill(.pass) +/// GatePill(.warn, label: "ΔPPL +0.09") +/// GatePill(.run, label: "step 420/1000") +/// GatePill(.fail, label: "NaN loss") +/// ``` +struct GatePill: View { + let status: GateStatus + let label: String + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var pulseOpacity: Double = 1.0 + + init(_ status: GateStatus, label: String? = nil) { + self.status = status + self.label = label ?? status.label + } + + var body: some View { + HStack(spacing: 4) { + // Pulse indicator dot for RUN state + if status == .run { + Circle() + .fill(status.textColor) + .frame(width: 5, height: 5) + .opacity(pulseOpacity) + .onAppear { + guard !reduceMotion else { return } + withAnimation( + .easeInOut(duration: 0.7).repeatForever(autoreverses: true) + ) { + pulseOpacity = 0.3 + } + } + } + + Text(label) + .font(Theme.Fonts.cell) + .foregroundStyle(status.textColor) + .monospacedDigit() + .textCase(.uppercase) + } + .padding(.horizontal, Theme.Space.sm) + .padding(.vertical, 3) + .background( + status == .run + ? status.fillColor.opacity(pulseOpacity * 0.7 + 0.3) + : status.fillColor + ) + .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.pill, style: .continuous)) + } +} + +// MARK: - Previews + +#Preview("GatePill") { + VStack(spacing: Theme.Space.sm) { + HStack(spacing: Theme.Space.sm) { + GatePill(.pass) + GatePill(.warn) + GatePill(.fail) + GatePill(.run) + } + + HStack(spacing: Theme.Space.sm) { + GatePill(.pass, label: "Q4 ok 405MB −74%") + GatePill(.warn, label: "ΔPPL +0.09") + GatePill(.fail, label: "NaN loss @ step 840") + GatePill(.run, label: "step 420/1000") + } + + HStack(spacing: Theme.Space.sm) { + GatePill(.pass, label: "eval PASS · best val 1.94") + GatePill(.warn, label: "grad spike · norm 4.7") + } + } + .padding(Theme.Space.lg) + .instrumentPanel() + .background(Theme.Palette.canvas) +} diff --git a/apps/macos/Sources/LatticeStudio/Components/HeroNumber.swift b/apps/macos/Sources/LatticeStudio/Components/HeroNumber.swift new file mode 100644 index 0000000000..7d0b4db056 --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Components/HeroNumber.swift @@ -0,0 +1,116 @@ +import SwiftUI + +// MARK: - Primitive 3: HeroNumber +// +// The visual anchor of TRAIN and QUANTIZE screens. +// - Big tabular-mono value (Theme.Fonts.hero = 56pt Bold by default) +// - Size param: .hero (56pt) or .heroAlt (34pt) or .heroMinor (21pt) +// - Small mono unit below/beside the value +// - 1px teal under-rule beneath the number +// - Per-digit tick via .contentTransition(.numericText()) +// - .monospacedDigit() always + +/// Preset sizes for HeroNumber, matching the modular scale. +enum HeroSize { + case hero // 56pt Bold — the loss / compression / PPL headline + case heroAlt // 34pt Bold — secondary hero + case heroMinor // 21pt Semibold — tertiary large numeral + + var font: Font { + switch self { + case .hero: Theme.Fonts.hero + case .heroAlt: Theme.Fonts.heroAlt + case .heroMinor: Theme.Fonts.heroMinor + } + } +} + +/// A big tabular-mono numeral with a unit label and a 1px teal under-rule. +/// Ticks per-digit via `.contentTransition(.numericText())`. +/// +/// ```swift +/// HeroNumber(value: "0.6121", unit: "LOSS") +/// HeroNumber(value: "3.97×", unit: "SMALLER", size: .heroAlt) +/// HeroNumber(value: "405", unit: "MB", size: .heroAlt, unitPosition: .trailing) +/// ``` +struct HeroNumber: View { + let value: String + let unit: String + var size: HeroSize = .hero + var unitPosition: UnitPosition = .below + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + enum UnitPosition { + case below // unit appears below the numeral (default) + case trailing // unit appears to the right at baseline + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if unitPosition == .trailing { + HStack(alignment: .firstTextBaseline, spacing: 6) { + numeralText + unitText + } + } else { + numeralText + if !unit.isEmpty { + unitText + .padding(.top, 2) + } + } + + // 1px teal under-rule + Theme.Palette.signal + .frame(height: 1) + .padding(.top, 4) + } + } + + private var numeralText: some View { + Text(value) + .font(size.font) + .foregroundStyle(Theme.Palette.ink) + .monospacedDigit() + .contentTransition( + reduceMotion ? .identity : .numericText() + ) + .animation( + reduceMotion + ? nil + : .easeOut(duration: Theme.Motion.tick), + value: value + ) + } + + private var unitText: some View { + Text(unit) + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + .textCase(.uppercase) + .tracking(Theme.Space.labelTracking) + } +} + +// MARK: - Previews + +#Preview("HeroNumber") { + VStack(alignment: .leading, spacing: Theme.Space.xl) { + // 56pt hero — primary loss display + HeroNumber(value: "0.6121", unit: "LOSS") + + // 34pt heroAlt — compression ratio + HeroNumber(value: "3.97×", unit: "SMALLER", size: .heroAlt) + + // 21pt heroMinor — secondary stat + HeroNumber(value: "405", unit: "MB", size: .heroMinor, unitPosition: .trailing) + + // Trailing unit + HeroNumber(value: "15.95", unit: "PPL", size: .heroAlt, unitPosition: .trailing) + } + .padding(Theme.Space.xl) + .instrumentPanel() + .background(Theme.Palette.canvas) + .frame(width: 320) +} diff --git a/apps/macos/Sources/LatticeStudio/Components/InspectorShell.swift b/apps/macos/Sources/LatticeStudio/Components/InspectorShell.swift new file mode 100644 index 0000000000..67684d585e --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Components/InspectorShell.swift @@ -0,0 +1,134 @@ +import SwiftUI + +// MARK: - InspectorShell +// +// Graphite Signal Lab — reusable edge-attached configuration-inspector container. +// Generalizes ChatScreen's `ChatInspector` into a standalone shell. +// +// Design laws: +// - Edge-attached (not a rounded card): fills its column with the panel surface. +// The system .inspector presentation supplies the single left divider, so this +// shell draws no rule of its own (avoids a doubled hairline). +// - Background: Theme.Palette.panel (opaque, no glass). +// - Internal padding: Theme.Space.lg (16pt) on all sides. +// - Optional title as an uppercase section label, matching ChatInspector. + +/// A reusable edge-attached configuration-inspector container. +/// +/// Drop inside a screen's `.inspector { }` closure or any fixed-width column: +/// +/// ```swift +/// InspectorShell(title: "Settings") { +/// settingsRows +/// } +/// ``` +/// +/// Without a title: +/// ```swift +/// InspectorShell { +/// contentRows +/// } +/// ``` +struct InspectorShell: View { + let title: String? + private let content: Content + + init(title: String? = nil, @ViewBuilder content: () -> Content) { + self.title = title + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + // Optional title row + if let title { + Text(title) + .font(Theme.Fonts.sectionLabel) + .tracking(0.5) + .textCase(.uppercase) + .foregroundStyle(Theme.Palette.textSecondary) + .padding(.bottom, Theme.Space.lg) + } + + content + } + .padding(Theme.Space.lg) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(Theme.Palette.panel) + } +} + +// MARK: - Previews + +#Preview("InspectorShell") { + HStack(spacing: 0) { + // Simulate a main content area + Rectangle() + .fill(Theme.Palette.canvas) + .frame(maxWidth: .infinity) + .overlay { + Text("Main content area") + .font(Theme.Fonts.body) + .foregroundStyle(Theme.Palette.inkDim) + } + + // Inspector with title + InspectorShell(title: "Settings") { + VStack(alignment: .leading, spacing: Theme.Space.md) { + HStack { + Text("Adapter") + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.Palette.textSecondary) + Spacer() + Text("none") + .font(Theme.Fonts.body) + .foregroundStyle(Theme.Palette.inkDim) + } + + Divider() + + HStack { + Text("Temperature") + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.Palette.textSecondary) + Spacer() + Text("0.7") + .font(Theme.Fonts.tableNumeric) + .monospacedDigit() + .foregroundStyle(Theme.Palette.ink) + } + + HStack { + Text("Max tokens") + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.Palette.textSecondary) + Spacer() + Text("256") + .font(Theme.Fonts.tableNumeric) + .monospacedDigit() + .foregroundStyle(Theme.Palette.ink) + } + } + } + .frame(width: 280) + } + .frame(width: 600, height: 400) + .background(Theme.Palette.canvas) +} + +#Preview("InspectorShell — no title") { + HStack(spacing: 0) { + Rectangle() + .fill(Theme.Palette.canvas) + .frame(maxWidth: .infinity) + + InspectorShell { + Text("Content without title header") + .font(Theme.Fonts.body) + .foregroundStyle(Theme.Palette.ink) + } + .frame(width: 280) + } + .frame(width: 600, height: 300) + .background(Theme.Palette.canvas) +} diff --git a/apps/macos/Sources/LatticeStudio/Components/KeyCapChip.swift b/apps/macos/Sources/LatticeStudio/Components/KeyCapChip.swift new file mode 100644 index 0000000000..90976347fb --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Components/KeyCapChip.swift @@ -0,0 +1,89 @@ +import SwiftUI + +// MARK: - Primitive 7: KeyCapChip +// +// A tiny 1px-outlined ⌘-cap on actionable elements so the keyboard map self-documents. +// +// Design: +// - 1px hairline border (no fill — transparent background) +// - Rounded rect corners: Theme.Radius.control = 6px +// - Mono text: Theme.Fonts.cell (11pt) +// - Foreground: Theme.Palette.inkDim (dimmed — decorative, not primary) +// - Can show a modifier glyph (⌘, ⌥, ⇧, ^) + key, or just the key + +/// A tiny outlined keyboard shortcut chip for self-documenting UI. +/// +/// ```swift +/// KeyCapChip("⌘1") +/// KeyCapChip("⌘K") +/// KeyCapChip("⌘↵") +/// KeyCapChip("⌥A") +/// ``` +struct KeyCapChip: View { + let keyText: String + + init(_ keyText: String) { + self.keyText = keyText + } + + var body: some View { + Text(keyText) + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + .monospacedDigit() + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background(.clear) + .overlay( + RoundedRectangle(cornerRadius: Theme.Radius.control, style: .continuous) + .strokeBorder(Theme.Palette.hairline, lineWidth: 1) + ) + } +} + +// MARK: - Previews + +#Preview("KeyCapChip") { + VStack(alignment: .leading, spacing: Theme.Space.sm) { + HStack(spacing: Theme.Space.sm) { + Text("01 MODELS") + .instrumentLabel() + Spacer() + KeyCapChip("⌘1") + } + HStack(spacing: Theme.Space.sm) { + Text("02 TRAIN") + .instrumentLabel() + Spacer() + KeyCapChip("⌘2") + } + HStack(spacing: Theme.Space.sm) { + Text("Command bar") + .instrumentLabel() + Spacer() + KeyCapChip("⌘K") + } + HStack(spacing: Theme.Space.sm) { + Text("Start run") + .instrumentLabel() + Spacer() + KeyCapChip("⌘↵") + } + HStack(spacing: Theme.Space.sm) { + Text("Hot-swap adapter") + .instrumentLabel() + Spacer() + KeyCapChip("⌥A") + } + HStack(spacing: Theme.Space.sm) { + Text("Inspector") + .instrumentLabel() + Spacer() + KeyCapChip("⌘\\") + } + } + .padding(Theme.Space.lg) + .instrumentPanel() + .background(Theme.Palette.canvas) + .frame(width: 260) +} diff --git a/apps/macos/Sources/LatticeStudio/Components/MassBars.swift b/apps/macos/Sources/LatticeStudio/Components/MassBars.swift new file mode 100644 index 0000000000..1195984fdd --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Components/MassBars.swift @@ -0,0 +1,181 @@ +import SwiftUI + +// MARK: - Primitive 10: MassBars +// +// Two horizontal bars to TRUE scale (before = inkDim, after = teal). +// The compression ratio counts up into a HeroNumber while bars animate to length. +// +// Design spec: +// - fp16 "before" bar: Theme.Palette.inkDim fill +// - Q4 "after" bar: Theme.Palette.signal fill +// - Both bars share the same MAX width (true scale: wider = larger size) +// - Ratio animates as a HeroNumber while bars grow +// - 180ms ease-out for bar extension; respect accessibilityReduceMotion + +/// Two true-scale horizontal mass bars with an animated compression ratio hero. +/// +/// ```swift +/// MassBars( +/// beforeLabel: "fp16", +/// beforeMB: 1648, +/// afterLabel: "Q4", +/// afterMB: 405, +/// ratioLabel: "SMALLER" +/// ) +/// ``` +struct MassBars: View { + let beforeLabel: String + let beforeMB: Double + let afterLabel: String + let afterMB: Double + var ratioLabel: String = "SMALLER" + var animated: Bool = true + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var animationProgress: Double = 0.0 // 0→1 + + private var ratio: Double { + beforeMB > 0 ? beforeMB / afterMB : 1.0 + } + + private var displayedRatio: Double { + 1.0 + (ratio - 1.0) * animationProgress + } + + private var afterFraction: Double { + beforeMB > 0 ? afterMB / beforeMB : 1.0 + } + + var body: some View { + VStack(alignment: .leading, spacing: Theme.Space.sm) { + // Hero ratio number (counts up as bars animate) + HeroNumber( + value: String(format: "%.2f×", displayedRatio), + unit: ratioLabel, + size: .heroAlt, + unitPosition: .trailing + ) + .padding(.bottom, Theme.Space.xs) + + // BEFORE bar (full width = reference) + barRow( + label: beforeLabel, + size: beforeMB, + fraction: 1.0, + color: Theme.Palette.inkDim, + progress: animationProgress + ) + + // AFTER bar (proportionally narrower) + barRow( + label: afterLabel, + size: afterMB, + fraction: afterFraction, + color: Theme.Palette.signal, + progress: animationProgress + ) + } + .onAppear { + if animated { + if reduceMotion { + animationProgress = 1.0 + } else { + withAnimation(.easeOut(duration: Theme.Motion.focus)) { + animationProgress = 1.0 + } + } + } else { + animationProgress = 1.0 + } + } + .onChange(of: beforeMB) { _, _ in resetAndAnimate() } + .onChange(of: afterMB) { _, _ in resetAndAnimate() } + } + + private func resetAndAnimate() { + animationProgress = 0.0 + if reduceMotion { + animationProgress = 1.0 + } else { + withAnimation(.easeOut(duration: Theme.Motion.focus)) { + animationProgress = 1.0 + } + } + } + + private func barRow( + label: String, + size: Double, + fraction: Double, + color: Color, + progress: Double + ) -> some View { + HStack(spacing: Theme.Space.sm) { + // Label (fixed width) + Text(label) + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + .frame(width: 40, alignment: .trailing) + + // Bar track + GeometryReader { geo in + ZStack(alignment: .leading) { + // Track background + Rectangle() + .fill(Theme.Palette.wellSink) + .frame(maxWidth: .infinity) + + // Filled portion — true scale, animated + Rectangle() + .fill(color) + .frame(width: geo.size.width * fraction * progress) + } + } + .frame(height: 12) + .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous)) + + // Size label + Text(formattedMB(size)) + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + .monospacedDigit() + .frame(width: 64, alignment: .trailing) + } + } + + private func formattedMB(_ mb: Double) -> String { + if mb >= 1024 { + String(format: "%.2f GB", mb / 1024.0) + } else { + String(format: "%.0f MB", mb) + } + } +} + +// MARK: - Previews + +#Preview("MassBars") { + VStack(spacing: Theme.Space.xl) { + Text("MASS BARS — TRUE SCALE") + .instrumentLabel() + + MassBars( + beforeLabel: "fp16", + beforeMB: 1648, + afterLabel: "Q4", + afterMB: 415 + ) + + MassBars( + beforeLabel: "fp16", + beforeMB: 4096, + afterLabel: "Q4", + afterMB: 1024, + ratioLabel: "COMPRESSED" + ) + } + .padding(Theme.Space.xl) + .instrumentPanel() + .background(Theme.Palette.canvas) + .frame(width: 440) +} diff --git a/apps/macos/Sources/LatticeStudio/Components/OpaquePanel.swift b/apps/macos/Sources/LatticeStudio/Components/OpaquePanel.swift new file mode 100644 index 0000000000..f1fcbce033 --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Components/OpaquePanel.swift @@ -0,0 +1,135 @@ +import SwiftUI + +// MARK: - Primitive 1: OpaquePanel +// +// Graphite Signal Lab design laws: +// - Dark graphite surfaces (#121820 panel, #080B0F wellSink). +// - 10pt continuous-radius cards — panels are CARDED, not ruled (0px era is over). +// - White-opacity hairlines: borderStandard = white 8%, no static drop shadows. +// - One cyan accent (#48D8C4) for signal / CTA — never decorative. +// - Numbers never touch glass — wells stay opaque. +// +// Usage: +// SomeView() +// .instrumentPanel() +// +// // or wrap content: +// OpaquePanel { ... } + +/// An opaque, hairline-bordered instrument panel container. +/// Corner radius = Theme.Radius.panel (10pt continuous). No glass permitted inside. +struct OpaquePanel: View { + private let content: Content + + init(@ViewBuilder content: () -> Content) { + self.content = content() + } + + var body: some View { + content + .background(Theme.Palette.panel) + .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous) + .strokeBorder(Theme.Palette.borderStandard, lineWidth: 1) + ) + } +} + +// MARK: - Instrument Panel View Modifier + +struct InstrumentPanelModifier: ViewModifier { + func body(content: Content) -> some View { + content + .background(Theme.Palette.panel) + .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous) + .strokeBorder(Theme.Palette.borderStandard, lineWidth: 1) + ) + } +} + +extension View { + /// Applies the opaque instrument-panel surface: Theme.Palette.panel fill + 1px white-opacity border. + /// Corner radius = Theme.Radius.panel (10pt continuous). No glass permitted on this surface. + func instrumentPanel() -> some View { + modifier(InstrumentPanelModifier()) + } +} + +// MARK: - Readout Well Surface Style +// +// Recessed machined-in surface used inside readout wells. +// Fill: Theme.Palette.wellSink (#080B0F dark / #E8EDF2 light) +// Border: 1px white-opacity hairline (borderStandard) +// Corner radius: Theme.Radius.well (8pt continuous) +// Inner top-shadow: 2px so it reads machined-in, not raised. + +struct ReadoutWellSurface: ViewModifier { + @Environment(\.colorScheme) private var colorScheme + + private var shadowColor: Color { + colorScheme == .dark + ? Color.black.opacity(0.5) + : Color.black.opacity(0.06) + } + + func body(content: Content) -> some View { + content + .background(Theme.Palette.wellSink) + .overlay( + // 2px inner top-shadow: offset y=2, no spread — reads machined-in. + // Simulated with a top-edge gradient overlay since SwiftUI has no inner-shadow. + VStack(spacing: 0) { + shadowColor + .frame(height: 2) + Spacer() + } + ) + .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.well, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Theme.Radius.well, style: .continuous) + .strokeBorder(Theme.Palette.borderStandard, lineWidth: 1) + ) + } +} + +extension View { + /// Applies the recessed readout-well surface: wellSink fill + 2px inner top-shadow + white-opacity border. + func readoutWellSurface() -> some View { + modifier(ReadoutWellSurface()) + } +} + +// MARK: - Previews + +#Preview("OpaquePanel") { + VStack(spacing: Theme.Space.md) { + OpaquePanel { + Text("Loss: 0.6121") + .font(Theme.Fonts.readout) + .foregroundStyle(Theme.Palette.ink) + .padding(Theme.Space.lg) + } + + HStack { + Text("INSTRUMENT PANEL — opaque, 10pt card, white-opacity hairline") + .instrumentLabel() + .padding(Theme.Space.lg) + } + .instrumentPanel() + + HStack { + Text("WELL SINK — recessed surface") + .instrumentLabel() + .padding(Theme.Space.sm) + } + .readoutWellSurface() + .padding(Theme.Space.lg) + .instrumentPanel() + } + .background(Theme.Palette.canvas) + .frame(width: 360) + .padding(Theme.Space.lg) +} diff --git a/apps/macos/Sources/LatticeStudio/Components/ParamRow.swift b/apps/macos/Sources/LatticeStudio/Components/ParamRow.swift new file mode 100644 index 0000000000..b929ef4e8a --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Components/ParamRow.swift @@ -0,0 +1,356 @@ +import SwiftUI + +// MARK: - Primitive 8: ParamRow +// +// Label (left, dim) + control (right) on one hairline-ruled line. +// Forms are stacks of ParamRows — never boxed, never cardd. +// +// Variants: +// - Text/label only (read-only) +// - TextField (editable string) +// - Slider (with live mono readout showing current value in the track) +// - Picker / segmented (for method selection) +// - Toggle + +// MARK: Text ParamRow + +/// A read-only label + value display on a hairline-ruled line. +/// +/// ```swift +/// ParamRow(label: "MODEL", value: "qwen3.5-0.8b") +/// ``` +struct ParamRow: View { + let label: String + let value: String + + var body: some View { + HStack { + Text(label) + .instrumentLabel() + Spacer() + Text(value) + .font(Theme.Fonts.readout) + .foregroundStyle(Theme.Palette.ink) + .monospacedDigit() + } + .frame(height: Theme.Space.rowHeight) + .padding(.horizontal, Theme.Space.lg) + .overlay(alignment: .bottom) { + Theme.Palette.hairline.frame(height: 1) + } + } +} + +// MARK: TextField ParamRow + +/// An editable label + TextField on a hairline-ruled line. +/// +/// ```swift +/// ParamRowField(label: "DATASET", text: $datasetPath, placeholder: "path/to/data.jsonl") +/// ``` +struct ParamRowField: View { + let label: String + @Binding var text: String + var placeholder: String = "" + + var body: some View { + HStack { + Text(label) + .instrumentLabel() + Spacer() + TextField(placeholder, text: $text) + .font(Theme.Fonts.readout) + .foregroundStyle(Theme.Palette.ink) + .monospacedDigit() + .multilineTextAlignment(.trailing) + .frame(maxWidth: 240) + .textFieldStyle(.plain) + } + .frame(height: Theme.Space.rowHeight) + .padding(.horizontal, Theme.Space.lg) + .overlay(alignment: .bottom) { + Theme.Palette.hairline.frame(height: 1) + } + } +} + +// MARK: Slider ParamRow + +/// A label + Slider row where the current value appears as a live mono readout beside the track. +/// +/// ```swift +/// ParamRowSlider(label: "RANK", value: $rank, range: 1...64, step: 1, format: "%.0f") +/// ParamRowSlider(label: "LR", value: $lr, range: 1e-5...1e-2, format: "%.2e") +/// ``` +struct ParamRowSlider: View { + let label: String + @Binding var value: Double + let range: ClosedRange + var step: Double = 0 + var format: String = "%.4g" + var unit: String = "" + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private var formattedValue: String { + String(format: format, value) + (unit.isEmpty ? "" : " \(unit)") + } + + var body: some View { + HStack(spacing: Theme.Space.sm) { + Text(label) + .instrumentLabel() + .lineLimit(1) + .minimumScaleFactor(0.8) + .frame(width: 80, alignment: .leading) + + // Slider with live mono readout + HStack(spacing: Theme.Space.sm) { + if step > 0 { + Slider(value: $value, in: range, step: step) + } else { + Slider(value: $value, in: range) + } + + // Live mono readout in the track + Text(formattedValue) + .font(Theme.Fonts.readout) + .foregroundStyle(Theme.Palette.ink) + .monospacedDigit() + .frame(width: 72, alignment: .trailing) + .contentTransition(reduceMotion ? .identity : .numericText()) + .animation( + reduceMotion ? nil : .easeOut(duration: Theme.Motion.tick), + value: formattedValue + ) + } + } + .frame(height: Theme.Space.rowHeightComfortable) + .padding(.horizontal, Theme.Space.lg) + .overlay(alignment: .bottom) { + Theme.Palette.hairline.frame(height: 1) + } + } +} + +// MARK: Stepper ParamRow + +/// A label + typed numeric entry with optional native Stepper buttons on a hairline-ruled line. +/// +/// When `step > 0`, a native Stepper appears to the right of the field. When `step == 0`, +/// only the typed field is shown — correct for free-range values like learning rate. +/// +/// ```swift +/// ParamRowStepper(label: "STEPS", value: $steps, range: 1...500, step: 1, format: "%.0f") +/// ParamRowStepper(label: "LR", value: $lr, range: 1e-5...5e-3, format: "%.1e") +/// ParamRowStepper(label: "FIRST LAYER", value: $firstLayer, range: 0...23, step: 1, +/// format: "%.0f", caption: "adapts layers 19–23 · 5 layers") +/// ``` +struct ParamRowStepper: View { + let label: String + @Binding var value: Double + let range: ClosedRange + var step: Double = 0 + var format: String = "%.0f" + var unit: String = "" + var caption: String? = nil + + @State private var text: String = "" + @FocusState private var focused: Bool + + private func commit() { + guard let parsed = Double(text.trimmingCharacters(in: .whitespaces)) else { + text = String(format: format, value) + return + } + let clamped = min(max(parsed, range.lowerBound), range.upperBound) + value = clamped + text = String(format: format, clamped) + } + + private var primaryRow: some View { + HStack(spacing: Theme.Space.sm) { + Text(label) + .instrumentLabel() + Spacer() + TextField("", text: $text) + .font(Theme.Fonts.readout) + .foregroundStyle(Theme.Palette.ink) + .monospacedDigit() + .multilineTextAlignment(.trailing) + .textFieldStyle(.plain) + .frame(width: 88) + .focused($focused) + .onSubmit { commit() } + .onChange(of: focused) { _, isFocused in + if !isFocused { commit() } + } + .onChange(of: value) { _, newVal in + if !focused { text = String(format: format, newVal) } + } + if !unit.isEmpty { + Text(unit) + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + } + if step > 0 { + Stepper("", value: $value, in: range, step: step) + .labelsHidden() + } + } + .frame(height: Theme.Space.rowHeightComfortable) + } + + var body: some View { + Group { + if let caption { + VStack(alignment: .leading, spacing: 2) { + primaryRow + Text(caption) + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, Theme.Space.xs) + } + } else { + primaryRow + } + } + .padding(.horizontal, Theme.Space.lg) + .overlay(alignment: .bottom) { + Theme.Palette.hairline.frame(height: 1) + } + .onAppear { + text = String(format: format, value) + } + } +} + +// MARK: Picker ParamRow + +/// A label + segmented picker on a hairline-ruled line. +/// +/// ```swift +/// ParamRowPicker(label: "METHOD", options: ["Q4", "QuaRot"], selection: $method) +/// ``` +struct ParamRowPicker: View { + let label: String + let options: [String] + @Binding var selection: String + + var body: some View { + HStack { + Text(label) + .instrumentLabel() + Spacer() + Picker("", selection: $selection) { + ForEach(options, id: \.self) { opt in + Text(opt).tag(opt) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 200) + .labelsHidden() + } + .frame(height: Theme.Space.rowHeightComfortable) + .padding(.horizontal, Theme.Space.lg) + .overlay(alignment: .bottom) { + Theme.Palette.hairline.frame(height: 1) + } + } +} + +// MARK: Menu ParamRow + +/// A label + dropdown menu picker on a hairline-ruled line. +/// +/// Use this (not `ParamRowPicker`) for selections with many or long options — e.g. MODEL — +/// where a segmented control would overflow the panel. The dropdown stays a fixed-width button +/// showing the current selection and never grows with the option count. +/// +/// ```swift +/// ParamRowMenu(label: "MODEL", options: modelNames, selection: $selectedModelName) +/// ``` +struct ParamRowMenu: View { + let label: String + let options: [String] + @Binding var selection: String + + var body: some View { + HStack { + Text(label) + .instrumentLabel() + Spacer() + Picker("", selection: $selection) { + ForEach(options, id: \.self) { opt in + Text(opt).tag(opt) + } + } + .pickerStyle(.menu) + .labelsHidden() + .font(Theme.Fonts.readout) + .fixedSize() + } + .frame(height: Theme.Space.rowHeightComfortable) + .padding(.horizontal, Theme.Space.lg) + .overlay(alignment: .bottom) { + Theme.Palette.hairline.frame(height: 1) + } + } +} + +// MARK: Toggle ParamRow + +/// A label + toggle on a hairline-ruled line. +/// +/// ```swift +/// ParamRowToggle(label: "COMFORTABLE ROWS", isOn: $comfortable) +/// ``` +struct ParamRowToggle: View { + let label: String + @Binding var isOn: Bool + + var body: some View { + HStack { + Text(label) + .instrumentLabel() + Spacer() + Toggle("", isOn: $isOn) + .toggleStyle(.switch) + .labelsHidden() + } + .frame(height: Theme.Space.rowHeight) + .padding(.horizontal, Theme.Space.lg) + .overlay(alignment: .bottom) { + Theme.Palette.hairline.frame(height: 1) + } + } +} + +// MARK: - Previews + +#Preview("ParamRow") { + @Previewable @State var rank: Double = 8 + @Previewable @State var lr: Double = 2e-4 + @Previewable @State var steps: Double = 25 + @Previewable @State var firstLayer: Double = 19 + @Previewable @State var method: String = "Q4" + @Previewable @State var dataset: String = "claude-lora.jsonl" + @Previewable @State var comfortable: Bool = false + + return VStack(spacing: 0) { + ParamRow(label: "MODEL", value: "qwen3.5-0.8b") + ParamRow(label: "LAYERS", value: "18 GDN · 6 GQA") + ParamRowSlider(label: "RANK", value: $rank, range: 1...64, step: 1, format: "%.0f") + ParamRowSlider(label: "LR", value: $lr, range: 1e-5...1e-2, format: "%.2e") + ParamRowStepper(label: "STEPS", value: $steps, range: 1...500, step: 1, format: "%.0f", + caption: "stepper with caption · \(Int(steps)) steps") + ParamRowStepper(label: "LR (TYPED)", value: $lr, range: 1e-5...5e-3, format: "%.1e") + ParamRowPicker(label: "METHOD", options: ["Q4", "QuaRot"], selection: $method) + ParamRowField(label: "DATASET", text: $dataset, placeholder: "path/to/data.jsonl") + ParamRowToggle(label: "COMFORTABLE ROWS", isOn: $comfortable) + } + .instrumentPanel() + .background(Theme.Palette.canvas) + .frame(width: 400) +} diff --git a/apps/macos/Sources/LatticeStudio/Components/ReadoutWell.swift b/apps/macos/Sources/LatticeStudio/Components/ReadoutWell.swift new file mode 100644 index 0000000000..5ff90799aa --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Components/ReadoutWell.swift @@ -0,0 +1,151 @@ +import SwiftUI + +// MARK: - Primitive 2: ReadoutWell +// +// The atomic readout unit: +// - 11pt all-caps dim label (LOSS, TOK/S, GRAD-NORM…) +// - Tabular-mono value at Theme.Fonts.wellValue (15pt medium) +// - Unit string (dim, 11pt) +// - Optional delta caret (▲/▼): teal = good (falling), amber = bad (rising) +// - All numerals use .monospacedDigit() +// - Recessed machined-in well surface + +/// Direction of movement for the delta caret. +enum DeltaDirection { + case down // value falling — teal (good for loss/PPL) + case up // value rising — amber (bad for loss/PPL; good for tok/s) + case none + + var glyph: String { + switch self { + case .down: "▼" + case .up: "▲" + case .none: "" + } + } + + var color: Color { + switch self { + case .down: Theme.Palette.signal + case .up: Theme.Palette.amber + case .none: .clear + } + } +} + +/// A single readout well: label + tabular-mono value + unit + optional delta caret. +/// +/// ```swift +/// ReadoutWell(label: "LOSS", value: "0.6121", unit: "", delta: .init("▼0.004", .down)) +/// ReadoutWell(label: "TOK/S", value: "1820", unit: "t/s") +/// ReadoutWell(label: "LR-NOW", value: "1.81e-4") +/// ``` +struct ReadoutWell: View { + let label: String + let value: String + let unit: String + let delta: DeltaInfo? + let minHeight: CGFloat? + + struct DeltaInfo { + let text: String + let direction: DeltaDirection + + init(_ text: String, _ direction: DeltaDirection) { + self.text = text + self.direction = direction + } + } + + init(label: String, value: String, unit: String = "", delta: DeltaInfo? = nil, minHeight: CGFloat? = nil) { + self.label = label + self.value = value + self.unit = unit + self.delta = delta + self.minHeight = minHeight + } + + var body: some View { + VStack(alignment: .leading, spacing: 3) { + // 11pt all-caps dim label + Text(label) + .instrumentLabel() + + HStack(alignment: .firstTextBaseline, spacing: 4) { + // Tabular-mono value — .monospacedDigit() ensures fixed-width digits + Text(value) + .font(Theme.Fonts.wellValue) + .foregroundStyle(Theme.Palette.ink) + .monospacedDigit() + .contentTransition(.numericText()) + + // Unit (dim, 11pt) + if !unit.isEmpty { + Text(unit) + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + } + + // Delta caret + if let delta = delta, delta.direction != .none { + Text("\(delta.direction.glyph)\(delta.text)") + .font(Theme.Fonts.cell) + .foregroundStyle(delta.direction.color) + .monospacedDigit() + .animation(.easeOut(duration: Theme.Motion.tick), value: delta.direction.glyph) + } + } + } + .padding(.horizontal, Theme.Space.md) + .padding(.vertical, Theme.Space.sm) + .wellMinHeight(minHeight) + .readoutWellSurface() + } +} + +// MARK: - Min-height helper (opt-in; nil ⇒ intrinsic height, byte-identical to prior behavior) + +private extension View { + @ViewBuilder + func wellMinHeight(_ height: CGFloat?) -> some View { + if let height { + frame(maxWidth: .infinity, minHeight: height, alignment: .topLeading) + } else { + self + } + } +} + +// MARK: - Previews + +#Preview("ReadoutWell") { + HStack(spacing: Theme.Space.sm) { + ReadoutWell( + label: "LOSS", + value: "0.6121", + unit: "", + delta: ReadoutWell.DeltaInfo("0.004", .down) + ) + ReadoutWell( + label: "TOK/S", + value: "1820", + unit: "t/s", + delta: ReadoutWell.DeltaInfo("12", .up) + ) + ReadoutWell( + label: "LR-NOW", + value: "1.81e-4" + ) + ReadoutWell( + label: "GRAD", + value: "0.93" + ) + ReadoutWell( + label: "ETA", + value: "4m 12s" + ) + } + .padding(Theme.Space.lg) + .instrumentPanel() + .background(Theme.Palette.canvas) +} diff --git a/apps/macos/Sources/LatticeStudio/Components/StripChart.swift b/apps/macos/Sources/LatticeStudio/Components/StripChart.swift new file mode 100644 index 0000000000..f119096cc3 --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Components/StripChart.swift @@ -0,0 +1,242 @@ +import SwiftUI +import Charts + +// MARK: - Primitive 4: StripChart (Oscilloscope) +// +// The live training oscilloscope. +// +// Visual spec: +// - LineMark: 1.5px Theme.Palette.signal stroke +// - AreaMark: Theme.Palette.signalGlow gradient beneath the line +// - RuleMark: 1px teal now-cursor at the latest step (or scrub position) +// - Ghost baseline series: dashed inkDim line (optional, for base PPL / comparison) +// - Mono axis ticks, hairline grid, NO legend +// - Draggable scrub line: reports hovered step via scrubStep binding +// - Throttle: caller is responsible for throttling updates to ~20Hz (chartCommitHz) +// - Respects accessibilityReduceMotion: instant updates (no chart animation) + +/// Which value series to plot on the primary axis. +enum StripSeries { + case loss + case valLoss + case gradNorm + + var label: String { + switch self { + case .loss: "LOSS" + case .valLoss: "VAL LOSS" + case .gradNorm: "GRAD NORM" + } + } + + func value(from point: TrainPoint) -> Double? { + switch self { + case .loss: point.loss + case .valLoss: point.valLoss + case .gradNorm: point.gradNorm + } + } +} + +/// The oscilloscope strip chart for training metrics. +/// +/// ```swift +/// StripChart( +/// points: store.liveRun?.points ?? [], +/// series: .loss, +/// scrubStep: $scrubStep +/// ) +/// +/// // With ghost baseline (quantize PPL ghost) +/// StripChart( +/// points: points, +/// series: .valLoss, +/// ghostBaseline: 15.86, +/// ghostLabel: "base PPL", +/// scrubStep: $scrubStep +/// ) +/// ``` +struct StripChart: View { + let points: [TrainPoint] + var series: StripSeries = .loss + var ghostBaseline: Double? = nil + var ghostLabel: String = "base" + @Binding var scrubStep: Int? + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var isDragging = false + + var body: some View { + Chart { + // Ghost baseline (dashed inkDim) — e.g., base PPL on quantize screen + if let baseline = ghostBaseline { + RuleMark(y: .value("Ghost", baseline)) + .foregroundStyle(Theme.Palette.inkDim.opacity(0.6)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [4, 4])) + .annotation(position: .trailing, alignment: .leading) { + Text(ghostLabel) + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + } + } + + // Area fill (signalGlow gradient beneath line) + ForEach(validPoints) { point in + if let yVal = series.value(from: point) { + AreaMark( + x: .value("Step", point.step), + y: .value(series.label, yVal) + ) + .foregroundStyle( + LinearGradient( + colors: [Theme.Palette.signalGlow, .clear], + startPoint: .top, + endPoint: .bottom + ) + ) + } + } + + // Primary line (1.5px signal teal) + ForEach(validPoints) { point in + if let yVal = series.value(from: point) { + LineMark( + x: .value("Step", point.step), + y: .value(series.label, yVal) + ) + .foregroundStyle(Theme.Palette.signal) + .lineStyle(StrokeStyle(lineWidth: 1.5)) + } + } + + // Val loss overlay (dashed, inkDim) when primary is .loss + if series == .loss { + ForEach(validPoints.filter { $0.valLoss != nil }) { point in + if let val = point.valLoss { + LineMark( + x: .value("Step", point.step), + y: .value("VAL", val) + ) + .foregroundStyle(Theme.Palette.inkDim.opacity(0.7)) + .lineStyle(StrokeStyle(lineWidth: 1, dash: [3, 3])) + } + } + } + + // Now-cursor (1px teal RuleMark at current/scrub step) + if let nowStep = nowCursorStep { + RuleMark(x: .value("Now", nowStep)) + .foregroundStyle(Theme.Palette.signal.opacity(0.8)) + .lineStyle(StrokeStyle(lineWidth: 1)) + } + } + // Axis styling: mono ticks, hairline grid, no legend + .chartXAxis { + AxisMarks(values: .automatic(desiredCount: 5)) { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Theme.Palette.hairline) + AxisTick(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Theme.Palette.hairline) + AxisValueLabel() + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + } + } + .chartYAxis { + AxisMarks(values: .automatic(desiredCount: 4)) { _ in + AxisGridLine(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Theme.Palette.hairline) + AxisTick(stroke: StrokeStyle(lineWidth: 0.5)) + .foregroundStyle(Theme.Palette.hairline) + AxisValueLabel() + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + } + } + .chartLegend(.hidden) + .chartBackground { _ in + Theme.Palette.panel + } + // Scrub-to-freeze: drag gesture on the chart overlay + .chartOverlay { proxy in + GeometryReader { geo in + Rectangle() + .fill(.clear) + .contentShape(Rectangle()) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + isDragging = true + let xPos = value.location.x - geo.frame(in: .local).minX + if let step: Int = proxy.value(atX: xPos) { + // Clamp to available range + let minStep = validPoints.first?.step ?? 0 + let maxStep = validPoints.last?.step ?? 0 + scrubStep = max(minStep, min(maxStep, step)) + } + } + .onEnded { _ in + isDragging = false + // Release scrub — caller can decide to keep or clear + } + ) + } + } + .animation(reduceMotion ? nil : .easeOut(duration: 1.0 / Theme.Motion.chartCommitHz), value: points.count) + } + + private var validPoints: [TrainPoint] { + points.filter { series.value(from: $0) != nil } + } + + private var nowCursorStep: Int? { + scrubStep ?? validPoints.last?.step + } +} + +// MARK: - Previews + +#Preview("StripChart") { + @Previewable @State var scrubStep: Int? = nil + + let samplePoints: [TrainPoint] = (0..<60).map { i in + let t = Double(i) / 60.0 + return TrainPoint( + step: i * 10, + loss: 2.5 * exp(-t * 2.5) + 0.5 + Double.random(in: -0.02...0.02), + valLoss: 2.4 * exp(-t * 2.3) + 0.55 + Double.random(in: -0.015...0.015), + gradNorm: 1.2 * exp(-t) + 0.3, + lr: 2e-4 * (1.0 - t * 0.5), + tokS: 1800 + Double.random(in: -50...50) + ) + } + + return VStack(alignment: .leading, spacing: 0) { + Text("STRIP CHART — LOSS + VAL LOSS") + .instrumentLabel() + .padding(.horizontal, Theme.Space.lg) + .padding(.top, Theme.Space.lg) + + StripChart( + points: samplePoints, + series: .loss, + scrubStep: Binding( + get: { scrubStep }, + set: { scrubStep = $0 } + ) + ) + .frame(height: 180) + .padding(Theme.Space.lg) + + if let s = scrubStep { + Text("Scrub: step \(s)") + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + .padding(.horizontal, Theme.Space.lg) + .padding(.bottom, Theme.Space.sm) + } + } + .instrumentPanel() + .background(Theme.Palette.canvas) + .frame(width: 520) +} diff --git a/apps/macos/Sources/LatticeStudio/Screens/ChatScreen.swift b/apps/macos/Sources/LatticeStudio/Screens/ChatScreen.swift new file mode 100644 index 0000000000..973e7bcc6a --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Screens/ChatScreen.swift @@ -0,0 +1,975 @@ +import SwiftUI + +// MARK: - 04 CHAT +// +// Single-mode chat interface with run history. +// +// Layout (two zones inside ScreenScaffold): +// • TAB PICKER — segmented control "Chat | History" (max 240pt, left-aligned). +// In Chat tab: "New conversation" on the right. +// • TRANSCRIPT — scrollable column of ChatTurn bubbles (Chat tab), max-width 920pt. +// OR run-history DataTable + live banner (History tab). +// • COMPOSER — rounded TextEditor with Send/Stop as a 30×30 in-composer button +// (bottom-trailing overlay). Chat tab only; hidden in History tab. +// +// State ownership: +// All transcript + selection state lives in AppStore (hoisted from @State) so it +// survives NavigationSplitView teardown when Ocean navigates to another screen. +// ChatScreen reads/writes via @Bindable var store. +// +// Generation model: +// generate_lora prints output ATOMICALLY. A "message" lifecycle is: +// launch via store.runGenerate(GenConfig) → status == .running → +// collect store.liveRun.genText (cumulative) → status == .done → copy into pending turn. +// +// Completion detection: +// Each run carries an onComplete hook (set in send()/retryTurn()) that +// AppStore.finish() fires when the subprocess exits — independent of ChatScreen being +// mounted, so a turn still resolves if Ocean navigates away mid-generation. It also +// fires with a failed status when another screen's launch() supersedes the run. +// store.chatAwaitingTurnID: UUID? tracks which turn receives the streamed text. +// +// N-way generation compare (A/B and beyond) lives in EvalScreen (Stage 3). + +// MARK: - ChatTab + +private enum ChatTab: String, CaseIterable { + case chat = "Chat" + case history = "History" +} + +// MARK: - Typing indicator dots + +private struct TypingDots: View { + @State private var phase: Int = 0 + + var body: some View { + HStack(spacing: 4) { + ForEach(0..<3, id: \.self) { i in + Circle() + .fill(Theme.Palette.textTertiary) + .frame(width: 6, height: 6) + .scaleEffect(phase == i ? 1.25 : 0.85) + .animation( + .easeInOut(duration: 0.4) + .repeatForever(autoreverses: true) + .delay(Double(i) * 0.15), + value: phase + ) + } + } + .onAppear { phase = 0 } + .task { + try? await Task.sleep(nanoseconds: 50_000_000) + phase = 1 + } + } +} + +// MARK: - ChatScreen + +struct ChatScreen: View { + @Bindable var store: AppStore + + // MARK: Local state (ephemeral; does NOT need to survive navigation) + + // Tab selection: Chat playground vs History (run notebook) + @State private var chatTab: ChatTab = .chat + // History tab: selected run (owned here so selection survives tab switches within Chat) + @State private var selectedRunID: String? + // Composer text is ephemeral — clearing on navigation is acceptable + @State private var composerText: String = "" + + // MARK: Store-backed derived helpers + + // CPU mode (generate_lora): bf16 only — generate_lora loads from_safetensors. + // GPU mode (chat_metal): bf16 + q4 — Metal path supports both formats. + // Embedding models are never generative; excluded in both modes. + private var chatModels: [ModelInfo] { + if store.chatUseGPU { + store.models.filter { $0.format == .bf16 || $0.format == .q4 } + } else { + store.models.filter { $0.format == .bf16 } + } + } + + private var selectedModel: ModelInfo? { + if let m = chatModels.first(where: { $0.name == store.chatSelectedModelName }) { return m } + return chatModels.first + } + + // All known adapters for the selected model (compatible + incompatible). + // The picker surfaces both so the user sees what exists and why some are unavailable. + private var allAdapters: [AdapterInfo] { + selectedModel?.adapters ?? [] + } + + private var adapterOptions: [String] { + // All adapters appear in the picker — usable ones selectable, unusable ones shown + // with a reason so the user is never left wondering why an adapter is "missing". + return ["none"] + allAdapters.map(\.name) + } + + private var selectedAdapter: AdapterInfo? { + guard store.chatSelectedAdapterName != "none" else { return nil } + // Only return an adapter that has a resolvable weight file — otherwise generation + // would silently run the base model under the adapter's label. + return selectedModel?.adapters.first { + $0.name == store.chatSelectedAdapterName && $0.weightFile != nil + } + } + + /// Compatibility check for display. Returns nil when the adapter is usable, or a short + /// human-readable reason string when it cannot be loaded by `generate_lora`. + private func incompatibilityReason(for adapter: AdapterInfo) -> String? { + if adapter.weightFile == nil { + return "no weight file (.safetensors not found)" + } + return nil + } + + // isRunning: true while a single-mode run is in-flight. + private var isRunning: Bool { + store.liveRun(matching: [.chat])?.status == .running + } + + private var canSend: Bool { + !composerText.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !isRunning + } + + // MARK: Subtitle (model + disk availability; honest — never claims residency) + + private var subtitle: String { + guard let model = selectedModel else { return "no models found" } + // "ready" when the model directory exists on disk; never claim "loaded" since + // the app shells out a fresh subprocess per generation and nothing stays in memory. + let diskStatus = FileManager.default.fileExists(atPath: model.path.path) ? "ready" : "not found" + let adapterLabel = selectedAdapter?.name ?? "no adapter" + return "\(model.name) · \(diskStatus) · \(adapterLabel)" + } + + // MARK: Body + + var body: some View { + ScreenScaffold(screen: .chat, subtitle: subtitle) { + VStack(spacing: 0) { + // Tab / mode picker row + HStack { + // Chat | History tab picker + Picker("", selection: $chatTab) { + ForEach(ChatTab.allCases, id: \.self) { tab in + Text(tab.rawValue).tag(tab) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(maxWidth: 240) + + Spacer() + + // "New conversation" — only visible in Chat tab + if chatTab == .chat { + // New conversation — clears transcript, keeps model/adapter/settings + Button { + newConversation() + } label: { + Label("New", systemImage: "square.and.pencil") + .font(Theme.Fonts.body) + } + .buttonStyle(LatticeSecondaryButtonStyle()) + .help("Start a new conversation (keeps model & settings)") + .disabled(store.chatTurns.isEmpty) + } + } + .padding(.horizontal, Theme.Space.lg) + .padding(.top, Theme.Space.md) + .padding(.bottom, Theme.Space.sm) + + // Tab content + switch chatTab { + case .chat: + transcriptArea + composerBar + case .history: + RunsContent(store: store, selectedRunID: $selectedRunID) + } + } + } + .inspector(isPresented: $store.inspectorPresented) { + switch chatTab { + case .chat: + settingsInspector + .inspectorColumnWidth(min: 280, ideal: 320, max: 380) + case .history: + RunsContent(store: store, selectedRunID: $selectedRunID) + .inspectorPanel + .inspectorColumnWidth(min: 260, ideal: 300, max: 320) + } + } + .onAppear { applyDefaults() } + .onChange(of: store.models) { _, _ in applyDefaults() } + .onChange(of: store.chatUseGPU) { _, _ in + // Backend toggle changes the eligible model set (CPU drops Q4) — re-validate + // the selection so the picker never shows a model that isn't in chatModels. + applyDefaults() + } + .onChange(of: store.chatSelectedModelName) { _, _ in + // Reset adapter when model changes. + store.chatSelectedAdapterName = "none" + } + .onChange(of: store.liveRun?.genText) { _, newText in + // Route streaming tokens to the awaiting turn. + guard store.liveRun?.kind == .chat else { return } + guard let turnID = store.chatAwaitingTurnID, + let idx = store.chatTurns.firstIndex(where: { $0.id == turnID }) + else { return } + store.chatTurns[idx].responseText = newText ?? "" + } + } + + // MARK: - Settings inspector (inline; binds directly to store) + + @ViewBuilder + private var settingsInspector: some View { + ScrollView(.vertical, showsIndicators: false) { + VStack(spacing: 0) { + + // ── TARGET ────────────────────────────────────────────────────────── + + Text("Target") + .instrumentLabel() + .padding(.horizontal, Theme.Space.lg) + .padding(.top, Theme.Space.lg) + .padding(.bottom, Theme.Space.sm) + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(spacing: 0) { + // Model picker — menu (may have many options; segmented would overflow) + ParamRowMenu( + label: "Model", + options: chatModels.map(\.name), + selection: Binding( + get: { store.chatSelectedModelName }, + set: { store.chatSelectedModelName = $0 } + ) + ) + .disabled(chatModels.count <= 1) + + // Backend — segmented CPU/GPU (two options; segmented is correct) + ParamRowPicker( + label: "Backend", + options: ["CPU bf16", "GPU Metal"], + selection: Binding( + get: { store.chatUseGPU ? "GPU Metal" : "CPU bf16" }, + set: { store.chatUseGPU = ($0 == "GPU Metal") } + ) + ) + + // Disk status — honest, never claims "loaded" + if let model = selectedModel { + let exists = FileManager.default.fileExists(atPath: model.path.path) + HStack(spacing: 6) { + GatePill(exists ? .pass : .fail, label: exists ? "READY" : "NOT FOUND") + if let liveRun = store.liveRun(matching: [.chat]), + liveRun.status == .running { + GatePill(.run, label: liveRun.genText.isEmpty ? "LOADING" : "GEN") + } + Spacer() + } + .frame(height: Theme.Space.rowHeight) + .padding(.horizontal, Theme.Space.lg) + .overlay(alignment: .bottom) { + Theme.Palette.hairline.frame(height: 1) + } + } + + // Adapter picker — all adapters shown; incompatible ones labelled + adapterPickerRow + } + .instrumentPanel() + .padding(.horizontal, Theme.Space.lg) + + // ── SAMPLING ──────────────────────────────────────────────────────── + + Text("Sampling") + .instrumentLabel() + .padding(.horizontal, Theme.Space.lg) + .padding(.top, Theme.Space.lg) + .padding(.bottom, Theme.Space.sm) + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(spacing: 0) { + // Temperature — real flag: --temperature (default 0.7) + ParamRowField( + label: "Temperature", + text: Binding( + get: { store.chatTempText }, + set: { store.chatTempText = $0 } + ), + placeholder: "0.7" + ) + + // Top-k — real flag: --top-k (default 50) + ParamRowField( + label: "Top-k", + text: Binding( + get: { store.chatTopKText }, + set: { store.chatTopKText = $0 } + ), + placeholder: "50" + ) + + // Top-p — real flag: --top-p (default 0.9) + ParamRowField( + label: "Top-p", + text: Binding( + get: { store.chatTopPText }, + set: { store.chatTopPText = $0 } + ), + placeholder: "0.9" + ) + + // Repetition penalty — real flag: --repetition-penalty (default 1.1) + ParamRowField( + label: "Rep. penalty", + text: Binding( + get: { store.chatRepPenaltyText }, + set: { store.chatRepPenaltyText = $0 } + ), + placeholder: "1.1" + ) + } + .instrumentPanel() + .padding(.horizontal, Theme.Space.lg) + + // ── GENERATION ────────────────────────────────────────────────────── + + Text("Generation") + .instrumentLabel() + .padding(.horizontal, Theme.Space.lg) + .padding(.top, Theme.Space.lg) + .padding(.bottom, Theme.Space.sm) + .frame(maxWidth: .infinity, alignment: .leading) + + VStack(spacing: 0) { + // Max tokens — real flag: --max-tokens (default 256) + ParamRowField( + label: "Max tokens", + text: Binding( + get: { store.chatMaxTokensText }, + set: { store.chatMaxTokensText = $0 } + ), + placeholder: "256" + ) + + // Seed — real flag: --seed (nil = non-deterministic) + ParamRowField( + label: "Seed", + text: Binding( + get: { store.chatSeedText }, + set: { store.chatSeedText = $0 } + ), + placeholder: "random" + ) + } + .instrumentPanel() + .padding(.horizontal, Theme.Space.lg) + + // ── ACTIONS ───────────────────────────────────────────────────────── + + Button("New conversation") { + newConversation() + } + .buttonStyle(LatticeSecondaryButtonStyle()) + .disabled(store.chatTurns.isEmpty) + .padding(.horizontal, Theme.Space.lg) + .padding(.top, Theme.Space.lg) + .padding(.bottom, Theme.Space.lg) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .background(Theme.Palette.panel) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + + /// Adapter picker row that surfaces ALL adapters — compatible ones selectable, + /// incompatible ones shown with a reason so the user understands why they cannot be used. + @ViewBuilder + private var adapterPickerRow: some View { + HStack { + Text("Adapter") + .instrumentLabel() + Spacer() + Menu { + // "none" option always first + Button { + store.chatSelectedAdapterName = "none" + } label: { + if store.chatSelectedAdapterName == "none" { + Label("none", systemImage: "checkmark") + } else { + Text("none") + } + } + + if !allAdapters.isEmpty { + Divider() + + ForEach(allAdapters) { adapter in + let reason = incompatibilityReason(for: adapter) + let isSelected = store.chatSelectedAdapterName == adapter.name + + if let reason { + // Incompatible: shown but disabled with a reason label. + // The user sees it exists and WHY it cannot be used. + Text("\(adapter.name) — \(reason)") + .foregroundStyle(Theme.Palette.textTertiary) + .font(Theme.Fonts.caption) + } else { + Button { + store.chatSelectedAdapterName = adapter.name + } label: { + if isSelected { + Label(adapter.name, systemImage: "checkmark") + } else { + Text(adapter.name) + } + } + } + } + } + } label: { + // Label shows current selection; incompatible selection shows "(incompatible)" hint + let selName = store.chatSelectedAdapterName + let isIncompatible: Bool = { + guard selName != "none", + let a = allAdapters.first(where: { $0.name == selName }) + else { return false } + return incompatibilityReason(for: a) != nil + }() + Text(isIncompatible ? "\(selName) (!)" : selName) + .font(Theme.Fonts.body) + .foregroundStyle(isIncompatible ? Theme.Palette.amber : Theme.Palette.ink) + } + .menuStyle(.borderlessButton) + .fixedSize() + } + .frame(height: Theme.Space.rowHeight) + .padding(.horizontal, Theme.Space.lg) + .overlay(alignment: .bottom) { + Theme.Palette.hairline.frame(height: 1) + } + } + + // MARK: - Single-mode transcript + + @ViewBuilder + private var transcriptArea: some View { + if store.chatTurns.isEmpty { + emptyState + } else { + ScrollViewReader { proxy in + ScrollView(.vertical) { + LazyVStack(alignment: .leading, spacing: Theme.Space.lg) { + ForEach(store.chatTurns) { turn in + turnView(turn) + .id(turn.id) + } + } + .padding(.horizontal, Theme.Space.lg) + .padding(.vertical, Theme.Space.xl) + .frame(maxWidth: Theme.Space.chatMaxWidth) + .frame(maxWidth: .infinity) + } + .onChange(of: store.chatTurns.count) { _, _ in + if let last = store.chatTurns.last { + withAnimation(.easeOut(duration: Theme.Motion.focus)) { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } + .onChange(of: store.chatTurns.last?.responseText) { _, _ in + if let last = store.chatTurns.last { + proxy.scrollTo(last.id, anchor: .bottom) + } + } + } + } + } + + private var emptyState: some View { + VStack { + Spacer() + .frame(minHeight: 0) + .frame(maxHeight: .infinity) + .layoutPriority(-1) + EmptyStateView( + systemImage: "text.bubble", + title: "Try it out", + message: "Send a message to run this model locally on your Mac." + ) + .frame(maxWidth: .infinity) + Spacer() + .frame(minHeight: 0) + .frame(maxHeight: .infinity) + } + } + + // MARK: - Turn bubbles (single mode) + + @ViewBuilder + private func turnView(_ turn: ChatTurn) -> some View { + VStack(alignment: .leading, spacing: Theme.Space.sm) { + // User bubble — right-aligned, neutral panel surface (no teal) + HStack { + Spacer(minLength: Theme.Space.xxl) + Text(turn.prompt) + .font(Theme.Fonts.body) + .foregroundStyle(Theme.Palette.ink) + .multilineTextAlignment(.trailing) + .textSelection(.enabled) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous) + .fill(Theme.Palette.panel) + .overlay( + RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous) + .strokeBorder(Theme.Palette.hairline, lineWidth: 1) + ) + ) + } + + // Assistant bubble — left-aligned, surfaceRaised fill + HStack(alignment: .top) { + VStack(alignment: .leading, spacing: 4) { + if turn.status == .running && turn.responseText.isEmpty { + // Typing indicator: distinguish "loading model" from "generating" + HStack(spacing: Theme.Space.xs) { + TypingDots() + let liveText = store.liveRun(matching: [.chat])?.genText ?? "" + Text(liveText.isEmpty ? "Loading model…" : "Thinking…") + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.Palette.textSecondary) + } + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(assistantBubbleBackground) + + } else if turn.status == .failed && turn.responseText.isEmpty { + // Failed with NO output — show the engine reason, never silent "(no output)" + let reason = turn.errorMessage ?? "Generation failed." + Text(reason) + .font(Theme.Fonts.body) + .foregroundStyle(Theme.Palette.error) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(assistantBubbleBackground) + + // Retry — re-launches the identical GenConfig + if let cfg = turn.retryConfig { + Button { + retryTurn(turn: turn, config: cfg) + } label: { + Label("Retry", systemImage: "arrow.clockwise") + .font(Theme.Fonts.caption) + } + .buttonStyle(LatticeSecondaryButtonStyle()) + .disabled(isRunning) + .padding(.leading, 4) + } + + } else { + // Normal response — ALWAYS ink color. Red is for errors only. + // (Previous code used Theme.Palette.error for failed status regardless + // of whether the text was a real error or a base/adapter reply.) + Text(turn.responseText) + .font(Theme.Fonts.body) + .foregroundStyle(Theme.Palette.ink) + .multilineTextAlignment(.leading) + .textSelection(.enabled) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background(assistantBubbleBackground) + + // If the run failed but we got partial text, show the reason beneath + if turn.status == .failed, let reason = turn.errorMessage { + Text(reason) + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.Palette.error) + .padding(.leading, 4) + } + + // tok/s + honest hardware label — muted, small + // The label was snapshotted at send time: "GPU Metal bf16", "CPU bf16", etc. + // It is never updated retroactively so what launched is always what shows. + if let tps = turn.tokensPerSecond { + let labelStr = turn.inferenceLabel.map { " · \($0)" } ?? "" + Text(String(format: "%.1f tok/s\(labelStr)", tps)) + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.Palette.textTertiary) + .monospacedDigit() + .padding(.leading, 4) + } else if let label = turn.inferenceLabel, turn.status == .running { + // Show label while generating (before tok/s is known) + Text(label) + .font(Theme.Fonts.caption) + .foregroundStyle(Theme.Palette.textTertiary) + .padding(.leading, 4) + } + + // Retry button for failed turns that do have partial text + if turn.status == .failed, let cfg = turn.retryConfig { + Button { + retryTurn(turn: turn, config: cfg) + } label: { + Label("Retry", systemImage: "arrow.clockwise") + .font(Theme.Fonts.caption) + } + .buttonStyle(LatticeSecondaryButtonStyle()) + .disabled(isRunning) + .padding(.leading, 4) + } + } + } + Spacer(minLength: Theme.Space.xxl) + } + } + } + + private var assistantBubbleBackground: some View { + RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous) + .fill(Theme.Palette.surfaceRaised) + .overlay( + RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous) + .strokeBorder(Theme.Palette.borderStandard, lineWidth: 1) + ) + } + + // MARK: - Composer + + private var composerBar: some View { + VStack(spacing: 0) { + Rectangle() + .fill(Theme.Palette.hairline) + .frame(height: 1) + + HStack { + composerField + .frame(maxWidth: Theme.Space.chatMaxWidth) + .frame(maxWidth: .infinity) + } + .padding(Theme.Space.lg) + .background(Theme.Palette.canvas) + } + } + + private var composerField: some View { + ZStack(alignment: .topLeading) { + // Placeholder — shown only when composer is empty + if composerText.isEmpty { + Text("Message…") + .font(Theme.Fonts.body) + .foregroundStyle(Theme.Palette.textTertiary) + .padding(.horizontal, 10) + .padding(.top, 9) + .allowsHitTesting(false) + } + // TextEditor on macOS wraps NSTextView inside NSScrollView which shows + // always-on scrollers by default. `.scrollIndicators(.never)` suppresses + // them so the field looks like a native input area rather than a document. + TextEditor(text: $composerText) + .font(Theme.Fonts.body) + .foregroundStyle(Theme.Palette.ink) + .scrollContentBackground(.hidden) + .scrollIndicators(.never) + .background(.clear) + .padding(.horizontal, 6) + .padding(.vertical, 4) + .padding(.trailing, 40) + } + // Auto-grow: starts at 44pt (single line), expands up to 160pt when content fills. + // The outer frame clamps max height — the inner TextEditor fills it naturally. + .frame(minHeight: 44, maxHeight: 160) + .fixedSize(horizontal: false, vertical: true) + .background(Theme.Palette.surfaceRaised) + .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: Theme.Radius.panel, style: .continuous) + .strokeBorder(Theme.Palette.borderStandard, lineWidth: 1) + ) + .overlay(alignment: .bottomTrailing) { + actionButton + .padding(7) + } + } + + @ViewBuilder + private var actionButton: some View { + if isRunning { + Button { + store.chatUserStoppedTurnID = store.chatAwaitingTurnID + store.stopRun() + } label: { + Image(systemName: "stop.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(Theme.Palette.crimson) + .frame(width: 30, height: 30) + .background( + RoundedRectangle(cornerRadius: Theme.Radius.control, style: .continuous) + .fill(Theme.Palette.crimson.opacity(0.12)) + ) + } + .buttonStyle(.plain) + .help("Stop generation") + } else { + Button(action: { + send() + }) { + Image(systemName: "arrow.up") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(canSend ? Theme.Palette.onAccent : Theme.Palette.textTertiary) + .frame(width: 30, height: 30) + .background( + RoundedRectangle(cornerRadius: Theme.Radius.control, style: .continuous) + .fill(canSend ? Theme.Palette.signal : Theme.Palette.wellSink) + ) + } + .buttonStyle(.plain) + .disabled(!canSend) + .keyboardShortcut(.return, modifiers: .command) + .help("Send ⌘↵") + } + } + + // MARK: - Actions + + private func applyDefaults() { + if store.chatSelectedModelName.isEmpty || + chatModels.first(where: { $0.name == store.chatSelectedModelName }) == nil { + let target = store.targetModel + if let t = target, t.format == .bf16 { + store.chatSelectedModelName = t.name + } else { + store.chatSelectedModelName = chatModels.first?.name ?? "" + } + } + } + + /// Clear conversation transcript while preserving model/adapter/settings selections. + private func newConversation() { + store.chatTurns = [] + store.chatAwaitingTurnID = nil + store.chatUserStoppedTurnID = nil + if isRunning { store.stopRun() } + } + + /// Build ChatML from completed turns (single-mode history as context). + private func renderChatML(newUserText: String) -> String { + var buf = "" + for turn in store.chatTurns { + guard turn.status == .done, !turn.responseText.isEmpty else { continue } + buf += "<|im_start|>user\n\(turn.prompt)<|im_end|>\n" + buf += "<|im_start|>assistant\n\(turn.responseText)<|im_end|>\n" + } + buf += "<|im_start|>user\n\(newUserText)<|im_end|>\n" + buf += "<|im_start|>assistant\n" + return buf + } + + // MARK: Single-mode send + + private func send() { + let rawUserText = composerText.trimmingCharacters(in: .whitespacesAndNewlines) + guard !rawUserText.isEmpty, !isRunning else { return } + + let temperature = Double(store.chatTempText) ?? 0.7 + let maxTokens = Int(store.chatMaxTokensText) ?? 256 + let seed: UInt64? = store.chatSeedText.isEmpty ? nil : UInt64(store.chatSeedText) + let topK = Int(store.chatTopKText) ?? 50 + let topP = Double(store.chatTopPText) ?? 0.9 + let repetitionPenalty = Double(store.chatRepPenaltyText) ?? 1.1 + let chatMLPrompt = renderChatML(newUserText: rawUserText) + let useGPU = store.chatUseGPU + + // For Q4 models on the GPU path: the tokenizer lives in the bf16 sibling directory. + // The GPU binary (chat_metal) requires --tokenizer-dir when the model dir has no + // tokenizer.json. CPU path (generate_lora) ignores this flag. + let tokenizerDirURL: URL? = { + guard useGPU, let model = selectedModel, model.format == .q4 else { return nil } + // The bf16 sibling is the same name without a "-q4" / "-quarot" suffix. + let modelName = model.name + let baseName = modelName + .replacingOccurrences(of: "-q4", with: "", options: .caseInsensitive) + .replacingOccurrences(of: "-quarot", with: "", options: .caseInsensitive) + let siblingURL = LatticeBridge.modelCacheDir.appendingPathComponent(baseName, isDirectory: true) + let tokenizerJSON = siblingURL.appendingPathComponent("tokenizer.json") + return FileManager.default.fileExists(atPath: tokenizerJSON.path) ? siblingURL : nil + }() + + // Honest hardware label — snapshotted at send time, never changes after dispatch. + // "GPU Metal q4" / "GPU Metal bf16" / "CPU bf16" / "GPU Metal bf16+LoRA" etc. + let inferenceLabel: String = { + let adapterTag = selectedAdapter != nil ? "+LoRA" : "" + if useGPU { + let fmtTag = (selectedModel?.format == .q4) ? "q4" : "bf16" + return "GPU Metal \(fmtTag)\(adapterTag)" + } else { + return "CPU bf16\(adapterTag)" + } + }() + + // Snapshot GenConfig for Retry (plain types; no URL reference needed) + let retryCfg = ChatGenConfig( + modelDirPath: selectedModel?.path.path, + model: selectedModel == nil ? (store.chatSelectedModelName.isEmpty ? nil : store.chatSelectedModelName) : nil, + tokenizerDirPath: tokenizerDirURL?.path, + adapterFilePath: selectedAdapter?.weightFile?.path, + prompt: chatMLPrompt, + maxTokens: maxTokens, + seed: seed, + temperature: temperature, + topK: topK, + topP: topP, + repetitionPenalty: repetitionPenalty, + useGPU: useGPU + ) + + var turn = ChatTurn(prompt: rawUserText) + turn.retryConfig = retryCfg + turn.inferenceLabel = inferenceLabel + let turnID = turn.id + store.chatTurns.append(turn) + store.chatAwaitingTurnID = turnID + + composerText = "" + + let cfg = GenConfig( + modelDir: selectedModel?.path, + model: selectedModel == nil ? (store.chatSelectedModelName.isEmpty ? nil : store.chatSelectedModelName) : nil, + tokenizerDir: tokenizerDirURL, + adapterPath: selectedAdapter?.weightFile, + prompt: chatMLPrompt, + maxTokens: maxTokens, + seed: seed, + temperature: temperature, + topK: topK, + topP: topP, + repetitionPenalty: repetitionPenalty, + useGPU: useGPU + ) + + let run = store.runGenerate(cfg) + + // Resolve via the run's own completion hook so the turn lands even if Ocean navigates + // away from Chat before generation finishes (the .onChange handlers only fire while + // ChatScreen is mounted). It also fires if another screen's launch() supersedes this + // run — finish() runs onComplete with a failed status, so the turn shows a real reason + // instead of a permanent spinner. + run.onComplete = { completed in + self.resolveTurn(id: turnID, from: completed, status: completed.status) + } + + // Synchronous launch failure: no process started, so onComplete never fires. + if run.status == .failed { + resolveTurn(id: turnID, from: run, status: .failed) + } + } + + /// Retry a failed/empty turn with its original GenConfig. + private func retryTurn(turn: ChatTurn, config: ChatGenConfig) { + guard !isRunning else { return } + guard let idx = store.chatTurns.firstIndex(where: { $0.id == turn.id }) else { return } + + // Reset in-place to running state + store.chatTurns[idx].responseText = "" + store.chatTurns[idx].status = .running + store.chatTurns[idx].errorMessage = nil + store.chatTurns[idx].tokensPerSecond = nil + store.chatAwaitingTurnID = turn.id + + let modelDir: URL? = config.modelDirPath.flatMap { URL(fileURLWithPath: $0) } + let tokenizerDir: URL? = config.tokenizerDirPath.flatMap { URL(fileURLWithPath: $0) } + let adapterPath: URL? = config.adapterFilePath.flatMap { URL(fileURLWithPath: $0) } + let cfg = GenConfig( + modelDir: modelDir, + model: config.model, + tokenizerDir: tokenizerDir, + adapterPath: adapterPath, + prompt: config.prompt, + maxTokens: config.maxTokens, + seed: config.seed, + temperature: config.temperature, + topK: config.topK, + topP: config.topP, + repetitionPenalty: config.repetitionPenalty, + useGPU: config.useGPU + ) + + let run = store.runGenerate(cfg) + run.onComplete = { [turnID = turn.id] completed in + self.resolveTurn(id: turnID, from: completed, status: completed.status) + } + if run.status == .failed { + resolveTurn(id: turn.id, from: run, status: .failed) + } + } + + private func resolveTurn(id turnID: UUID, from run: LiveRun, status: RunStatus) { + guard let idx = store.chatTurns.firstIndex(where: { $0.id == turnID }) else { return } + // Idempotent: once a turn is resolved to .done/.failed, ignore repeat calls. The run's + // onComplete hook is authoritative; a synchronous-failure check can also land here. + guard store.chatTurns[idx].status == .running else { return } + + let wasUserStopped = (store.chatUserStoppedTurnID == turnID) + store.chatAwaitingTurnID = nil + store.chatUserStoppedTurnID = nil + + let responseText: String + if !run.genText.isEmpty { + responseText = run.genText.trimmingCharacters(in: .whitespacesAndNewlines) + } else { + let cleaned = run.log + .filter { !$0.hasPrefix("$ ") } + .joined(separator: "\n") + .trimmingCharacters(in: .whitespacesAndNewlines) + + if cleaned.isEmpty && status == .failed { + responseText = wasUserStopped ? "Stopped." : "" + } else { + responseText = cleaned + } + } + + store.chatTurns[idx].responseText = responseText + + if wasUserStopped { + store.chatTurns[idx].status = .done + } else { + store.chatTurns[idx].status = (status == .done) ? .done : .failed + } + + // Surface engine error for failed turns with no output. + if store.chatTurns[idx].status == .failed && responseText.isEmpty { + let errorLine = run.log + .filter { !$0.hasPrefix("$ ") } + .last { line in + let lo = line.lowercased() + return lo.contains("error") || lo.contains("nan") || + lo.contains("unrecognized") || lo.contains("failed") || + lo.contains("panic") + } + ?? run.log.filter { !$0.hasPrefix("$ ") }.last + ?? "Generation failed to start." + store.chatTurns[idx].errorMessage = errorLine + } + + if status == .done || wasUserStopped { + store.chatTurns[idx].tokensPerSecond = run.genTokS + } + } + +} diff --git a/apps/macos/Sources/LatticeStudio/Screens/GetModelsSheet.swift b/apps/macos/Sources/LatticeStudio/Screens/GetModelsSheet.swift new file mode 100644 index 0000000000..b5291a3c6b --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Screens/GetModelsSheet.swift @@ -0,0 +1,295 @@ +import SwiftUI +import AppKit + +// MARK: - Get Models sheet +// +// Three honest sections: +// 1. DOWNLOAD — 7 embedding models with verified --download-only support. +// Shows INSTALLED when already in store.models; otherwise a real Download button. +// In-flight downloads show a spinner + size hint. Errors are surfaced verbatim. +// +// 2. GENERATIVE — import-only models (no engine downloader exists). +// Provides a "Copy HF URL" button per model. A clear caption explains the workflow. +// Shows INSTALLED when the model appears in store.models. +// NO Download button — that would be a dead control (honest-nil discipline). +// +// 3. IMPORT FROM DISK — NSOpenPanel for any model folder. +// Validates config.json + .safetensors before copying to the model cache. +// Shows import progress + errors verbatim. + +struct GetModelsSheet: View { + @Bindable var store: AppStore + @Environment(\.dismiss) private var dismiss + + var body: some View { + VStack(spacing: 0) { + sheetHeader + Divider() + ScrollView { + VStack(alignment: .leading, spacing: Theme.Space.xl) { + downloadSection + generativeSection + importSection + } + .padding(Theme.Space.xl) + } + } + .background(Theme.Palette.canvas) + } + + // MARK: Header + + private var sheetHeader: some View { + HStack { + VStack(alignment: .leading, spacing: 3) { + Text("GET MODELS") + .font(Theme.Fonts.title) + .foregroundStyle(Theme.Palette.ink) + Text("Download embedding models or import any model folder.") + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + } + Spacer() + Button("Done") { dismiss() } + .buttonStyle(LatticeSecondaryButtonStyle()) + } + .padding(Theme.Space.lg) + } + + // MARK: Section 1 — Download (embedding models) + + private var downloadSection: some View { + OpaquePanel { + VStack(spacing: 0) { + sectionHeader("DOWNLOAD — EMBEDDING MODELS", + note: "checksums verified by the engine") + + ForEach(curatedCatalog.filter { $0.kind == .downloadable }) { model in + embeddingRow(model) + Theme.Palette.hairline.frame(height: 1) + } + } + } + } + + @ViewBuilder + private func embeddingRow(_ model: CuratedModel) -> some View { + let isInstalled = store.models.contains { $0.name.lowercased() == model.id.lowercased() } + let isDownloading = store.downloadingModels.contains(model.id) + let errorMsg = store.downloadErrors[model.id] + + HStack(spacing: Theme.Space.md) { + VStack(alignment: .leading, spacing: 3) { + Text(model.name) + .font(Theme.Fonts.body) + .foregroundStyle(Theme.Palette.ink) + HStack(spacing: Theme.Space.sm) { + Text(model.detail) + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + if let sz = model.approxSize { + Text("·") + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + Text(sz) + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + } + } + if let err = errorMsg { + HStack(spacing: 4) { + GatePill(.fail, label: "FAILED") + Text(err) + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + .lineLimit(2) + } + .padding(.top, 2) + } + } + + Spacer() + + // Status / action — exactly one of: INSTALLED pill, spinner, or Download button. + if isInstalled { + GatePill(.pass, label: "INSTALLED") + } else if isDownloading { + HStack(spacing: Theme.Space.sm) { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.small) + if let sz = model.approxSize { + Text("Downloading… (\(sz))") + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + } else { + Text("Downloading…") + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + } + } + } else { + Button { + store.downloadModel(canonicalName: model.id) + } label: { + Text("Download") + .font(Theme.Fonts.body) + } + .buttonStyle(LatticePrimaryButtonStyle()) + } + } + .padding(.horizontal, Theme.Space.lg) + .padding(.vertical, Theme.Space.sm) + } + + // MARK: Section 2 — Generative (import-only) + + private var generativeSection: some View { + OpaquePanel { + VStack(spacing: 0) { + sectionHeader("IMPORT-ONLY MODELS", + note: "download from HuggingFace, then use Import below") + + // Honest caption — these models have no in-app downloader (generative models + // and the Qwen embedding model, whose loader only does a local-dir lookup). + HStack { + Text("These models have no in-app downloader. Copy the HuggingFace URL, fetch the repo with `git clone` or `huggingface-cli download`, then import the folder below.") + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + .fixedSize(horizontal: false, vertical: true) + Spacer() + } + .padding(.horizontal, Theme.Space.lg) + .padding(.vertical, Theme.Space.sm) + + Theme.Palette.hairline.frame(height: 1) + + ForEach(curatedCatalog.filter { $0.kind == .importOnly }) { model in + generativeRow(model) + Theme.Palette.hairline.frame(height: 1) + } + } + } + } + + @ViewBuilder + private func generativeRow(_ model: CuratedModel) -> some View { + let isInstalled = store.models.contains { $0.name.lowercased() == model.id.lowercased() } + + HStack(spacing: Theme.Space.md) { + VStack(alignment: .leading, spacing: 3) { + Text(model.name) + .font(Theme.Fonts.body) + .foregroundStyle(Theme.Palette.ink) + Text(model.detail) + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + if let url = model.hfURL { + Text(url) + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.signal) + .lineLimit(1) + .truncationMode(.middle) + } + } + + Spacer() + + if isInstalled { + GatePill(.pass, label: "INSTALLED") + } else if let url = model.hfURL { + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(url, forType: .string) + } label: { + Text("Copy HF URL") + .font(Theme.Fonts.body) + } + .buttonStyle(LatticeSecondaryButtonStyle()) + .help("Copy HuggingFace URL to clipboard") + } + } + .padding(.horizontal, Theme.Space.lg) + .padding(.vertical, Theme.Space.sm) + } + + // MARK: Section 3 — Import from disk + + private var importSection: some View { + OpaquePanel { + VStack(spacing: 0) { + sectionHeader("IMPORT FROM DISK", + note: "works for any model: BF16, Q4, embedding") + + VStack(alignment: .leading, spacing: Theme.Space.md) { + Text("Choose a model folder. It must contain config.json and at least one .safetensors file. The folder is copied into the model cache (~/.lattice/models).") + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + .fixedSize(horizontal: false, vertical: true) + + // Progress and error feedback — always honest, never silent. + if !store.importingModel.isEmpty { + HStack(spacing: Theme.Space.sm) { + ProgressView() + .progressViewStyle(.circular) + .controlSize(.small) + Text("Importing \(store.importingModel)…") + .font(Theme.Fonts.body) + .foregroundStyle(Theme.Palette.inkDim) + } + } + + if let err = store.importError { + HStack(spacing: 4) { + GatePill(.fail, label: "IMPORT FAILED") + Text(err) + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + .lineLimit(3) + .fixedSize(horizontal: false, vertical: true) + } + } + + Button { + importFromDisk() + } label: { + Text("Import Model…") + .font(Theme.Fonts.body) + } + .buttonStyle(LatticePrimaryButtonStyle()) + .disabled(!store.importingModel.isEmpty) + } + .padding(Theme.Space.lg) + } + } + } + + // MARK: Helpers + + private func sectionHeader(_ title: String, note: String) -> some View { + HStack { + Text(title) + .instrumentLabel() + Spacer() + Text(note) + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + } + .frame(height: Theme.Space.rowHeight) + .padding(.horizontal, Theme.Space.lg) + .overlay(alignment: .bottom) { + Theme.Palette.hairline.frame(height: 1) + } + } + + private func importFromDisk() { + let panel = NSOpenPanel() + panel.canChooseDirectories = true + panel.canChooseFiles = false + panel.allowsMultipleSelection = false + panel.message = "Choose a model directory (must contain config.json + .safetensors)" + panel.prompt = "Import" + guard panel.runModal() == .OK, let url = panel.url else { return } + store.importModel(from: url) + } +} diff --git a/apps/macos/Sources/LatticeStudio/Screens/ModelsScreen.swift b/apps/macos/Sources/LatticeStudio/Screens/ModelsScreen.swift new file mode 100644 index 0000000000..65abea75e3 --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Screens/ModelsScreen.swift @@ -0,0 +1,683 @@ +import SwiftUI +import AppKit + +// MARK: - 01 MODELS +// +// Two-tab layout via [Models | Adapters] segmented Picker at the top: +// +// MODELS tab (unchanged): +// • Trailing action row: Refresh + Reveal-in-Finder + Train/Quantize/Chat CTAs +// • Main body: DataTable of store.models +// • Right inspector: ReadoutWells for the selected model + adapter sub-list +// +// ADAPTERS tab (new): +// • Main body: DataTable of store.adapters (scanned from /adapters) +// • Right inspector: ReadoutWells for the selected adapter + Delete button +// +// Empty state: if binariesReady is false hint "run `make build`" and show modelCachePath. +// One teal primary CTA per screen: "Train →" (filled); others are outline. + +// MARK: - ModelsTab + +private enum ModelsTab: String, CaseIterable { + case models = "Models" + case adapters = "Adapters" +} + +private let byteFormatter: ByteCountFormatter = { + let f = ByteCountFormatter() + f.allowedUnits = [.useBytes, .useKB, .useMB, .useGB] + f.countStyle = .file + return f +}() + +struct ModelsScreen: View { + @Bindable var store: AppStore + + // Tab selection — Models or Adapters + @State private var modelsTab: ModelsTab = .models + // Local selection state — deselects when models list changes. + @State private var selectedModelID: String? + @State private var selectedAdapterID: String? + // Quantize sheet: presented from the model inspector/action row (Phase A re-parenting). + @State private var showQuantizeSheet: Bool = false + @State private var showGetModelsSheet: Bool = false + @State private var didInitInspector = false + + private var selectedModel: ModelInfo? { + store.models.first { $0.id == selectedModelID } + } + + private var selectedAdapter: AdapterInfo? { + store.adapters.first { $0.id == selectedAdapterID } + } + + private var subtitle: String { + switch modelsTab { + case .models: + let count = store.models.count + if count == 0 { return "no models found" } + return "\(count) model\(count == 1 ? "" : "s") · \(store.modelCachePath)" + case .adapters: + let count = store.adapters.count + if count == 0 { return "no adapters found" } + return "\(count) adapter\(count == 1 ? "" : "s")" + } + } + + var body: some View { + ScreenScaffold( + screen: .models, + subtitle: subtitle, + trailing: { actionRow } + ) { + VStack(spacing: 0) { + // Segmented tab picker — mirrors ChatScreen pattern + HStack { + Picker("", selection: $modelsTab) { + ForEach(ModelsTab.allCases, id: \.self) { tab in + Text(tab.rawValue).tag(tab) + } + } + .pickerStyle(.segmented) + .labelsHidden() + .frame(maxWidth: 240) + Spacer() + } + .padding(.horizontal, Theme.Space.lg) + .padding(.top, Theme.Space.md) + .padding(.bottom, Theme.Space.sm) + + // Tab content + switch modelsTab { + case .models: + if store.models.isEmpty { + emptyState + } else { + modelTable + } + case .adapters: + adapterTable + } + } + } + .inspector(isPresented: $store.inspectorPresented) { + switch modelsTab { + case .models: + inspectorPanel + .inspectorColumnWidth(min: 260, ideal: 300, max: 320) + case .adapters: + adapterInspectorPanel + .inspectorColumnWidth(min: 260, ideal: 300, max: 320) + } + } + .sheet(isPresented: $showQuantizeSheet) { + QuantizeScreen(store: store) + .frame(minWidth: 760, idealWidth: 900, maxWidth: .infinity, + minHeight: 540, idealHeight: 640, maxHeight: .infinity) + } + .sheet(isPresented: $showGetModelsSheet) { + GetModelsSheet(store: store) + .frame(minWidth: 680, idealWidth: 760, maxWidth: .infinity, + minHeight: 500, idealHeight: 620, maxHeight: .infinity) + } + .onAppear { openInspectorOnce() } + } + + // Open the detail inspector once on first appear (replaces the previously always-visible column). + private func openInspectorOnce() { + guard !didInitInspector else { return } + didInitInspector = true + store.inspectorPresented = true + } + + // MARK: Action row (scaffold trailing) + + private var actionRow: some View { + HStack(spacing: Theme.Space.sm) { + // Get Models — primary (the discovery CTA; no model selection required) + Button { + showGetModelsSheet = true + } label: { + Text("Get Models") + .font(Theme.Fonts.body) + } + .buttonStyle(LatticePrimaryButtonStyle()) + .help("Browse and download models or import from disk") + + // Refresh — secondary + Button { + store.refreshModels() + } label: { + Text("Refresh") + .font(Theme.Fonts.body) + } + .buttonStyle(LatticeSecondaryButtonStyle()) + .keyboardShortcut("r", modifiers: .command) + .help("Refresh model list (⌘R)") + + // Tab-specific actions + switch modelsTab { + case .models: + // Reveal in Finder — only when a model is selected + if let model = selectedModel { + Button { + NSWorkspace.shared.activateFileViewerSelecting([model.path]) + } label: { + Text("Reveal") + .font(Theme.Fonts.body) + } + .buttonStyle(LatticeSecondaryButtonStyle()) + } + + Divider() + .frame(height: 16) + + // Navigation CTAs — Train is the single teal primary + if let model = selectedModel { + Button { + store.use(model, on: .train) + } label: { + Text("Train") + .font(Theme.Fonts.body) + } + .buttonStyle(LatticePrimaryButtonStyle()) + .help("Send selected model to Train (⌘3)") + + Button { + store.workingModel = model + showQuantizeSheet = true + } label: { + Text("Quantize…") + .font(Theme.Fonts.body) + } + .buttonStyle(LatticeSecondaryButtonStyle()) + .help("Quantize selected model (Q4 / QuaRot)") + + Button { + store.use(model, on: .chat) + } label: { + Text("Chat") + .font(Theme.Fonts.body) + } + .buttonStyle(LatticeSecondaryButtonStyle()) + .help("Send selected model to Chat (⌘4)") + + Button { + store.use(model, on: .eval) + } label: { + Text("Eval →") + .font(Theme.Fonts.body) + } + .buttonStyle(LatticeSecondaryButtonStyle()) + .help("Send selected model to Eval workspace (⌘5)") + } + + case .adapters: + // Reveal in Finder — only when an adapter is selected + if let adapter = selectedAdapter { + Button { + NSWorkspace.shared.activateFileViewerSelecting([adapter.path]) + } label: { + Text("Reveal") + .font(Theme.Fonts.body) + } + .buttonStyle(LatticeSecondaryButtonStyle()) + } + } + } + } + + // MARK: Master table + + private var modelTable: some View { + ScrollView { + DataTable( + rows: store.models, + columns: modelColumns, + selectedID: $selectedModelID, + comfortable: store.rowComfortable + ) + } + .instrumentPanel() + } + + private var modelColumns: [ColumnDef] { + [ + ColumnDef( + id: "name", + header: "NAME", + alignment: .leading, + minWidth: 180, + isNumeric: false + ) { $0.name }, + + ColumnDef( + id: "format", + header: "FORMAT", + alignment: .leading, + minWidth: 80, + isNumeric: false + ) { $0.format.badge }, + + ColumnDef( + id: "params", + header: "PARAMS", + alignment: .trailing, + minWidth: 64, + isNumeric: false + ) { $0.params ?? "—" }, + + ColumnDef( + id: "layers", + header: "LAYERS", + alignment: .leading, + minWidth: 120, + isNumeric: false + ) { $0.layerSummary ?? "—" }, + + ColumnDef( + id: "size", + header: "SIZE", + alignment: .trailing, + minWidth: 80, + isNumeric: true + ) { byteFormatter.string(fromByteCount: $0.sizeBytes) }, + + ColumnDef( + id: "files", + header: "FILES", + alignment: .trailing, + minWidth: 48, + isNumeric: true + ) { "\($0.fileCount)" }, + + ColumnDef( + id: "tokenizer", + header: "TOK", + alignment: .center, + minWidth: 40, + isNumeric: false + ) { $0.hasTokenizer ? "✓" : "—" }, + + ColumnDef( + id: "adapters", + header: "#ADAPTERS", + alignment: .trailing, + minWidth: 72, + isNumeric: true + ) { "\($0.adapters.count)" }, + ] + } + + // MARK: Adapter table + + private var adapterTable: some View { + ScrollView { + DataTable( + rows: store.adapters, + columns: adapterColumns, + selectedID: $selectedAdapterID, + comfortable: store.rowComfortable + ) + } + .instrumentPanel() + } + + private var adapterColumns: [ColumnDef] { + [ + ColumnDef( + id: "name", + header: "NAME", + alignment: .leading, + minWidth: 180, + isNumeric: false + ) { $0.name }, + + ColumnDef( + id: "baseModel", + header: "BASE MODEL", + alignment: .leading, + minWidth: 160, + isNumeric: false + ) { $0.baseModel ?? "—" }, + + ColumnDef( + id: "rank", + header: "RANK", + alignment: .trailing, + minWidth: 56, + isNumeric: true + ) { $0.rank.map(String.init) ?? "—" }, + + ColumnDef( + id: "scale", + header: "SCALE", + alignment: .trailing, + minWidth: 64, + isNumeric: true + ) { + if let s = $0.scale { return String(format: "%.0f", s) } + if let a = $0.alpha { return String(format: "%.0f", a) } + return "—" + }, + + ColumnDef( + id: "size", + header: "SIZE", + alignment: .trailing, + minWidth: 80, + isNumeric: true + ) { byteFormatter.string(fromByteCount: $0.sizeBytes) }, + ] + } + + // MARK: Adapter inspector panel + + @ViewBuilder + private var adapterInspectorPanel: some View { + if let adapter = selectedAdapter { + ScrollView { + VStack(alignment: .leading, spacing: Theme.Space.xl) { + adapterInspector(adapter) + } + .padding(Theme.Space.lg) + } + .instrumentPanel() + } else { + VStack { + Spacer() + Text("SELECT AN ADAPTER") + .instrumentLabel() + Spacer() + } + .frame(maxWidth: .infinity) + .instrumentPanel() + } + } + + private func adapterInspector(_ adapter: AdapterInfo) -> some View { + VStack(alignment: .leading, spacing: Theme.Space.md) { + // Adapter name header + Text(adapter.name) + .font(Theme.Fonts.title) + .foregroundStyle(Theme.Palette.ink) + .lineLimit(1) + + // Readout wells — 2-column grid + let wells = adapterWells(for: adapter) + LazyVGrid( + columns: [GridItem(.flexible(), spacing: Theme.Space.md), GridItem(.flexible(), spacing: Theme.Space.md)], + spacing: Theme.Space.md + ) { + ForEach(wells, id: \.label) { well in + ReadoutWell(label: well.label, value: well.value, unit: well.unit, minHeight: 56) + } + } + + // Delete button + Button { + store.deleteAdapter(adapter) + selectedAdapterID = nil + } label: { + Text("Delete") + .font(Theme.Fonts.body) + } + .buttonStyle(LatticeSecondaryButtonStyle()) + .padding(.top, Theme.Space.sm) + } + } + + private func adapterWells(for adapter: AdapterInfo) -> [WellSpec] { + var ws: [WellSpec] = [] + if let rank = adapter.rank { + ws.append(WellSpec("RANK", "\(rank)")) + } + // Show scale (MLX) or alpha (PEFT), whichever is present + if let scale = adapter.scale { + ws.append(WellSpec("SCALE", String(format: "%.0f", scale))) + } else if let alpha = adapter.alpha { + ws.append(WellSpec("ALPHA", String(format: "%.0f", alpha))) + } + if let baseModel = adapter.baseModel { + ws.append(WellSpec("BASE MODEL", baseModel)) + } + if let numLayers = adapter.numLayers { + ws.append(WellSpec("LAYERS", "\(numLayers)")) + } + if adapter.checkpointCount > 0 { + ws.append(WellSpec("CHECKPOINTS", "\(adapter.checkpointCount)")) + } + ws.append(WellSpec("SIZE", byteFormatter.string(fromByteCount: adapter.sizeBytes))) + if let modules = adapter.targetModules { + ws.append(WellSpec("MODULES", modules)) + } + return ws + } + + // MARK: Inspector panel + + @ViewBuilder + private var inspectorPanel: some View { + if let model = selectedModel { + ScrollView { + VStack(alignment: .leading, spacing: Theme.Space.xl) { + modelInspector(model) + if !model.adapters.isEmpty { + adapterList(model.adapters) + } + } + .padding(Theme.Space.lg) + } + .instrumentPanel() + } else { + VStack { + Spacer() + Text("SELECT A MODEL") + .instrumentLabel() + Spacer() + } + .frame(maxWidth: .infinity) + .instrumentPanel() + } + } + + private func modelInspector(_ model: ModelInfo) -> some View { + VStack(alignment: .leading, spacing: Theme.Space.md) { + // Model name header + HStack(spacing: Theme.Space.sm) { + Text(model.name) + .font(Theme.Fonts.title) + .foregroundStyle(Theme.Palette.ink) + .lineLimit(1) + if model.format.isQuantized { + GatePill(.pass, label: model.format.badge) + } + } + + // Readout wells — 2-column grid + let wells = modelWells(for: model) + LazyVGrid( + columns: [GridItem(.flexible(), spacing: Theme.Space.md), GridItem(.flexible(), spacing: Theme.Space.md)], + spacing: Theme.Space.md + ) { + ForEach(wells, id: \.label) { well in + ReadoutWell(label: well.label, value: well.value, unit: well.unit, minHeight: 56) + } + } + } + } + + private struct WellSpec { + let label: String + let value: String + let unit: String + init(_ label: String, _ value: String, _ unit: String = "") { + self.label = label; self.value = value; self.unit = unit + } + } + + private func modelWells(for model: ModelInfo) -> [WellSpec] { + var ws: [WellSpec] = [] + ws.append(WellSpec("FORMAT", model.format.rawValue)) + ws.append(WellSpec("DTYPE", model.dtype)) + if let params = model.params { + ws.append(WellSpec("PARAMS", params)) + } + if let hidden = model.hidden { + ws.append(WellSpec("HIDDEN", "\(hidden)")) + } + // intermediate_size — FFN/MLP inner width (3584 for qwen3.5, ~3.5× hidden). + // Honest-nil: omitted when the model config has no intermediate_size. + if let ffn = model.intermediateSize { + ws.append(WellSpec("FFN", "\(ffn)")) + } + if let vocab = model.vocab { + ws.append(WellSpec("VOCAB", "\(vocab)")) + } + if let ctx = model.contextLength { + ws.append(WellSpec("CTX", "\(ctx)")) + } + if let h = model.attnHeads { ws.append(WellSpec("HEADS", "\(h)")) } + if let kv = model.kvHeads { ws.append(WellSpec("KV HEADS", "\(kv)")) } + if let hd = model.headDim { ws.append(WellSpec("HEAD DIM", "\(hd)")) } + // GatedDeltaNet linear-attention heads; show K/V, honest-nil each, omit when both absent. + if model.gdnKeyHeads != nil || model.gdnValueHeads != nil { + let k = model.gdnKeyHeads.map(String.init) ?? "—" + let v = model.gdnValueHeads.map(String.init) ?? "—" + ws.append(WellSpec("GDN HEADS", "\(k)/\(v)")) + } + if let layers = model.layerSummary { + ws.append(WellSpec("LAYERS", layers)) + } + ws.append(WellSpec("SIZE", byteFormatter.string(fromByteCount: model.sizeBytes))) + ws.append(WellSpec("FILES", "\(model.fileCount)")) + ws.append(WellSpec("TOKENIZER", model.hasTokenizer ? "yes" : "—")) + return ws + } + + private func adapterList(_ adapters: [AdapterInfo]) -> some View { + VStack(alignment: .leading, spacing: Theme.Space.sm) { + // Section label + Text("ADAPTERS") + .instrumentLabel() + .padding(.bottom, 2) + + // Hairline divider + Theme.Palette.hairline.frame(height: 1) + + ForEach(adapters) { adapter in + adapterRow(adapter) + Theme.Palette.hairline.frame(height: 1) + } + } + } + + private func adapterRow(_ adapter: AdapterInfo) -> some View { + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 2) { + Text(adapter.name) + .font(Theme.Fonts.body) + .foregroundStyle(Theme.Palette.ink) + .lineLimit(1) + + HStack(spacing: Theme.Space.sm) { + if let rank = adapter.rank { + inlineStat("rank", "r\(rank)") + } + if let alpha = adapter.alpha { + inlineStat("α", String(format: "%.0f", alpha)) + } + if let modules = adapter.targetModules { + inlineStat("modules", modules) + } + } + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text(byteFormatter.string(fromByteCount: adapter.sizeBytes)) + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + .monospacedDigit() + + Button { + NSWorkspace.shared.activateFileViewerSelecting([adapter.path]) + } label: { + Text("Reveal") + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.signal) + } + .buttonStyle(.plain) + } + } + .padding(.vertical, Theme.Space.xs) + } + + private func inlineStat(_ label: String, _ value: String) -> some View { + HStack(spacing: 2) { + Text(label) + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.inkDim) + .textCase(.uppercase) + Text(value) + .font(Theme.Fonts.cell) + .foregroundStyle(Theme.Palette.ink) + .monospacedDigit() + } + } + + // MARK: Empty state + + private var emptyState: some View { + VStack(alignment: .leading, spacing: Theme.Space.xl) { + OpaquePanel { + VStack(alignment: .leading, spacing: Theme.Space.lg) { + Text("NO MODELS FOUND") + .font(Theme.Fonts.title) + .foregroundStyle(Theme.Palette.ink) + + VStack(alignment: .leading, spacing: Theme.Space.sm) { + Text("Model cache directory:") + .font(Theme.Fonts.body) + .foregroundStyle(Theme.Palette.inkDim) + + Text(store.modelCachePath) + .font(Theme.Fonts.readout) + .foregroundStyle(Theme.Palette.ink) + .monospacedDigit() + .lineLimit(2) + .truncationMode(.middle) + } + + if !store.binariesReady { + GatePill(.warn, label: "lattice binary not found") + + VStack(alignment: .leading, spacing: 4) { + Text("Build the lattice engine first:") + .font(Theme.Fonts.body) + .foregroundStyle(Theme.Palette.inkDim) + Text("make build") + .font(Theme.Fonts.readout) + .foregroundStyle(Theme.Palette.signal) + .monospacedDigit() + } + } + + Button { + store.refreshModels() + } label: { + Text("Refresh") + .font(Theme.Fonts.body) + } + .buttonStyle(LatticeSecondaryButtonStyle()) + } + .padding(Theme.Space.xl) + } + + Spacer() + } + } +} + diff --git a/apps/macos/Sources/LatticeStudio/Screens/ScreenScaffold.swift b/apps/macos/Sources/LatticeStudio/Screens/ScreenScaffold.swift new file mode 100644 index 0000000000..ec01a16bd1 --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Screens/ScreenScaffold.swift @@ -0,0 +1,48 @@ +import SwiftUI + +// Shared chrome for every screen: a ruled header (index · title + subtitle), an +// optional trailing slot for a primary action / live status, then the content body +// on the instrument canvas. Screens compose their own layouts inside `content`. +struct ScreenScaffold: View { + let screen: Screen + let subtitle: String + @ViewBuilder var trailing: () -> Trailing + @ViewBuilder var content: () -> Content + + init( + screen: Screen, + subtitle: String, + @ViewBuilder trailing: @escaping () -> Trailing = { EmptyView() }, + @ViewBuilder content: @escaping () -> Content + ) { + self.screen = screen + self.subtitle = subtitle + self.trailing = trailing + self.content = content + } + + var body: some View { + VStack(alignment: .leading, spacing: Theme.Space.lg) { + HStack(alignment: .firstTextBaseline) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 8) { + Text(screen.index) + .font(Theme.Fonts.mono(13)) + .foregroundStyle(Theme.Palette.inkDim) + Text(screen.title) + .font(Theme.Fonts.display(17, .bold)) + .foregroundStyle(Theme.Palette.ink) + } + Text(subtitle) + .font(Theme.Fonts.body) + .foregroundStyle(Theme.Palette.inkDim) + } + Spacer(minLength: Theme.Space.lg) + trailing() + } + content() + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(Theme.Space.xl) + } +} diff --git a/apps/macos/Sources/LatticeStudio/Shell/ContentView.swift b/apps/macos/Sources/LatticeStudio/Shell/ContentView.swift new file mode 100644 index 0000000000..eb5dd134d6 --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Shell/ContentView.swift @@ -0,0 +1,173 @@ +import SwiftUI + +struct ContentView: View { + @Bindable var store: AppStore + + var body: some View { + NavigationSplitView { + LeftRail(store: store) + // Sidebar: fixed ideal width with a collapse range so it never overlaps + // the detail column on narrow windows. + .navigationSplitViewColumnWidth( + min: Theme.Space.sidebarMin, + ideal: Theme.Space.railWidth, + max: Theme.Space.sidebarMax + ) + } detail: { + ZStack { + Theme.Palette.canvas.ignoresSafeArea() + detail + } + // Enforce a minimum detail width so the split view never collapses the + // content area to zero on narrow resize — the window's own minWidth (1120) + // is the outer bound; this clamps at the column level. + .frame(minWidth: 640) + .toolbar { + // Run-status capsule: always reserve the slot; hide the capsule content + // when there is no active run. This prevents the toolbar from reflowing + // when a run starts or stops, which previously caused the Embeddings + // HSplitView to jump. + ToolbarItem(placement: .primaryAction) { + if let run = store.liveRun, run.status == .running || run.status == .paused { + runStatusCapsule(run) + } else { + // Zero-size placeholder — keeps toolbar item count constant. + Color.clear.frame(width: 0, height: 0) + } + } + // Inspector toggle: only rendered for screens that have an inspector. + if store.selection.hasInspector { + ToolbarItem(placement: .primaryAction) { + Button { + store.inspectorPresented.toggle() + } label: { + Image(systemName: "sidebar.right") + } + .help("Toggle settings (⌘\\)") + .foregroundStyle(store.inspectorPresented ? Theme.Palette.signal : Theme.Palette.textSecondary) + } + } + } + } + .navigationTitle("") + // One neutral tint for every system control (Slider/Picker/Toggle/field caret). + // Elements that should read teal set Theme.Palette.signal explicitly, so they are + // unaffected — this keeps the accent reserved for live data + the single CTA. + .tint(Theme.Palette.control) + .background(shortcuts) + .overlay { + CommandBar( + isPresented: $store.commandBarOpen, + commands: CommandSpec.latticeDefaults, + onRun: handleCommand + ) + } + } + + // Route a ⌘K command to a screen / action. A leading model-name argument + // (e.g. `train qwen3.5 r8`) preselects the working model for that screen. + private func handleCommand(_ cmd: String, _ args: [String]) { + func retarget() { + guard let arg = args.first(where: { !$0.isEmpty })?.lowercased() else { return } + if let m = store.models.first(where: { $0.name.lowercased().contains(arg) }) { + store.workingModel = m + } + } + switch cmd { + case "train": retarget(); store.selection = .train + case "chat", + "runs": retarget(); store.selection = .chat + case "models": store.selection = .models + case "data": store.selection = .data + case "stop": store.stopRun() + default: break + } + } + + // Compact toolbar capsule shown while a run is active. + // Shows: status dot · model name · step counter — no invented fields. + // Clicking navigates to the Runs screen. + @ViewBuilder + private func runStatusCapsule(_ run: LiveRun) -> some View { + let activityLabel: String = { + switch run.kind { + case .train: return "Training" + case .quantizeQ4: return "Quantizing" + case .quantizeQuaRot: return "Quantizing" + case .chat: return "Generating" + case .eval: return "Evaluating" + case .embed: return "Embedding" + } + }() + let stepLabel: String = { + if run.kind == .train { + let s = "step \(run.currentStep)" + if let total = run.totalSteps { return s + "/\(total)" } + return s + } + return run.status == .paused ? "paused" : "running" + }() + + Button { + // Jump to the screen that owns this run kind — not always Chat. + switch run.kind { + case .chat: store.selection = .chat + case .train: store.selection = .train + case .quantizeQ4, .quantizeQuaRot: store.selection = .models + case .eval: store.selection = .eval + case .embed: store.selection = .eval + } + } label: { + HStack(spacing: 6) { + Circle() + .fill(Theme.Palette.running) + .frame(width: 6, height: 6) + .opacity(run.status == .paused ? 0.5 : 1.0) + Text(activityLabel) + .font(Theme.Fonts.controlText) + .foregroundStyle(Theme.Palette.textPrimary) + Text(run.modelName) + .font(Theme.Fonts.controlText) + .foregroundStyle(Theme.Palette.textSecondary) + .lineLimit(1) + Text(stepLabel) + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(Theme.Palette.textSecondary) + } + .padding(.horizontal, 10) + .padding(.vertical, 4) + .background( + Theme.Palette.running.opacity(0.12) + .clipShape(Capsule()) + ) + .overlay(Capsule().strokeBorder(Theme.Palette.running.opacity(0.28), lineWidth: 1)) + } + .buttonStyle(.plain) + .help("View active run") + } + + @ViewBuilder private var detail: some View { + switch store.selection { + case .models: ModelsScreen(store: store) + case .data: DataScreen(store: store) + case .train: TrainScreen(store: store) + case .chat: ChatScreen(store: store) + case .eval: EvalScreen(store: store) + } + } + + // Hidden buttons carry the global keyboard map (⌘1–6, ⌘K, ⌘\). + private var shortcuts: some View { + ZStack { + ForEach(Screen.allCases) { s in + Button("") { store.selection = s } + .keyboardShortcut(s.shortcut, modifiers: .command) + } + Button("") { store.commandBarOpen.toggle() }.keyboardShortcut("k", modifiers: .command) + Button("") { store.inspectorPresented.toggle() }.keyboardShortcut("\\", modifiers: .command) + } + .opacity(0) + .frame(width: 0, height: 0) + } + +} diff --git a/apps/macos/Sources/LatticeStudio/Shell/LeftRail.swift b/apps/macos/Sources/LatticeStudio/Shell/LeftRail.swift new file mode 100644 index 0000000000..83740a9eb9 --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Shell/LeftRail.swift @@ -0,0 +1,235 @@ +import SwiftUI + +// MARK: - The left rail: wordmark + engine-state header, indexed nav, compact memory footer. +struct LeftRail: View { + @Bindable var store: AppStore + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider().overlay(Theme.Palette.hairline) + nav + Spacer(minLength: 0) + Divider().overlay(Theme.Palette.hairline) + memoryFooter + } + .background(Theme.Palette.panel) + } + + // MARK: Header (~58pt): wordmark + engine-state row + + private var header: some View { + VStack(alignment: .leading, spacing: 4) { + // Wordmark + HStack(alignment: .firstTextBaseline, spacing: 4) { + Text("Lattice") + .font(Theme.Fonts.bodyStrong) // 13pt medium + .foregroundStyle(Theme.Palette.ink) + Spacer() + } + // Engine-state row (~24pt) + engineStateRow + } + .padding(.horizontal, Theme.Space.lg) + .padding(.top, Theme.Space.md) + .padding(.bottom, Theme.Space.sm) + } + + @ViewBuilder private var engineStateRow: some View { + let isActive: Bool = { + guard let run = store.liveRun else { return false } + return run.status == .running || run.status == .paused + }() + let dotColor = isActive ? Theme.Palette.running : Theme.Palette.idle + + HStack(spacing: 6) { + Circle() + .fill(dotColor) + .frame(width: 6, height: 6) + .modifier(PulsingDot(active: isActive)) + Text(engineStateLabel) + .font(Theme.Fonts.caption) // 11pt regular + .foregroundStyle(Theme.Palette.textSecondary) + Spacer() + } + .frame(height: 24) + } + + private var engineStateLabel: String { + guard let run = store.liveRun, + run.status == .running || run.status == .paused else { + return "Idle" + } + switch run.kind { + case .train: return "Training" + case .quantizeQ4: return "Quantizing" + case .quantizeQuaRot: return "Quantizing" + case .chat: return "Generating" + case .eval: return "Evaluating" + case .embed: return "Embedding" + } + } + + // MARK: Nav rows + + private var nav: some View { + VStack(spacing: 2) { + ForEach(Screen.allCases) { screen in + navRow(screen) + } + } + .padding(.horizontal, Theme.Space.xs) // 4pt outer — rows add their own 10pt h-pad + .padding(.vertical, Theme.Space.sm) + } + + private func navRow(_ screen: Screen) -> some View { + let selected = store.selection == screen + return NavRowButton(screen: screen, selected: selected) { + store.selection = screen + } + } + + // MARK: Memory footer (~56pt) + + private var memoryFooter: some View { + let mem = store.memoryUsage + let frac = mem.totalGB > 0 ? min(mem.usedGB / mem.totalGB, 1.0) : 0 + + return VStack(alignment: .leading, spacing: 6) { + // Label + Text("UNIFIED MEMORY") + .font(Theme.Fonts.sectionLabel) + .tracking(0.85) + .textCase(.uppercase) + .foregroundStyle(Theme.Palette.textTertiary) + + // Progress track (3pt, wellSink bg + running at 0.6 fill) + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 2) + .fill(Theme.Palette.wellSink) + RoundedRectangle(cornerRadius: 2) + .fill(Theme.Palette.running.opacity(0.6)) + .frame(width: geo.size.width * frac) + } + } + .frame(height: 3) + + // Value in SF Mono + Text("\(mem.usedGB, format: .number.precision(.fractionLength(1))) / \(mem.totalGB, format: .number.precision(.fractionLength(0))) GB") + .font(.system(size: 11, weight: .regular, design: .monospaced)) + .foregroundStyle(Theme.Palette.textSecondary) + } + .padding(.horizontal, Theme.Space.lg) + .padding(.vertical, Theme.Space.md) + .frame(height: 56) + } +} + +// MARK: - Nav Row Button +// 32pt tall, 10pt h-padding, 8pt symbol-to-label gap. +// Selected: selectionFill bg + selectionBorder overlay + 2pt leading accent marker. +// Hover: hoverOverlay. + +private struct NavRowButton: View { + let screen: Screen + let selected: Bool + let action: () -> Void + + @State private var hovered = false + + var body: some View { + Button(action: action) { + HStack(spacing: 0) { + // 2pt leading accent marker (only when selected) + Rectangle() + .fill(selected ? Theme.Palette.signal : .clear) + .frame(width: 2, height: 20) + .clipShape(RoundedRectangle(cornerRadius: 1)) + .padding(.trailing, 8) + + // SF Symbol at 14pt medium in a 16×16 frame + Image(systemName: screen.symbol) + .font(.system(size: 14, weight: .medium)) + .frame(width: 16, height: 16) + .foregroundStyle(selected ? Theme.Palette.ink : Theme.Palette.textSecondary) + + // 8pt gap between symbol and label + Spacer().frame(width: 8) + + // Screen label + Text(screen.title.localizedCapitalized) + .font(Theme.Fonts.bodyStrong) + .foregroundStyle(selected ? Theme.Palette.ink : Theme.Palette.textSecondary) + + Spacer() + + // Trailing shortcut at 10pt SF Mono tertiary + Text("⌘\(String(screen.shortcut.character).uppercased())") + .font(.system(size: 10, weight: .regular, design: .monospaced)) + .foregroundStyle(Theme.Palette.textTertiary) + } + .padding(.horizontal, 10) + .frame(height: 32) + .background(rowBackground) + .overlay(rowBorder) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .onHover { hovered = $0 } + } + + @ViewBuilder private var rowBackground: some View { + if selected { + Theme.Palette.selectionFill + .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.control)) + } else if hovered { + Theme.Palette.hoverOverlay + .clipShape(RoundedRectangle(cornerRadius: Theme.Radius.control)) + } else { + Color.clear + } + } + + @ViewBuilder private var rowBorder: some View { + if selected { + RoundedRectangle(cornerRadius: Theme.Radius.control) + .strokeBorder(Theme.Palette.selectionBorder, lineWidth: 1) + } + } +} + +// MARK: - Pulsing dot modifier (opacity 65%→100%, 1.2s, active run only) + +private struct PulsingDot: ViewModifier { + let active: Bool + @State private var pulsing = false + + func body(content: Content) -> some View { + if active { + content + .opacity(pulsing ? 1.0 : 0.65) + .animation( + Animation.easeInOut(duration: 0.6).repeatForever(autoreverses: true), + value: pulsing + ) + .onAppear { pulsing = true } + } else { + content + } + } +} + +// MARK: - Screen symbol mapping + +extension Screen { + var symbol: String { + switch self { + case .models: "shippingbox" + case .data: "tablecells" + case .train: "chart.line.uptrend.xyaxis" + case .chat: "text.bubble" + case .eval: "waveform.path.ecg" + } + } +} diff --git a/apps/macos/Sources/LatticeStudio/Store/AppStore.swift b/apps/macos/Sources/LatticeStudio/Store/AppStore.swift new file mode 100644 index 0000000000..d63f5aeab2 --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Store/AppStore.swift @@ -0,0 +1,397 @@ +import SwiftUI +import Observation + +// MARK: - The single source of truth (Observation framework, @MainActor). +// +// One @Observable store held in @State at the app root, handed to views as `@Bindable`. +// No @EnvironmentObject, no Combine. CRITICAL: the live run is owned HERE, above the views, +// keyed by identity — a screen re-render must never reset a running job. +@MainActor +@Observable +final class AppStore { + var selection: Screen = .models + var models: [ModelInfo] = [] + var adapters: [AdapterInfo] = [] + var runs: [RunRecord] = [] + var liveRun: LiveRun? + var inspectorPresented = false + var commandBarOpen = false + var binariesReady = false + var rowComfortable = false + + var modelCachePath: String { LatticeBridge.modelCacheDir.path } + var repoRootPath: String? { LatticeBridge.repoRoot?.path } + + // MARK: - Hoisted Chat state (survives NavigationSplitView teardown) + + // Single-mode conversation transcript. + var chatTurns: [ChatTurn] = [] + // GPU/CPU inference mode. true = chat_metal (Metal GPU); false = generate_lora (CPU BF16). + // Honest-label contract: this flag selects the binary AND anchors the label in each turn + // bubble at send time. A GPU-flagged run NEVER appears with a CPU label, and vice versa. + var chatUseGPU: Bool = false + // Selections — survive navigation. + var chatSelectedModelName: String = "" + var chatSelectedAdapterName: String = "none" + // Generation knob text fields. + var chatTempText: String = "0.7" + var chatMaxTokensText: String = "256" + var chatSeedText: String = "" + var chatTopKText: String = "50" + var chatTopPText: String = "0.9" + var chatRepPenaltyText: String = "1.1" + // In-flight tracking — must be store-owned so a generation that finishes while + // the user is on another screen still lands in chatTurns. + var chatAwaitingTurnID: UUID? = nil + var chatUserStoppedTurnID: UUID? = nil + + // MARK: - Hoisted Eval workspace state (Stage 1 — structural plumbing) + + // Active tab in EvalScreen ("PPL" | "Compare" | "Similar"). + var evalActiveTab: String = "PPL" + // Model selection (multi-select; survives navigation). + var evalSelectedModelNames: Set = [] + // Per-model adapter selection: modelName → adapterName. + var evalSelectedAdapterNames: [String: String] = [:] + // Generation knobs — separate from chat context; eval and chat are independent contexts. + var evalTempText: String = "0.7" + var evalMaxTokensText: String = "256" + var evalSeedText: String = "" + // Whether Compare tab generation should use the GPU Metal path. + // When true, each column dispatches to chat_metal (GPU Metal). + // When false, each column dispatches to generate_lora (CPU BF16). + // Honest-label contract: the label in EvalColumn.label is snapshotted at send time + // from the actual GenConfig.useGPU value so it always matches the binary that ran. + var evalUseGPU: Bool = false + // PPL corpus path (nil = use built-in default ~200-token corpus). + // Stored as a path string because URL is not directly @Observable-friendly. + var evalCorpusPath: String? = nil + + // MARK: - Eval compare state (Stage 3 — N-way generation compare) + + // Accumulated compare experiment pairs for the COMPARE tab. + // Each pair holds one EvalColumn per configured slot. Persists across navigation. + var evalComparePairs: [EvalComparePair] = [] + // Index of the column currently being generated (0-based). -1 = idle. + var evalComparePhase: Int = -1 + // ID of the pair currently being generated. nil = idle. + var evalCompareAwaitingPairID: UUID? = nil + + // MARK: - Hoisted PPL state (survives ModelsScreen teardown) + + /// Last measured perplexity per model ID. Keyed by ModelInfo.id (path). + /// Persisted across launches via ppl.json in the same AppSupport dir as runs.json. + var measuredPPL: [String: MeasuredPPL] = [:] + + // MARK: - Get Models state + + // Canonical names of models currently being downloaded. + var downloadingModels: Set = [] + // Per-model download errors; cleared when a download is retried or succeeds. + var downloadErrors: [String: String] = [:] + // Name of model currently being imported from disk ("" when idle). + var importingModel: String = "" + // Last import error; cleared at the start of each import attempt. + var importError: String? = nil + + private var handle: RunHandle? + // Dedicated handles for concurrent downloads — keyed by ObjectIdentifier so each + // download gets its own RunHandle and never evicts a training/eval job from `handle`. + var _downloadHandles: [ObjectIdentifier: RunHandle] = [:] + + // Handles stopped to make way for a new run, kept alive here until their terminationHandler + // delivers onExit. RunHandle.terminationHandler captures [weak self], so once `launch()` + // reassigns `handle` the old handle would otherwise deallocate before its exit callback runs: + // finish() (and the run's onComplete) would never fire and an awaiting chat turn would hang + // .running forever. Each handle removes itself from this map inside its own onExit. + private var retiringHandles: [ObjectIdentifier: RunHandle] = [:] + + init() { + runs = Self.loadRunArchive() + measuredPPL = Self.loadPPLArchive() + // Reap trainer processes orphaned by a previous app crash or force-quit. + // Done synchronously at init so no orphan can race with an immediately-launched run. + let reaped = RunRegistry.reapOrphans() + if reaped > 0 { + print("[AppStore] reaped \(reaped) orphaned trainer process(es) from previous session") + } + } + + func onAppear() { + refreshModels() + binariesReady = LatticeBridge.prebuiltBinary(.lattice) != nil + } + + // MARK: Run archive persistence + + /// The shared `/LatticeStudio` base directory. + /// + /// Used by both the runs.json archive and RunRegistry's active-runs subdirectory so + /// both always resolve to the same location. + nonisolated static var appSupportDir: URL? { + guard let appSupport = try? FileManager.default.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) else { return nil } + let dir = appSupport.appendingPathComponent("LatticeStudio", isDirectory: true) + // Create the subdirectory on first access. + try? FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + return dir + } + + /// URL of the on-disk runs.json archive in the app's Application Support directory. + private static var runsArchiveURL: URL? { + appSupportDir?.appendingPathComponent("runs.json") + } + + /// Decode the persisted run archive from disk. Returns an empty array on any failure + /// (missing file, corrupt JSON, schema mismatch) — honest empty, never fabricated. + private static func loadRunArchive() -> [RunRecord] { + guard let url = runsArchiveURL, + let data = try? Data(contentsOf: url) else { return [] } + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return (try? decoder.decode([RunRecord].self, from: data)) ?? [] + } + + /// Atomically write the finished run archive to disk. Call only with completed records. + private func persistRunArchive() { + guard let url = Self.runsArchiveURL else { return } + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = .prettyPrinted + guard let data = try? encoder.encode(runs) else { return } + try? data.write(to: url, options: .atomic) + } + + // MARK: PPL archive persistence + + private static var pplArchiveURL: URL? { + appSupportDir?.appendingPathComponent("ppl.json") + } + + private static func loadPPLArchive() -> [String: MeasuredPPL] { + guard let url = pplArchiveURL, + let data = try? Data(contentsOf: url) else { return [:] } + return (try? JSONDecoder().decode([String: MeasuredPPL].self, from: data)) ?? [:] + } + + func persistPPLArchive() { + guard let url = Self.pplArchiveURL else { return } + let encoder = JSONEncoder() + encoder.outputFormatting = .prettyPrinted + guard let data = try? encoder.encode(measuredPPL) else { return } + try? data.write(to: url, options: .atomic) + } + + func refreshModels() { + Task.detached { + let found = LatticeBridge.discoverModels() + let foundAdapters: [AdapterInfo] + if let root = LatticeBridge.repoRoot { + foundAdapters = LatticeBridge.discoverAdapterPackages(in: root.appendingPathComponent("adapters", isDirectory: true)) + } else { + foundAdapters = [] + } + // Attach compatible adapters to each model so the Chat A/B picker + // (selectedModel.adapters) can offer them; the global list still backs MODELS. + let withAdapters = LatticeBridge.associateAdapters(foundAdapters, into: found) + await MainActor.run { + self.models = withAdapters + self.adapters = foundAdapters + } + } + } + + /// Move adapter directory to Trash and refresh the adapter list. + func deleteAdapter(_ adapter: AdapterInfo) { + var trashURL: NSURL? + try? FileManager.default.trashItem(at: adapter.path, resultingItemURL: &trashURL) + refreshModels() + } + + func model(named name: String) -> ModelInfo? { models.first { $0.name == name } } + + // The model currently targeted across TRAIN / QUANTIZE / CHAT. Set from MODELS via `use(_:on:)`. + var workingModel: ModelInfo? + // The dataset currently selected in DATA. Consumed by TRAIN in a later task. + var workingDataset: DatasetFileStat? + // Sensible default target: first non-embedding model, else first of any. + var defaultModel: ModelInfo? { models.first { !$0.isEmbedding } ?? models.first } + // The effective target a screen should drive: explicit working model, else the default. + var targetModel: ModelInfo? { workingModel ?? defaultModel } + + /// Navigate to `screen` with `model` pre-selected as the working target. + func use(_ model: ModelInfo, on screen: Screen) { + workingModel = model + selection = screen + } + + // MARK: Launch / lifecycle (the generic primitive screens build typed configs on top of) + + @discardableResult + func launch(_ bin: LatticeBinary, args: [String], kind: RunKind, model: String, totalSteps: Int? = nil) -> LiveRun { + let run = LiveRun(kind: kind, modelName: model) + run.totalSteps = totalSteps + liveRun = run + + guard let spec = LatticeBridge.launchSpec(bin, args: args) else { + run.status = .failed + run.appendLog("error: could not resolve `\(bin.binName)` — no prebuilt binary and no cargo fallback. Run `make build` in the lattice repo, or set LATTICE_BIN_DIR.") + return run + } + run.appendLog("$ \(spec.executable.lastPathComponent) \(args.joined(separator: " "))") + + if let prior = handle, prior.isRunning { + // Retain the superseded handle until its onExit fires (see retiringHandles). The + // reassignment of `handle` below would otherwise drop its last strong reference, and + // its [weak self] terminationHandler could then no-op — stranding the prior run + // unresolved (a chat turn stuck .running with chatAwaitingTurnID never cleared). + retiringHandles[ObjectIdentifier(prior)] = prior + prior.stop() + } + let h = RunHandle() + h.onEvent = { [weak self] ev in self?.consume(ev, into: run) } + do { + try h.start(spec) + // Register AFTER a successful start so we never record a pid that never launched. + let pid = h.pid + RunRegistry.register( + pid: pid, + binPath: spec.executable.path, + kind: kind.rawValue, + startedAt: run.startedAt + ) + // Set onExit AFTER start so it captures `pid` (a value) rather than `h`. Capturing + // `h` would retain-cycle through h.onExit and leak the RunHandle + its Process + + // pipe file descriptors on every run. No fast-exit race: terminationHandler + // dispatches onExit onto the main queue, and we are still on the main actor here, + // so onExit is always assigned before it can fire. + // ObjectIdentifier (a value) lets onExit drop a superseded handle from + // retiringHandles without capturing `h` itself, which would re-introduce the + // retain cycle the comment above avoids. + let hid = ObjectIdentifier(h) + h.onExit = { [weak self] code in + // Deregister before finish so the PID slot is freed even if finish throws. + RunRegistry.deregister(pid: pid) + self?.finish(run, code: code) + self?.retiringHandles.removeValue(forKey: hid) + } + } catch { + run.status = .failed + run.appendLog("launch failed: \(error.localizedDescription)") + } + handle = h + return run + } + + private func consume(_ event: LatticeEvent, into run: LiveRun) { + switch event { + case .trainStep(let s): + // Step 0 carries the pre-training NLL, which equals train_done.base_nll + // (verified byte-identical against the binary). Capture it on the first event + // so "Δ FROM BASE" reads live from step 0 instead of "—" until the run ends. + // BEST VAL is deliberately NOT tracked here: the trainer computes its final + // held-out NLL once at completion (eval_valid on the saved final weights, not a + // best checkpoint), so any running minimum would diverge from the saved adapter + // and jump at trainDone. The live per-step held-out NLL is already shown in the + // HELD-OUT well; BEST VAL stays honest-nil until trainDone reports it. + if s.step == 0 { run.baseNLL = s.loss } + run.points.append(TrainPoint(step: s.step, loss: s.loss, valLoss: s.val_loss, + gradNorm: s.grad_norm, lr: s.lr, tokS: s.tok_s)) + case .trainEval(let e): + run.bestVal = e.best_val ?? min(run.bestVal ?? e.val_loss, e.val_loss) + case .trainDone(let d): + run.baseNLL = d.base_nll + run.bestVal = d.best_val ?? run.bestVal + run.savedAdapterPath = d.saved + case .quantLayer(let q): + run.quantLayerIndex = q.i + run.quantLayerCount = q.n + // Track the dominant quantized scheme (first non-passthrough scheme wins). + // F16 layers are kept as-is; the quantized scheme is what gives the after-bits. + if run.quantScheme == nil, q.scheme != "F16", q.scheme != "BF16" { + run.quantScheme = q.scheme + } + case .quantDone(let q): + run.quantBeforeMB = q.before_mb + run.quantAfterMB = q.after_mb + run.quantRatio = q.ratio + run.verdict = q.verdict + run.quantMaxAbs = q.max_abs + case .genToken(let g): + // Accumulate streamed text; do NOT write token deltas to the generic log + // so the log stays clean for loader status lines and error messages. + run.genText += g.token + if g.done == true { + run.genTokS = g.tok_s + run.genDone = true + } + case .perplexity(let p): + // Append — a run may emit several rows (bf16/q4/quarot/adapter). + run.perplexities.append(p) + case .embedDone(let e): + // Replace — exactly one embed_done event per batch run. + run.embed = e + case .downloadDone: + // download_done events are handled by downloadModel's dedicated RunHandle; + // they should never reach a LiveRun's consume path. + break + case .status(let line): + run.appendLog(line) + case .unknown(let j): + run.appendLog(j) + } + } + + private func finish(_ run: LiveRun, code: Int32) { + run.status = (code == 0) ? .done : .failed + // Capture the failure reason from the log when the process exits non-zero. + // Look for the last non-empty log line that isn't a banner (===) or launch echo ($). + // This surfaces the actual engine error (e.g. "Error: load model: Model not found…") + // without fabricating anything — honest-nil when no such line exists. + if code != 0 { + run.failureReason = run.log.last(where: { line in + !line.isEmpty && !line.hasPrefix("$") && !line.hasPrefix("===") + }) + } + let rec = RunRecord( + id: "\(run.kind.rawValue)-\(Int(run.startedAt.timeIntervalSince1970))", + kind: run.kind, model: run.modelName, status: run.status, startedAt: run.startedAt, + lastLoss: run.currentLoss, bestVal: run.bestVal, + durationS: Date().timeIntervalSince(run.startedAt), + configSummary: nil, adapterPath: run.savedAdapterPath + ) + runs.insert(rec, at: 0) + // Persist the archive immediately after appending the finished record. + persistRunArchive() + if run.savedAdapterPath != nil { refreshModels() } + // Fire the completion hook last — status, record, and disk state are all finalised. + // Used by screens to chain A/B follow-up runs (e.g. base eval → adapter eval). + run.onComplete?(run) + } + + func pauseRun() { handle?.pause(); liveRun?.status = .paused } + func resumeRun() { handle?.resume(); liveRun?.status = .running } + func stopRun() { handle?.stop() } + + func liveRun(matching kinds: Set) -> LiveRun? { + guard let r = liveRun, kinds.contains(r.kind) else { return nil } + return r + } + + var memoryUsage: (usedGB: Double, totalGB: Double) { + let total = Double(ProcessInfo.processInfo.physicalMemory) / 1_073_741_824.0 + var info = mach_task_basic_info() + var count = mach_msg_type_number_t(MemoryLayout.size) / 4 + let kerr = withUnsafeMutablePointer(to: &info) { + $0.withMemoryRebound(to: integer_t.self, capacity: Int(count)) { + task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count) + } + } + let used = kerr == KERN_SUCCESS ? Double(info.resident_size) / 1_073_741_824.0 : 0 + return (used, total) + } +} diff --git a/apps/macos/Sources/LatticeStudio/Store/DomainModels.swift b/apps/macos/Sources/LatticeStudio/Store/DomainModels.swift new file mode 100644 index 0000000000..1760be5d5b --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Store/DomainModels.swift @@ -0,0 +1,374 @@ +import SwiftUI + +// MARK: - Navigation + +enum Screen: String, CaseIterable, Identifiable, Hashable { + case models, data, train, chat, eval + var id: String { rawValue } + + var index: String { + switch self { + case .models: "01"; case .data: "02"; case .train: "03"; case .chat: "04"; case .eval: "05" + } + } + var title: String { + switch self { + case .models: "MODELS"; case .data: "DATA"; case .train: "TRAIN"; case .chat: "CHAT"; case .eval: "EVAL" + } + } + var shortcut: KeyEquivalent { + switch self { + case .models: "1"; case .data: "2"; case .train: "3"; case .chat: "4"; case .eval: "5" + } + } + + /// Whether this screen has a right inspector (toggle sidebar). Drives the + /// shared window-toolbar toggle visibility. The eval screen uses a left config + /// rail (like embed did) and does not use the system inspector panel. + var hasInspector: Bool { self != .eval } +} + +// MARK: - Models on disk + +enum ModelFormat: String, Equatable { + case bf16 = "BF16" + case q4 = "Q4" + case quarot = "QuaRot Q4" + case embedding = "Embed" + case unknown = "—" + + var badge: String { self == .quarot ? "rotated" : rawValue } + var isQuantized: Bool { self == .q4 || self == .quarot } +} + +struct ModelInfo: Identifiable, Equatable { + var id: String { path.path } + var name: String + var path: URL + var format: ModelFormat + var params: String? // e.g. "0.8B" parsed from name/config + var dtype: String // BF16 / Q4_0 / mixed + var sizeBytes: Int64 + var fileCount: Int + var hasTokenizer: Bool + var layerSummary: String? // e.g. "18 GDN · 6 GQA" + var hidden: Int? + var vocab: Int? + var contextLength: Int? // max_position_embeddings; nil when config.json absent + var attnHeads: Int? // num_attention_heads (GQA query heads) + var kvHeads: Int? // num_key_value_heads (GQA KV heads) + var headDim: Int? // head_dim (explicit; NOT hidden/heads for qwen3.5) + var gdnKeyHeads: Int? // linear_num_key_heads (GatedDeltaNet) + var gdnValueHeads: Int? // linear_num_value_heads (GatedDeltaNet) + var intermediateSize: Int? // intermediate_size — FFN/MLP inner width (3584 for qwen3.5) + var isEmbedding: Bool = false + var adapters: [AdapterInfo] = [] +} + +struct AdapterInfo: Identifiable, Equatable { + var id: String { path.path } + var name: String + var path: URL + var rank: Int? + var alpha: Double? + var targetModules: String? + var sizeBytes: Int64 + // MLX-format fields (nil when absent or config unreadable — honest) + var baseModel: String? = nil + var scale: Double? = nil + var numLayers: Int? = nil + var checkpointCount: Int = 0 + + /// The `.safetensors` weight file to hand to `generate_lora --lora`, or `nil` when the + /// package has no usable weight file. + /// + /// Resolved ONCE at discovery (`LatticeBridge.discoverAdapterPackages` / + /// `discoverAdapters`) via `resolveWeightFile`, NOT recomputed per access — the Chat + /// picker filters non-runnable adapters (`weightFile == nil`) on every re-render, so this + /// must be a cheap stored read, never per-frame file I/O. `nil` is honest: the picker + /// hides the adapter and callers omit `--lora` rather than pass a path the engine cannot + /// read (it would otherwise silently run the base model under the adapter's label). + var weightFile: URL? = nil + + /// Resolve the primary `.safetensors` weight file for an adapter package. + /// + /// `path` from `discoverAdapterPackages` is the package DIRECTORY, but the engine's + /// loader (`load_peft_safetensors`) reads a single file via `std::fs::read` and errors on + /// a directory. Resolve the file inside, by priority: + /// 1. `adapters.safetensors` — MLX/lattice final consolidated adapter + /// 2. `adapter.safetensors` — singular variant (lattice trainer output) + /// 3. `adapter_model.safetensors` — PEFT + /// 4. highest-numbered `*_adapters.safetensors` — MLX checkpoint (training stopped + /// before consolidation; pick the latest step) + /// Returns `path` unchanged when it already points at a file (loose-file adapters), or + /// `nil` when no weight file exists. + static func resolveWeightFile(at path: URL) -> URL? { + let fm = FileManager.default + var isDir: ObjCBool = false + guard fm.fileExists(atPath: path.path, isDirectory: &isDir) else { return nil } + if !isDir.boolValue { return path } + + func candidate(_ name: String) -> URL? { + let u = path.appendingPathComponent(name) + return fm.fileExists(atPath: u.path) ? u : nil + } + if let f = candidate("adapters.safetensors") { return f } + if let f = candidate("adapter.safetensors") { return f } + if let f = candidate("adapter_model.safetensors") { return f } + + guard let children = try? fm.contentsOfDirectory( + at: path, includingPropertiesForKeys: nil, options: [.skipsHiddenFiles] + ) else { return nil } + // The "_adapters.safetensors" prefix is an integer step count, so pick the + // highest step NUMERICALLY (10 > 9), not lexicographically (where "9" > "10" as + // strings would load an older checkpoint), with the filename as a stable tie-breaker. + func step(_ url: URL) -> Int { + Int(url.lastPathComponent.prefix { $0.isNumber }) ?? -1 + } + return children + .filter { $0.lastPathComponent.hasSuffix("_adapters.safetensors") } + .max { a, b in + let sa = step(a), sb = step(b) + return sa != sb ? sa < sb : a.lastPathComponent < b.lastPathComponent + } + } +} + +// MARK: - Curated model catalog (Get Models sheet) + +// UI treatment axis: .downloadable → one-click Download (engine has a checksum-verified +// downloader); .importOnly → "Copy HF URL" + manual Import (no in-app downloader). The +// Qwen embedding model is import-only too — its loader has no fetch path, only a local-dir +// lookup — so it belongs with the generative models, not in the Download section. +enum CuratedKind { case downloadable, importOnly } + +struct CuratedModel: Identifiable { + let id: String // canonical name used by the embed binary + let name: String + let kind: CuratedKind + let detail: String // e.g. "384 dim · ~130 MB" or one-line description + let approxSize: String? // downloadable models only + let hfURL: String? // import-only models — HuggingFace source link +} + +let curatedCatalog: [CuratedModel] = [ + // Downloadable embeddings — `embed --model --download-only --json` (checksum-verified) + CuratedModel(id: "bge-small-en-v1.5", name: "bge-small-en-v1.5", kind: .downloadable, detail: "384 dim · English", approxSize: "~130 MB", hfURL: nil), + CuratedModel(id: "bge-base-en-v1.5", name: "bge-base-en-v1.5", kind: .downloadable, detail: "768 dim · English", approxSize: "~440 MB", hfURL: nil), + CuratedModel(id: "bge-large-en-v1.5", name: "bge-large-en-v1.5", kind: .downloadable, detail: "1024 dim · English", approxSize: "~1.3 GB", hfURL: nil), + CuratedModel(id: "multilingual-e5-small", name: "multilingual-e5-small", kind: .downloadable, detail: "384 dim · Multilingual", approxSize: "~470 MB", hfURL: nil), + CuratedModel(id: "multilingual-e5-base", name: "multilingual-e5-base", kind: .downloadable, detail: "768 dim · Multilingual", approxSize: "~1.1 GB", hfURL: nil), + CuratedModel(id: "all-minilm-l6-v2", name: "all-minilm-l6-v2", kind: .downloadable, detail: "384 dim · English", approxSize: "~90 MB", hfURL: nil), + CuratedModel(id: "paraphrase-multilingual-minilm-l12-v2", name: "paraphrase-multilingual-minilm-l12-v2", kind: .downloadable, + detail: "384 dim · Multilingual", approxSize: "~470 MB", hfURL: nil), + + // Import-only — no in-app downloader; fetch from HuggingFace, then use Import from Disk + CuratedModel(id: "qwen3.5-0.8b", name: "qwen3.5-0.8b", kind: .importOnly, + detail: "0.8B generative model — lightweight, fast", + approxSize: nil, hfURL: "https://huggingface.co/Qwen/Qwen3.5-0.8B"), + CuratedModel(id: "qwen3.5-2b", name: "qwen3.5-2b", kind: .importOnly, + detail: "2B generative model — better quality", + approxSize: nil, hfURL: "https://huggingface.co/Qwen/Qwen3.5-2B"), + CuratedModel(id: "qwen3-embedding-0.6b", name: "qwen3-embedding-0.6b", kind: .importOnly, + detail: "0.6B embedding model — import-only (no in-app download)", + approxSize: nil, hfURL: "https://huggingface.co/Qwen/Qwen3-Embedding-0.6B"), +] + +// MARK: - Chat domain models (owned by AppStore; moved here from ChatScreen so the store can persist them) + +/// A single exchange in single-mode chat. +struct ChatTurn: Identifiable { + enum TurnStatus { case running, done, failed } + + let id = UUID() + let prompt: String + var responseText: String = "" + var status: TurnStatus = .running + var tokensPerSecond: Double? = nil + /// Error message from the run log — set when the turn finishes with an empty response. + /// Shown instead of "(no output)" so Ocean knows WHY the engine produced nothing. + var errorMessage: String? = nil + /// The GenConfig used to produce this turn — stored so Retry can re-launch the identical run. + var retryConfig: ChatGenConfig? = nil + /// Honest hardware label, snapshotted at send time: "GPU Metal bf16", "CPU bf16", etc. + /// Never updated after the run starts — what launched is what ran. + var inferenceLabel: String? = nil +} + +/// The generation parameters needed to retry a failed/empty turn. +/// Mirrors fields of GenConfig but uses serialisation-friendly plain types so it can live on ChatTurn. +struct ChatGenConfig: Equatable { + var modelDirPath: String? + var model: String? + var tokenizerDirPath: String? + var adapterFilePath: String? + var prompt: String + var maxTokens: Int + var seed: UInt64? + var temperature: Double + var topK: Int = 50 + var topP: Double = 0.9 + var repetitionPenalty: Double = 1.1 + /// Whether this config targets the GPU Metal path (chat_metal) or CPU (generate_lora). + var useGPU: Bool = false +} + +/// One A/B experiment pair (base column vs adapter column). +struct ComparePair: Identifiable { + enum Side { case base, adapter } + + let id = UUID() + let prompt: String // raw user text (for display) + let baseLabel: String // base model name, snapshotted at creation + let adapterLabel: String // adapter name, snapshotted at creation + var baseText: String = "" + var baseTokS: Double? = nil + var baseDone: Bool = false + var adapterText: String = "" + var adapterTokS: Double? = nil + var adapterDone: Bool = false + var failed: Bool = false + var failureReason: String? = nil // surface engine error when failed +} + +/// Tracks which phase of an A/B run sequence is active. +enum ABPhase { case idle, base, adapter } + +// MARK: - Eval compare domain models (owned by AppStore via evalComparePairs) + +/// One column in an N-way compare pair — holds the result for a single model/adapter slot. +struct EvalColumn { + /// Display label snapshotted at pair creation: "model-name · adapter-name · device-tag". + /// Survives picker changes — labels never relabel a completed result. + let label: String + /// Accumulated token stream from gen_token events. + var text: String = "" + /// Final tok/s from the generation done event. + var tokS: Double? = nil + /// True once this column's run has completed (done or failed). + var done: Bool = false + /// True if the run failed AND produced no output (honest failure, not partial output). + var failed: Bool = false + /// Engine error message from the run log when failed == true. Honest-nil when unknown. + var failureReason: String? = nil +} + +/// One N-way compare experiment — shared prompt, one EvalColumn per model/adapter slot. +struct EvalComparePair: Identifiable { + let id = UUID() + /// Raw user prompt text (shown as the cell header in each column). + let prompt: String + /// One entry per slot at the time the pair was submitted. Immutable count; column content mutates. + var columns: [EvalColumn] +} + +// MARK: - PPL measurement cache + +/// A single measured perplexity snapshot, keyed into AppStore.measuredPPL by modelID. +struct MeasuredPPL: Equatable, Codable { + var modelID: String + var bf16: Double? + var quant: Double? + var quantLabel: String? // "Q4" or "QuaRot" +} + +// MARK: - Runs (the lab notebook) + +enum RunKind: String, Equatable, Codable { + case train = "LoRA" + case quantizeQ4 = "Q4" + case quantizeQuaRot = "QuaRot" + case chat = "Chat" + case eval = "Eval" // eval_perplexity — measures PPL for one or more model variants + case embed = "Embed" // embed — produces embedding vectors and a pairwise cosine matrix +} +enum RunStatus: String, Equatable, Codable { case idle, running, paused, done, failed } + +struct RunRecord: Identifiable, Equatable, Codable { + var id: String + var kind: RunKind + var model: String + var status: RunStatus + var startedAt: Date + var lastLoss: Double? + var bestVal: Double? + var durationS: Double? + var configSummary: String? + var adapterPath: String? +} + +// MARK: - Live training series (drives the oscilloscope strip chart + scrub-to-freeze) + +struct TrainPoint: Identifiable, Equatable { + var id: Int { step } + var step: Int + var loss: Double + var valLoss: Double? + var gradNorm: Double? + var lr: Double? + var tokS: Double? +} + +@Observable +final class LiveRun { + var kind: RunKind + var modelName: String + var status: RunStatus = .running + var startedAt: Date = Date() + var totalSteps: Int? + + // Train + var points: [TrainPoint] = [] + var bestVal: Double? + var baseNLL: Double? + var savedAdapterPath: String? + + // Quantize + var quantBeforeMB: Double? + var quantAfterMB: Double? + var quantRatio: Double? + var quantLayerIndex: Int = 0 + var quantLayerCount: Int = 0 + var verdict: String? + var quantMaxAbs: Double? // QuaRot forward-equivalence max abs error + var quantScheme: String? // dominant quantization scheme seen in quant_layer events + + // Generation streaming (chat / generate_lora --json mode) + var genText: String = "" // accumulated incremental token deltas from gen_token events + var genTokS: Double? = nil // final tokens/sec from the done event (tok_s field) + var genDone: Bool = false // set true when the gen_token done event arrives + + // Eval (eval_perplexity --json mode) + // A single run may emit multiple rows — one per measurement label (bf16/q4/quarot/adapter). + var perplexities: [LatticeEvent.Perplexity] = [] + + // Embed (embed --json mode) + // Exactly one embed_done event is expected per run (batch mode: all texts in one call). + var embed: LatticeEvent.EmbedDone? = nil + + // Completion hook — fired by AppStore.finish() after status and RunRecord are set. + // Used by screens to chain a follow-up run (e.g. base eval → adapter eval A/B sequence). + // Closures cannot conform to Codable; never serialised to disk. + var onComplete: ((LiveRun) -> Void)? = nil + + // Failure reason — the last meaningful error line from the subprocess (non-empty, non-banner). + // Set by AppStore.finish() when the exit code is non-zero and the log contains an error. + // Honest-nil when the process exited non-zero but produced no parseable error line. + var failureReason: String? = nil + + // Shared + var log: [String] = [] + + var currentStep: Int { points.last?.step ?? 0 } + var currentLoss: Double? { points.last?.loss } + + init(kind: RunKind, modelName: String) { + self.kind = kind + self.modelName = modelName + } + + func appendLog(_ line: String) { + log.append(line) + if log.count > 4000 { log.removeFirst(log.count - 4000) } + } +} diff --git a/apps/macos/Sources/LatticeStudio/Theme/Theme.swift b/apps/macos/Sources/LatticeStudio/Theme/Theme.swift new file mode 100644 index 0000000000..bdc6e55e6d --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Theme/Theme.swift @@ -0,0 +1,332 @@ +import SwiftUI +import AppKit + +// MARK: - Color helpers (code-defined adaptive palette — no asset catalog, so `swift build` is hermetic) + +extension NSColor { + convenience init(hex: UInt32, alpha: CGFloat = 1.0) { + let r = CGFloat((hex >> 16) & 0xFF) / 255.0 + let g = CGFloat((hex >> 8) & 0xFF) / 255.0 + let b = CGFloat(hex & 0xFF) / 255.0 + self.init(srgbRed: r, green: g, blue: b, alpha: alpha) + } +} + +extension Color { + /// Resolves light/dark at the AppKit layer so the value tracks system appearance automatically. + static func adaptive(light: UInt32, dark: UInt32, alpha: CGFloat = 1.0) -> Color { + Color(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + return NSColor(hex: isDark ? dark : light, alpha: alpha) + }) + } +} + +// MARK: - Theme: the single source of truth for the Lattice Instrument visual language. +// +// Graphite Signal Lab — dark graphite surfaces, 10pt cards, white-opacity hairlines. +// One cyan accent (signal/CTA). Monospaced numerals on opaque surfaces. +// +// Governing laws (DESIGN.md): +// 1. Numbers never touch glass — every numeral sits on an opaque surface. +// 2. One accent (Signal Teal / #48D8C4), spent only on movement / the single CTA. +// 3. Bold is spent on the NUMBERS (tabular mono), not on chrome. +enum Theme { + + // MARK: Palette — Dark is the home key; light is a true peer. + enum Palette { + // ── Base surfaces ────────────────────────────────────────────────────────── + + /// Window background beneath all content. + static let window = Color.adaptive(light: 0xF4F6F8, dark: 0x090C10) + + /// Main screen canvas. + static let canvas = Color.adaptive(light: 0xEEF1F4, dark: 0x0D1117) + + /// Solid sidebar fallback when Reduce Transparency is enabled. + static let sidebarFallback = Color.adaptive(light: 0xF0F3F7, dark: 0x10151C) + + /// Standard panels and cards (spec: surface). Replaces old `panel`. + static let panel = Color.adaptive(light: 0xFFFFFF, dark: 0x121820) + + /// Controls, selected cards, elevated sections. + static let surfaceRaised = Color.adaptive(light: 0xF7F9FB, dark: 0x171E27) + + /// Hovered rows and controls. + static let surfaceHover = Color.adaptive(light: 0xEDF2F5, dark: 0x1C2530) + + /// Charts, logs, code and recessed readouts (spec: surfaceInset). Replaces old `wellSink`. + static let wellSink = Color.adaptive(light: 0xE8EDF2, dark: 0x080B0F) + + /// Alias kept for code that imports surfaceInset directly. + static var surfaceInset: Color { wellSink } + + /// Disabled control fill. + static let surfaceDisabled = Color.adaptive(light: 0xEBEFF3, dark: 0x11161C) + + // ── Text ─────────────────────────────────────────────────────────────────── + + /// Primary labels and prose. Replaces old `ink`. + static let ink = Color.adaptive(light: 0x11151A, dark: 0xF2F5F7) + + /// Alias kept for direct textPrimary references. + static var textPrimary: Color { ink } + + /// Supporting text. Replaces old `inkDim`. + static let inkDim = Color.adaptive(light: 0x56616D, dark: 0xA7B0BA) + + /// Alias kept for direct textSecondary references. + static var textSecondary: Color { inkDim } + + /// Metadata, inactive labels, axis values. + /// WCAG AA fix: 0x646E79 = 4.58:1 on canvas 0xEEF1F4; 0x79838F = 4.92:1 on canvas 0x0D1117. + static let textTertiary = Color.adaptive(light: 0x646E79, dark: 0x79838F) + + /// Disabled controls. + static let textDisabled = Color.adaptive(light: 0x9AA3AD, dark: 0x4E5965) + + // ── Accent ───────────────────────────────────────────────────────────────── + + /// The one accent. Live trace, token stream, now-cursor, focus ring, the single CTA per screen. + static let signal = Color.adaptive(light: 0x078F82, dark: 0x48D8C4) + + /// Alias kept for direct accent references. + static var accent: Color { signal } + + /// Hovered primary action. + static let accentHover = Color.adaptive(light: 0x0AA493, dark: 0x63E2D1) + + /// Pressed primary action. + static let accentActive = Color.adaptive(light: 0x08786F, dark: 0x2EB9A7) + + /// Text/icons on accent fill. + /// WCAG AA fix (light): 0x0A0D11 = 4.88:1 on signal 0x078F82 (white was only 3.99:1). + /// Dark stays 0x04110F = 10.89:1 on signal 0x48D8C4. + static let onAccent = Color.adaptive(light: 0x0A0D11, dark: 0x04110F) + + /// Accent glow fill — accent at 12% opacity. + static let signalGlow = Color.adaptive(light: 0x078F82, dark: 0x48D8C4, alpha: 0.12) + + // ── State semantics ──────────────────────────────────────────────────────── + + /// Idle engine state. + static let idle = Color.adaptive(light: 0x7D8793, dark: 0x7D8793) + + /// Active run. + static let running = Color.adaptive(light: 0x078F82, dark: 0x48D8C4) + + /// Completed, valid, improvement. + static let success = Color.adaptive(light: 0x3BAF5C, dark: 0x6BD58C) + + /// Validation warning, partial issue. + static let amber = Color.adaptive(light: 0xB8730A, dark: 0xF2B85B) + + /// Alias kept for direct warning references. + static var warning: Color { amber } + + /// Failed, invalid, regression. + static let crimson = Color.adaptive(light: 0xC42032, dark: 0xFF6B73) + + /// Alias kept for direct error references. + static var error: Color { crimson } + + // ── Neutral control ──────────────────────────────────────────────────────── + + /// Neutral steel for interactive controls (slider fill, segmented selection, toggle-on, + /// field caret). Spent so the accent above stays reserved for live data + the single CTA. + static let control = Color.adaptive(light: 0x8A909B, dark: 0x6B7280) + + // ── Hairline ─────────────────────────────────────────────────────────────── + + /// 1px ruled borders — depth via line, not shadow. + static let hairline = Color.adaptive(light: 0xDCDFE5, dark: 0x23262E) + + // ── Opacity-based border / overlay helpers (white-relative, use on dark surfaces) ── + + /// Default card border — white at 8%. + static let borderStandard = Color.white.opacity(0.08) + + /// Selected/strong card border — white at 14%. + static let borderStrong = Color.white.opacity(0.14) + + /// Hovered row/control overlay — white at 4%. + static let hoverOverlay = Color.white.opacity(0.04) + + /// Accent selection fill — accent at 12%. + static let selectionFill = Color.adaptive(light: 0x078F82, dark: 0x48D8C4, alpha: 0.12) + + /// Accent selection border — accent at 35%. + static let selectionBorder = Color.adaptive(light: 0x078F82, dark: 0x48D8C4, alpha: 0.35) + + /// Accent focus ring — accent at 55%. + static let focusRing = Color.adaptive(light: 0x078F82, dark: 0x48D8C4, alpha: 0.55) + + /// Chart minor grid — white at 4%. + static let chartGridMinor = Color.white.opacity(0.04) + + /// Chart major grid — white at 8%. + static let chartGridMajor = Color.white.opacity(0.08) + } + + // MARK: Typography — mono is the signature. SF Mono today; swap `mono()` to bundled JetBrains Mono later. + enum Fonts { + /// Tabular monospace for every numeral / data cell. `.monospacedDigit()` is applied at use sites. + static func mono(_ size: CGFloat, _ weight: Font.Weight = .regular) -> Font { + .system(size: size, weight: weight, design: .monospaced) + } + /// SF Pro for titles, labels, prose. + static func display(_ size: CGFloat, _ weight: Font.Weight = .semibold) -> Font { + .system(size: size, weight: weight, design: .default) + } + + // ── Legacy modular scale (preserved for backward compat) ────────────────── + // Modular scale (pt): 11 · 13 · 15 · 21 · 34 · 56 + static let hero = mono(56, .bold) // the loss / compression / PPL headline + static let heroAlt = mono(34, .bold) + static let heroMinor = mono(21, .semibold) + static let wellValue = mono(15, .medium) // readout-well value + static let readout = mono(13) // dense readouts + static let cell = mono(11) // table cells + static let title = display(17, .semibold) + static let body = display(13, .regular) + /// 11pt all-caps instrument label (LOSS, TOK/S, GRAD-NORM). Apply `.textCase(.uppercase)` + tracking. + static let label = display(11, .medium) + + // ── Graphite Signal Lab role scale (new additions) ──────────────────────── + + /// Screen/section title — Models, Train, Quantize. + static let screenTitle = display(22, .semibold) + + /// Selected model or run header inside an inspector. + static let inspectorTitle = display(17, .semibold) + + /// Group labels — always rendered uppercase + 0.85 tracking at use site. + static let sectionLabel = display(11, .semibold) + + /// Row names and values (medium weight body). + static let bodyStrong = display(13, .medium) + + /// Buttons, pickers — 12pt medium. + /// (Named `controlText` to avoid shadowing the `control` palette token.) + static let controlText = display(12, .medium) + + /// Supporting text — 11pt regular. + static let caption = display(11, .regular) + + /// Metric labels, metadata — 10pt medium (+0.45 tracking at use site). + static let micro = display(10, .medium) + + /// Large loss / error / size headline — 20pt SF Mono medium. + static let largeMetric = mono(20, .medium) + + /// Counts, dimensions — 13pt SF Mono medium. + static let metric = mono(13, .medium) + + /// Params, bytes, duration table cells — 12pt SF Mono regular. + static let tableNumeric = mono(12) + + /// Commands and JSON — 12pt SF Mono regular. + static let codeFont = mono(12) + + /// Streaming console — 11pt SF Mono regular. + static let logFont = mono(11) + } + + // MARK: Spacing — 4pt base grid (Graphite Signal Lab); legacy 8pt names preserved. + enum Space { + // ── Legacy 8pt-grid names (kept for backward compat) ───────────────────── + static let xs: CGFloat = 4 + static let sm: CGFloat = 8 + static let md: CGFloat = 12 + static let lg: CGFloat = 16 // internal panel padding + static let xl: CGFloat = 24 // section gutter + static let xxl: CGFloat = 32 + + static let railWidth: CGFloat = 220 + static let inspectorWidth: CGFloat = 300 + static let rowHeight: CGFloat = 28 + static let rowHeightComfortable: CGFloat = 32 + static let labelTracking: CGFloat = 0.6 + + // ── 4pt scale additions ─────────────────────────────────────────────────── + static let space1: CGFloat = 4 + static let space2: CGFloat = 8 + static let space3: CGFloat = 12 + static let space4: CGFloat = 16 + static let space5: CGFloat = 20 + static let space6: CGFloat = 24 + static let space8: CGFloat = 32 + static let space10: CGFloat = 40 + static let space12: CGFloat = 48 + + // ── Layout constants ────────────────────────────────────────────────────── + static let sidebarMin: CGFloat = 196 + static let sidebarIdeal: CGFloat = 212 + static let sidebarMax: CGFloat = 240 + static let configRail: CGFloat = 320 + static let detailInspector: CGFloat = 300 + static let controlHeight: CGFloat = 30 + static let controlHeightCompact: CGFloat = 26 + static let controlHeightLarge: CGFloat = 34 + static let chatMaxWidth: CGFloat = 920 + static let dataMaxWidth: CGFloat = 1480 + static let emptyStateMaxWidth: CGFloat = 360 + } + + // MARK: Corner radii — Graphite Signal Lab: panels are 10pt cards; wells 8pt. + enum Radius { + /// Standard card / panel / chart container (was 0 — now 10pt continuous cards). + static let panel: CGFloat = 10 + + /// Readout well / metric well (was 6 — now 8pt). + static let well: CGFloat = 8 + + /// Buttons, text fields, segmented items. + static let control: CGFloat = 6 + + /// Status capsules (backward-compat alias; use `.infinity` for a true capsule at use site). + static let pill: CGFloat = 6 + + /// Command / composer bar. + static let commandBar: CGFloat = 10 + + /// Compact badges and tiny indicators. + static let badge: CGFloat = 4 + } + + // MARK: Motion — mechanical, never bouncy. + enum Motion { + static let tick: Double = 0.12 + static let focus: Double = 0.18 + static let pane: Double = 0.24 + /// Spring is reserved for EXACTLY one element: the adapter hot-swap fader. + static let faderSpring = Animation.spring(response: 0.32, dampingFraction: 0.85) + static let chartCommitHz: Double = 20 + static let numeralTickHz: Double = 8 + + // ── Graphite Signal Lab timing additions ────────────────────────────────── + /// Hover and selection — 100–120 ms ease-out. + static let hover: Double = 0.11 + + /// Button pressed state — 60–80 ms. + static let press: Double = 0.07 + + /// Metric numeric transition — 120–160 ms. + static let metric: Double = 0.14 + + /// Progress interpolation — 120–180 ms. + static let progress: Double = 0.15 + } +} + +// MARK: - Reusable view modifiers expressing the laws + +extension View { + /// An OPAQUE instrument label: 11pt all-caps, dimmed, tracked. For LOSS / TOK/S / GRAD-NORM. + func instrumentLabel() -> some View { + self.font(Theme.Fonts.label) + .textCase(.uppercase) + .tracking(Theme.Space.labelTracking) + .foregroundStyle(Theme.Palette.inkDim) + } +} diff --git a/apps/macos/docs/INSTRUMENT_SCOPE.md b/apps/macos/docs/INSTRUMENT_SCOPE.md new file mode 100644 index 0000000000..22013e5a30 --- /dev/null +++ b/apps/macos/docs/INSTRUMENT_SCOPE.md @@ -0,0 +1,427 @@ +# Lattice Instrument — Scope & Architecture + +> Status as of 2026-06-21. Every claim cites a real file. `EXISTS` = code present. +> `PARTIAL` = code present, stated limitations apply. `PROPOSED` = not yet implemented. + +--- + +## 1. Purpose & Scope + +Lattice Instrument is a zero-dependency macOS 14 SwiftUI application (Swift 6.0 tools, +Package.swift line 7) that wraps the Rust `lattice-tune` and `lattice-inference` engine +binaries as subprocess instruments. Its purpose is to let Ocean run LoRA fine-tuning, +model quantization, chat testing, and training-data inspection without leaving a macOS +window — while the Rust binaries remain the single source of correctness. + +The app is NOT a trainer or inference engine. It is an instrument panel: it spawns +processes, parses their output, and renders live readouts. + +**Platform**: macOS 14+, Swift 6.0 tools, Swift language mode v5, zero external deps. +**Bundle ID**: `ai.khive.lattice.studio` (package-app.sh line 9). +**Min window**: 1080×720 (LatticeStudioApp.swift line 12). + +--- + +## 2. Current State Inventory + +### 2.1 Swift Source Files (28 files in 7 directories) + +| Dir | File | Role | State | +|-----|------|------|-------| +| App/ | LatticeStudioApp.swift | @main entry, @NSApplicationDelegateAdaptor, @State AppStore | EXISTS | +| Bridge/ | LatticeBridge.swift | Process spawn, binary resolution, model/adapter discovery | EXISTS | +| Bridge/ | LatticeEvents.swift | `@@lattice` protocol decoder, HumanLineParser, QuantAccumulator | EXISTS | +| Bridge/ | Drivers.swift | TrainConfig, QuantConfig, GenConfig typed arg-builders; AppStore launch extensions | EXISTS | +| Store/ | AppStore.swift | @Observable @MainActor singleton, run lifecycle, event routing, run archive | EXISTS | +| Store/ | DomainModels.swift | Screen, ModelInfo, AdapterInfo, RunKind, LiveRun, TrainPoint, RunRecord | EXISTS | +| Shell/ | ContentView.swift | NavigationSplitView two-pane shell, CommandBar overlay, global ⌘1-6 shortcuts | EXISTS | +| Shell/ | LeftRail.swift | Wordmark, live RUN block (step+loss), nav rows, system memory bar | EXISTS | +| Screens/ | TrainScreen.swift | LoRA fine-tune config + live oscilloscope + control strip | EXISTS | +| Screens/ | QuantizeScreen.swift | Q4 / QuaRot config + layer progress + mass comparison | EXISTS | +| Screens/ | ModelsScreen.swift | Model DataTable + inspector + action row (Train/Quantize/Chat→) | EXISTS | +| Screens/ | ChatScreen.swift | Config strip + single-variant transcript + generate_lora subprocess | PARTIAL | +| Screens/ | DataScreen.swift | Source dir scan, .jsonl inspector, builder-script copy buttons | EXISTS | +| Screens/ | RunsScreen.swift | Run archive DataTable + live banner + inspector | EXISTS | +| Screens/ | ScreenScaffold.swift | Shared header chrome (index / title / subtitle / trailing slot) | EXISTS | +| Components/ | CommandBar.swift | ⌘K floating palette, fuzzy prefix match, 7 default commands | EXISTS | +| Components/ | StripChart.swift | Swift Charts oscilloscope: LineMark + AreaMark + scrub-to-freeze | EXISTS | +| Components/ | HeroNumber.swift | 56pt tabular-mono hero with .contentTransition(.numericText()) | EXISTS | +| Components/ | GatePill.swift | PASS/WARN/FAIL/RUN verdict pill, 6px radius, animated pulse on RUN | EXISTS | +| Components/ | FaderToggle.swift | Console spring-fader for binary mode choices (Q4↔QuaRot, BASE↔+ADAPTER) | EXISTS | +| Components/ | ReadoutWell.swift | 15pt tabular-mono well: label + value + unit + delta caret | EXISTS | +| Components/ | OpaquePanel.swift | Instrument panel surface (opaque, 1px hairline, 0px radius) + well surface | EXISTS | +| Components/ | ParamRow.swift | Config param row variants used in TrainScreen/QuantizeScreen | EXISTS | +| Components/ | DataTable.swift | Generic sortable DataTable used across Models/Data/Runs screens | EXISTS | +| Components/ | ContrastPair.swift | Before/after contrast pair (fold-wipe on completion) used in QuantizeScreen | EXISTS | +| Components/ | MassBars.swift | Dual mass bars (before/after MB) used in QuantizeScreen | EXISTS | +| Components/ | KeyCapChip.swift | ⌘K keyboard shortcut keycap chip used in CommandBar | EXISTS | +| Theme/ | Theme.swift | Adaptive palette, SF Mono fonts, motion constants — no asset catalog | EXISTS | + +### 2.2 State Model + +``` +AppStore (@Observable @MainActor) + ├── selection: Screen current nav screen + ├── models: [ModelInfo] discovered on disk + ├── runs: [RunRecord] JSON-persisted archive + ├── liveRun: LiveRun? the one active subprocess run + ├── workingModel: ModelInfo? explicit cross-screen target + ├── handle: RunHandle? the one live Process wrapper + └── binariesReady: Bool prebuilt .lattice binary present +``` + +Only one subprocess runs at a time. `AppStore.launch()` calls `prior.stop()` before starting +a new one (AppStore.swift line 108). + +### 2.3 What Works Today + +- TrainScreen: full config → subprocess → live step/loss/eval/done parsing → StripChart, + ReadoutWells, PAUSE/RESUME/STOP, adapter path on save. End-to-end verified in production + (NLL 5.18→0.61 documented in MEMORY.md). +- QuantizeScreen: Q4 and QuaRot, layer progress, mass bars, ratio, verdict GatePill. + Drives both `quantize_q4` and `quantize_quarot` binaries. +- ModelsScreen: model discovery (parses config.json), layer summary, adapter list, navigate-to + shortcuts, Finder reveal. +- DataScreen: .jsonl scan, summary stats, 5-example preview, builder-script copy buttons. +- RunsScreen: persistent run archive (Application Support/LatticeStudio/runs.json), live banner. +- CommandBar: ⌘K palette with 7 commands, fuzzy match. +- ChatScreen config strip and transcript: functional for single-variant generation. + +--- + +## 3. Engine-Wrapping Architecture + +### 3.1 Process Spawn + +`RunHandle` (LatticeBridge.swift) wraps `Foundation.Process` with two `Pipe`s: + +``` +AppStore.launch(bin, args) + → LatticeBridge.launchSpec(bin, args) resolves binary path + → RunHandle.start(spec) process.launch() + stdout → Pipe → readabilityHandler + split on 0x0A + LatticeEventParser.parse(line:) + → .trainStep / .quantLayer / .genToken / .status / .unknown + stderr → merged into same outPipe + stdin ← RunHandle.send(line:) (chat only) + SIGSTOP / SIGCONT via RunHandle.pause() / resume() + → AppStore.consume(event, into: liveRun) main-queue dispatch + → LiveRun @Observable → SwiftUI renders +``` + +`Source: LatticeBridge.swift (RunHandle class) + AppStore.swift lines 96-120` + +### 3.2 Binary Path Resolution + +Resolution order (LatticeBridge.swift `launchSpec`): + +1. `.app/Contents/Resources/bin/` (distribution build) +2. `$LATTICE_BIN_DIR/` (env override) +3. `../target/release/` (relative to bundle's Resources) +4. `cargo run -p --bin --features ` (fallback) + +`Source: LatticeBridge.swift + apps/macos/DISTRIBUTION.md` + +### 3.3 Bundled Binaries (6) + +Defined in `apps/macos/scripts/package-app.sh`: + +| Binary | Crate | Features | Used For | +|--------|-------|----------|----------| +| `quantize_q4` | lattice-inference | (none) | Q4 quantize | +| `quantize_quarot` | lattice-inference | (none) | QuaRot quantize | +| `lattice` | lattice-inference | (none) | main inference binary | +| `qwen35_generate` | lattice-inference | (none) | Qwen3.5 generation | +| `train_grad_full` | lattice-tune | `train-backward` | LoRA fine-tune | +| `generate_lora` | lattice-tune | `safetensors,inference-hook` | LoRA chat generation | + +### 3.4 Event Protocol + +The bridge has two parse paths (LatticeEvents.swift): + +**Path 1 — JSON sentinel (preferred, available when `--json` passed):** + +``` +@@lattice {"ev":"train_step","step":5,"loss":3.2341,"lr":0.001000} +@@lattice {"ev":"train_eval","step":5,"val_loss":3.1200,"best_val":3.1200} +@@lattice {"ev":"train_done","base_nll":5.18,"final_nll":0.61,"duration_s":120.0} +@@lattice {"ev":"gen_token","token":" hello","done":false} +@@lattice {"ev":"gen_token","token":"","done":true,"tok_s":28.4,"ttft_ms":312.0} +``` + +Prefix constant: `let kLatticeEventPrefix = "@@lattice "` (LatticeEvents.swift line 14). +Both `train_grad_full` and `generate_lora` emit these when `--json` is passed +(confirmed in train_grad_full.rs lines 1155/1239/1331; generate_lora.rs lines 166/183). + +**Path 2 — HumanLineParser regex fallback (for quantize bins and older binaries):** + +`HumanLineParser` parses current human-readable stdout of `quantize_q4` and `quantize_quarot` +via regex (LatticeEvents.swift). These bins have no `--json` flag — the human-readable format +is the only output. `QuantAccumulator` accumulates multiple summary lines to emit `quantDone`. + +**Path 3 — pass-through:** Any line not matching Path 1 or 2 becomes `.status(line)` and +appends to `LiveRun.log`. + +ANSI escape sequences are stripped before parsing via `stripANSI()` (LatticeEvents.swift). + +### 3.5 Verified CLI Flags + +**train_grad_full** (train_grad_full.rs lines 68-82): + +``` +--model-dir model directory +--data-dir dataset dir with train.jsonl + valid.jsonl +--first-layer first trained layer (default 19) +--steps Adam steps (default 25) +--lr learning rate (default 1e-3) +--rank LoRA rank (default 8) +--alpha LoRA alpha (default 16.0) +--seq-len max tokens per sample (default 64) +--max-train training samples cap (default 3) +--max-valid held-out samples for eval (default 16) +--log-every print NLL every N steps (default 5) +--json emit @@lattice JSON events to stdout +--save write PEFT safetensors adapter after training +``` + +TrainConfig in Drivers.swift maps 1:1 to these flags. The `--json` flag comment in Drivers.swift +notes "future --json mode; older binaries ignore unknown flags" — but the flag IS implemented in +the current binary (train_grad_full.rs line 743). + +**generate_lora** (generate_lora.rs lines 70-76): + +``` +--model-dir | --model +--lora adapter path +--prompt +--max-tokens +--temperature +--json emit @@lattice gen_token events +--seed +``` + +**quantize_q4 / quantize_quarot** (confirmed in package-app.sh; no `--json`): + +``` +--model-dir +--output-dir +--dry-run (Q4 and QuaRot) +--seed (QuaRot only, default 0xC0FFEE) +``` + +### 3.6 ASCII Architecture Diagram + +``` +┌─────────────────────────────────────────────────────┐ +│ LatticeStudio.app │ +│ │ +│ ┌──────────┐ ┌───────────┐ ┌─────────────┐ │ +│ │ AppStore │←─→ │ LiveRun │←── │ SwiftUI │ │ +│ │@Observable │@Observable│ │ Screens │ │ +│ └────┬─────┘ └───────────┘ └─────────────┘ │ +│ │ │ +│ ┌────▼──────────────────────────────────────────┐ │ +│ │ RunHandle │ │ +│ │ Process │ outPipe (stdout+stderr merged) │ │ +│ │ │ inPipe (stdin, chat only) │ │ +│ │ │ SIGSTOP/SIGCONT │ │ +│ └────┬──────────────────────────────────────────┘ │ +│ │ stdout lines │ +│ ┌────▼──────────────────────────────────────────┐ │ +│ │ LatticeEventParser │ │ +│ │ Path 1: @@lattice prefix → JSON decode │ │ +│ │ Path 2: HumanLineParser (quantize bins) │ │ +│ │ Path 3: .status(line) passthrough │ │ +│ └───────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────┘ + │ spawn + ▼ +┌──────────────────────────────────────────────────┐ +│ Rust Binaries (.app/Contents/Resources/bin/) │ +│ │ +│ train_grad_full (lattice-tune +train-backward)│ +│ generate_lora (lattice-tune +safetensors) │ +│ quantize_q4 (lattice-inference) │ +│ quantize_quarot (lattice-inference) │ +│ lattice (lattice-inference) │ +│ qwen35_generate (lattice-inference) │ +└──────────────────────────────────────────────────┘ +``` + +--- + +## 4. Surface-by-Surface Status + +### Surface 1: LoRA Fine-Tune (TrainScreen.swift) + +| Feature | State | Notes | +|---------|-------|-------| +| 12 config params (ParamRow) | EXISTS | model-dir, data-dir, first-layer, steps, lr, rank, alpha, seq-len, max-train, max-valid, log-every, save-path | +| NSOpenPanel directory choosers | EXISTS | model-dir + data-dir | +| TrainConfig → subprocess | EXISTS | Drivers.swift `startTrain(_:)` | +| `@@lattice` train_step events | EXISTS | real-time loss, lr, grad_norm, val_loss, tok_s | +| StripChart oscilloscope | EXISTS | 20Hz, scrub-to-freeze, val_loss overlay | +| ReadoutWells (6: STEP, TRAIN NLL, HELD-OUT, Δ FROM BASE, TOK/S, BEST VAL) | EXISTS | | +| HeroNumber (56pt best val) | EXISTS | .numericText() per-digit tick | +| PAUSE / RESUME / STOP | EXISTS | SIGSTOP/SIGCONT via RunHandle | +| Adapter path display on done | EXISTS | `@@lattice train_done.saved` field | +| `--json` mode actually wired | EXISTS | `--json` in TrainConfig.args; binary supports it | +| Grad-norm scheduling display | PARTIAL | TrainPoint has gradNorm field; no dedicated well yet | +| ETA display | PARTIAL | eta_s in TrainStep struct (Drivers.swift); no ReadoutWell shows it | +| Per-step LR schedule chart | PROPOSED | lr field present in TrainPoint; not charted separately | +| Multi-run overlay | PROPOSED | RunRecord has no point series; chart can only show the live run | + +### Surface 2: Model Management (ModelsScreen.swift) + +| Feature | State | Notes | +|---------|-------|-------| +| Model discovery (config.json) | EXISTS | LatticeBridge.discoverModels() | +| Layer summary (GDN/GQA counts) | EXISTS | parses `layer_types` array from config.json | +| Adapter discovery | EXISTS | LatticeBridge.discoverAdapters() | +| DataTable (8 columns) | EXISTS | NAME, FORMAT, PARAMS, LAYERS, SIZE, FILES, TOK, #ADAPTERS | +| Inspector panel | EXISTS | model wells + adapter list + Reveal buttons | +| Train→ / Quantize→ / Chat→ nav | EXISTS | AppStore.use(_:on:) | +| Finder reveal (NSWorkspace) | EXISTS | | +| Adapter rank/alpha metadata | MISSING | AdapterInfo.rank/alpha/targetModules always nil; discoverAdapters() does not parse adapter config | +| Model delete | PROPOSED | no delete action in ModelsScreen | +| Model download | PROPOSED | out of scope for v1; no CLI surface exists | + +### Surface 3: Training Data Curation (DataScreen.swift) + +| Feature | State | Notes | +|---------|-------|-------| +| Source dir field + Scan | EXISTS | immediate + 1 level deep | +| .jsonl file enumeration | EXISTS | | +| Summary strip (FILES, ≈TOKENS, AVG LEN, TRAIN, VALID) | EXISTS | | +| HeroNumber (total examples) | EXISTS | | +| Files DataTable | EXISTS | | +| 5-example preview panel | EXISTS | parses prompt/completion or raw line | +| Builder script copy buttons | EXISTS | `uv run scripts/build_claude_lora_dataset.py` + `uv run scripts/budget_lora_dataset.py` | +| Token count accuracy | PARTIAL | approximate chars/4; NOT lattice tokenizer | +| Train/valid split detection | PARTIAL | detected by filename (train.jsonl vs valid.jsonl); no visual split editor | +| In-app example editing | PROPOSED | read-only; editing out of scope for v1 | +| Builder script execution | PROPOSED | explicitly not runnable from UI (DataScreen.swift comment) | +| Lattice tokenizer integration | PROPOSED | would need FFI or subprocess to get exact counts | + +### Surface 4: Sample-Testing Chat (ChatScreen.swift) + +| Feature | State | Notes | +|---------|-------|-------| +| Config strip (model + adapter pickers) | EXISTS | | +| FaderToggle BASE↔+ADAPTER | EXISTS | changes adapterPath in next GenConfig only | +| Sampling params (temperature, max-tokens, seed) | EXISTS | | +| Single-variant transcript | EXISTS | ChatTurn model; streaming via genText accumulation | +| generate_lora subprocess + `--json` | EXISTS | GenConfig → AppStore.runGenerate() | +| Streaming gen_token events | EXISTS | onChange on store.liveRun?.genText accumulates deltas | +| Non-streaming fallback | EXISTS | filters log lines for non-"$ " prefix | +| True A/B lockstep streaming | MISSING | FaderToggle flip is manual; two parallel subprocesses never run; "0 ms reload" text is hardcoded UI label (FaderToggle.swift line 137) | +| Conversation history (multi-turn) | MISSING | each submission is a fresh subprocess invocation; no history passed | +| Adapter hot-swap mid-conversation | MISSING | DESIGN.md arc_swap/LiveModel references are aspirational; no engine API exists | + +--- + +## 5. Gaps & Risks + +### G1 — No true A/B side-by-side (MISSING, HIGH) + +`ChatScreen.swift` comment: "We do NOT auto-run both variants — manual flip+resend is the v1 +A/B story." The FaderToggle label "0 ms reload" is hardcoded text (FaderToggle.swift line 137), +not a live measurement. Running base and adapter in parallel requires two simultaneous `RunHandle` +instances and a side-by-side transcript view. AppStore currently enforces one active run at a +time (AppStore.swift line 108: `prior.stop()`). + +### G2 — quantize bins have no `--json` (STRUCTURAL, HIGH) + +`quantize_q4` and `quantize_quarot` emit only human-readable stdout. There is no `--json` flag +in these bins (confirmed by absence in package-app.sh and Rust source grep). All quantize +progress depends on `HumanLineParser` regex matching. `QuantDone.est_ppl_delta` is always nil +because no Rust producer emits that field. If the quantize binary output format changes, the +parser silently degrades to `.status` passthrough. + +### G3 — Adapter metadata not parsed (MISSING, MEDIUM) + +`AdapterInfo.rank`, `.alpha`, and `.targetModules` are always nil (DomainModels.swift lines +63-65; LatticeBridge.discoverAdapters() confirms no config parsing). The inspector shows +adapter file size and name only. Users cannot distinguish a rank-4 from a rank-64 adapter in +the UI without inspecting the file manually. + +### G4 — Multi-turn conversation not supported (MISSING, MEDIUM) + +Each chat generation is a fresh subprocess invocation with a single `--prompt` string. There is +no mechanism to pass prior turns to `generate_lora`. The binary interface has no `--history` +flag. + +### G5 — Token count is approximate (MEDIUM) + +DataScreen uses `chars / 4` for token estimation (DataScreen.swift comment). The actual lattice +tokenizer (a BPE tokenizer) is not called from Swift. For CJK or code content the estimate +degrades significantly. + +### G6 — No historical chart replay (LOW) + +`RunRecord` (DomainModels.swift lines 74-85) stores only scalar summary (lastLoss, bestVal, +durationS). The point series is discarded after the run ends. RunsScreen has no chart tab. + +### G7 — JetBrains Mono deferred (LOW) + +Theme.swift uses `.system(design: .monospaced)` (SF Mono). DESIGN.md specifies JetBrains Mono +for the number face. A bundled font requires adding the .ttf to Package.swift resources and +updating Theme.Fonts — no external dependency needed. + +### R1 — HumanLineParser fragility + +The regex-based fallback for quantize output is the only parse path for those bins. Format +changes in Rust (adding a unit suffix, changing a field name) silently degrade to `.status` +passthrough with no error. Mitigation: add `--json` to both quantize bins. + +### R2 — Single-process constraint blocks parallel A/B + +The current `AppStore.launch()` stop-prior-on-new-launch design is correct for the current +feature set but is an architectural blocker for true A/B. Lifting this requires either a +second `handle2: RunHandle?` field and a parallel `liveRun2`, or a session abstraction. + +--- + +## 6. Proposed Next Slices + +### P0 — Add `--json` to quantize bins (1 day, Rust + Swift) + +Remove the HumanLineParser fragility for the quantize surface. In `quantize_q4` and +`quantize_quarot`, add `--json` flag parsing and emit `@@lattice quant_layer` and +`@@lattice quant_done` events, including `est_ppl_delta` (compute from before/after perplexity +probes or leave as nil until measured). In Swift, the HumanLineParser stays as fallback for +older binaries. Immediate benefit: `QuantDone.est_ppl_delta` becomes populated; parser is +no longer regex-fragile; protocol is consistent across all 4 event-emitting bins. + +Files: `crates/inference/src/bin/quantize_q4.rs`, `quantize_quarot.rs`, `LatticeEvents.swift` +(minimal change — struct already has the field). + +### P1 — Adapter metadata from config (0.5 day, Swift only) + +In `LatticeBridge.discoverAdapters()`, after discovering an adapter directory, attempt to read +`adapter_config.json` (standard PEFT format) and parse `r` → `rank`, `lora_alpha` → `alpha`, +`target_modules` → `targetModules`. If the file is absent or malformed, values remain nil +(current behavior). This unblocks ModelsScreen inspector from showing useful adapter detail and +lets TrainScreen auto-populate rank/alpha when "use existing adapter" is a future feature. + +Files: `LatticeBridge.swift` (`discoverAdapters` function only). + +### P2 — True A/B side-by-side chat (3-5 days, Swift architecture change) + +Lift the single-run constraint for the Chat surface. Add `handle2: RunHandle?` and +`liveRun2: LiveRun?` to AppStore (or introduce a `ChatSession` model that holds two +`(RunHandle, LiveRun)` pairs). ChatScreen renders an HSplitView with two transcript columns. +The FaderToggle becomes a "run both" trigger. The "0 ms reload" hardcoded label in +FaderToggle.swift line 137 becomes a real measurement from the time between the two +`gen_token done` events. This requires the binary to support a stable `--json` streaming +interface (already true for generate_lora). + +Files: `AppStore.swift`, `ChatScreen.swift`, `FaderToggle.swift`, `DomainModels.swift`. + +--- + +*Document generated from source reads. All file paths are absolute within the lattice repo at +`/Users/lion/projects/khive/lattice/`. No build was run. All claims cite specific file and +line numbers verified in the research phase.* diff --git a/apps/macos/scripts/generate-icon.py b/apps/macos/scripts/generate-icon.py new file mode 100755 index 0000000000..b024ca9b49 --- /dev/null +++ b/apps/macos/scripts/generate-icon.py @@ -0,0 +1,124 @@ +#!/usr/bin/env python3 +""" +Generate the LatticeStudio.icns app icon. + +Design: dark rounded-rect (#0A0B0D), teal (#00E5C7) lattice grid glyph. +Renders a 5x5 node grid connected by edges, with corner nodes highlighted. + +Usage: + uv run scripts/generate-icon.py [--out ] + # Outputs: Resources/LatticeStudio.icns +""" +import argparse, os, subprocess, sys, tempfile, shutil, math +from pathlib import Path + +try: + from PIL import Image, ImageDraw +except ImportError: + subprocess.check_call([sys.executable, "-m", "pip", "install", "pillow"], stdout=subprocess.DEVNULL) + from PIL import Image, ImageDraw + +REPO_ROOT = Path(__file__).resolve().parents[3] # lattice/ +SCRIPT_DIR = Path(__file__).resolve().parent +DEFAULT_OUT = SCRIPT_DIR.parent / "Resources" / "LatticeStudio.icns" + +BG = (10, 11, 13) # #0A0B0D +TEAL = (0, 229, 199) # #00E5C7 +TEAL_DIM = (0, 140, 122) # dimmed edges + + +def render_icon(size: int) -> Image.Image: + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + draw = ImageDraw.Draw(img) + + # Rounded-rect background — corner radius ~22% of size + r = int(size * 0.22) + draw.rounded_rectangle([(0, 0), (size - 1, size - 1)], radius=r, fill=BG + (255,)) + + # Grid parameters: 4x4 grid of lines (5 nodes per axis) occupying ~55% of canvas + pad = size * 0.20 + grid_size = size - 2 * pad + cols = rows = 4 # 5 nodes = 4 intervals + + nodes = [] + for gy in range(rows + 1): + for gx in range(cols + 1): + x = pad + gx * (grid_size / cols) + y = pad + gy * (grid_size / rows) + nodes.append((x, y)) + + # Draw edges first (behind nodes) + edge_w = max(1, int(size * 0.018)) + for gy in range(rows + 1): + for gx in range(cols + 1): + idx = gy * (cols + 1) + gx + nx, ny = nodes[idx] + # Horizontal edge + if gx < cols: + rx, ry = nodes[idx + 1] + draw.line([(nx, ny), (rx, ry)], fill=TEAL_DIM + (200,), width=edge_w) + # Vertical edge + if gy < rows: + bx, by = nodes[idx + (cols + 1)] + draw.line([(nx, ny), (bx, by)], fill=TEAL_DIM + (200,), width=edge_w) + + # Draw nodes — larger at corners, medium at edges, small inside + for gy in range(rows + 1): + for gx in range(cols + 1): + is_corner = (gx in (0, cols)) and (gy in (0, rows)) + is_edge = (gx in (0, cols)) or (gy in (0, rows)) + if is_corner: + base_r = size * 0.048 + alpha = 255 + elif is_edge: + base_r = size * 0.030 + alpha = 220 + else: + base_r = size * 0.020 + alpha = 180 + nr = int(base_r) + x, y = nodes[gy * (cols + 1) + gx] + draw.ellipse( + [(x - nr, y - nr), (x + nr, y + nr)], + fill=TEAL + (alpha,) + ) + + return img + + +ICON_SIZES = [16, 32, 128, 256, 512] + + +def build_iconset(iconset_dir: Path): + iconset_dir.mkdir(parents=True, exist_ok=True) + for sz in ICON_SIZES: + img = render_icon(sz) + img.save(iconset_dir / f"icon_{sz}x{sz}.png") + img2 = render_icon(sz * 2).resize((sz * 2, sz * 2), Image.LANCZOS) + img2.save(iconset_dir / f"icon_{sz}x{sz}@2x.png") + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument("--out", default=str(DEFAULT_OUT)) + args = parser.parse_args() + + out = Path(args.out) + out.parent.mkdir(parents=True, exist_ok=True) + + with tempfile.TemporaryDirectory() as tmp: + iconset = Path(tmp) / "LatticeStudio.iconset" + build_iconset(iconset) + result = subprocess.run( + ["iconutil", "-c", "icns", str(iconset), "-o", str(out)], + capture_output=True, text=True + ) + if result.returncode != 0: + print(f"iconutil failed: {result.stderr}", file=sys.stderr) + sys.exit(1) + + print(f"Icon written: {out} ({out.stat().st_size:,} bytes)") + + +if __name__ == "__main__": + main() diff --git a/apps/macos/scripts/package-app.sh b/apps/macos/scripts/package-app.sh new file mode 100755 index 0000000000..c62deaf7c1 --- /dev/null +++ b/apps/macos/scripts/package-app.sh @@ -0,0 +1,203 @@ +#!/usr/bin/env bash +# package-app.sh — Build and package LatticeStudio.app + .dmg + .zip +# +# Usage: +# ./scripts/package-app.sh [--out ] [--skip-build] [--skip-cargo] +# +# Options: +# --out Output directory (default: apps/macos/dist/) +# --skip-build Skip `swift build -c release` (use existing .build/release/LatticeStudio) +# --skip-cargo Skip cargo builds (use existing target/release/ binaries) +# +# Idempotent: re-running overwrites dist/ cleanly. +# +# Signing: ad-hoc codesign only (no Developer ID required). +# Recipients must right-click → Open, or: xattr -dr com.apple.quarantine LatticeStudio.app +# See DISTRIBUTION.md for the Developer ID upgrade path. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +MACOS_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" # apps/macos/ +REPO_ROOT="$(cd "$MACOS_DIR/../.." && pwd)" # lattice/ +OUT_DIR="$MACOS_DIR/dist" +SKIP_BUILD=false +SKIP_CARGO=false + +# --- Parse args --- +while [[ $# -gt 0 ]]; do + case "$1" in + --out) OUT_DIR="$(realpath "$2")"; shift 2 ;; + --skip-build) SKIP_BUILD=true; shift ;; + --skip-cargo) SKIP_CARGO=true; shift ;; + *) echo "Unknown arg: $1"; exit 1 ;; + esac +done + +APP_NAME="LatticeStudio" +BUNDLE="$OUT_DIR/$APP_NAME.app" +VERSION="0.3.0" +BUNDLE_ID="ai.khive.lattice.studio" + +echo "==> Package $APP_NAME v$VERSION" +echo " Repo: $REPO_ROOT" +echo " Output: $OUT_DIR" + +# --- Step 1: Swift release build --- +if [ "$SKIP_BUILD" = false ]; then + echo "" + echo "==> [1/6] swift build -c release" + swift build -c release --package-path "$MACOS_DIR" +fi +SWIFT_BIN="$MACOS_DIR/.build/release/$APP_NAME" +if [ ! -x "$SWIFT_BIN" ]; then + echo "ERROR: $SWIFT_BIN not found or not executable" + exit 1 +fi +echo " Swift binary: $(du -sh "$SWIFT_BIN" | awk '{print $1}')" + +# --- Step 2: Cargo release builds --- +if [ "$SKIP_CARGO" = false ]; then + echo "" + echo "==> [2/6] cargo build --release (9 engine binaries)" + # lattice-inference binaries (no extra features) + for BIN in quantize_q4 quantize_quarot lattice qwen35_generate; do + echo " cargo build --release -p lattice-inference --bin $BIN" + cargo build --release -p lattice-inference --bin "$BIN" \ + --manifest-path "$REPO_ROOT/Cargo.toml" + done + # lattice-tune: train_grad_full needs train-backward feature + echo " cargo build --release -p lattice-tune --bin train_grad_full --features train-backward" + cargo build --release -p lattice-tune --bin train_grad_full \ + --features train-backward \ + --manifest-path "$REPO_ROOT/Cargo.toml" + # lattice-tune: generate_lora needs safetensors,inference-hook + echo " cargo build --release -p lattice-tune --bin generate_lora --features safetensors,inference-hook" + cargo build --release -p lattice-tune --bin generate_lora \ + --features "safetensors,inference-hook" \ + --manifest-path "$REPO_ROOT/Cargo.toml" + # lattice-inference: eval_perplexity needs f16,metal-gpu (CPU bf16 + Metal Q4/QuaRot PPL) + echo " cargo build --release -p lattice-inference --bin eval_perplexity --features f16,metal-gpu" + cargo build --release -p lattice-inference --bin eval_perplexity \ + --features "f16,metal-gpu" \ + --manifest-path "$REPO_ROOT/Cargo.toml" + # lattice-inference: chat_metal is the GPU (Metal) generation path for Chat (bf16/Q4 + LoRA-on-Metal) + echo " cargo build --release -p lattice-inference --bin chat_metal --features f16,metal-gpu" + cargo build --release -p lattice-inference --bin chat_metal \ + --features "f16,metal-gpu" \ + --manifest-path "$REPO_ROOT/Cargo.toml" + # lattice-embed: embed CLI (native default features) + echo " cargo build --release -p lattice-embed --bin embed" + cargo build --release -p lattice-embed --bin embed \ + --manifest-path "$REPO_ROOT/Cargo.toml" +fi + +TARGET_RELEASE="$REPO_ROOT/target/release" +for BIN in quantize_q4 quantize_quarot lattice qwen35_generate train_grad_full generate_lora eval_perplexity embed chat_metal; do + if [ ! -x "$TARGET_RELEASE/$BIN" ]; then + echo "ERROR: $TARGET_RELEASE/$BIN not found" + exit 1 + fi +done +echo " All 9 engine binaries present" + +# --- Step 3: Construct .app bundle --- +echo "" +echo "==> [3/6] Construct $APP_NAME.app" +rm -rf "$BUNDLE" +mkdir -p "$BUNDLE/Contents/MacOS" +mkdir -p "$BUNDLE/Contents/Resources/bin" + +# Executable +cp "$SWIFT_BIN" "$BUNDLE/Contents/MacOS/$APP_NAME" +chmod +x "$BUNDLE/Contents/MacOS/$APP_NAME" + +# Engine binaries +for BIN in quantize_q4 quantize_quarot lattice qwen35_generate train_grad_full generate_lora eval_perplexity embed chat_metal; do + cp "$TARGET_RELEASE/$BIN" "$BUNDLE/Contents/Resources/bin/$BIN" + chmod +x "$BUNDLE/Contents/Resources/bin/$BIN" +done +echo " Copied 9 engine binaries → Contents/Resources/bin/" + +# App icon +ICNS_SRC="$MACOS_DIR/Resources/LatticeStudio.icns" +if [ -f "$ICNS_SRC" ]; then + cp "$ICNS_SRC" "$BUNDLE/Contents/Resources/LatticeStudio.icns" + echo " Copied icon" +else + echo " WARNING: $ICNS_SRC not found, regenerating..." + uv run "$MACOS_DIR/scripts/generate-icon.py" --out "$ICNS_SRC" + cp "$ICNS_SRC" "$BUNDLE/Contents/Resources/LatticeStudio.icns" +fi + +# --- Step 4: Info.plist --- +echo "" +echo "==> [4/6] Write Info.plist" +cat > "$BUNDLE/Contents/Info.plist" < + + + + CFBundleIdentifier + $BUNDLE_ID + CFBundleName + Lattice Studio + CFBundleDisplayName + Lattice Studio + CFBundleExecutable + $APP_NAME + CFBundleShortVersionString + $VERSION + CFBundleVersion + $VERSION + CFBundlePackageType + APPL + CFBundleIconFile + LatticeStudio + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + LSApplicationCategoryType + public.app-category.developer-tools + NSPrincipalClass + NSApplication + NSHumanReadableCopyright + Copyright © 2026 khive AI. All rights reserved. + + +PLIST +echo " Info.plist written (bundle id: $BUNDLE_ID)" + +# --- Step 5: Ad-hoc codesign --- +echo "" +echo "==> [5/6] Ad-hoc codesign" +codesign --force --deep --options runtime --sign - "$BUNDLE" +codesign --verify --deep --strict "$BUNDLE" +echo " Codesign verified" + +# --- Step 6: DMG + ZIP --- +echo "" +echo "==> [6/6] Create distributable artifacts" +DMG="$OUT_DIR/$APP_NAME.dmg" +ZIP="$OUT_DIR/$APP_NAME.zip" + +# DMG +hdiutil create \ + -volname "Lattice Studio" \ + -srcfolder "$BUNDLE" \ + -ov \ + -format UDZO \ + "$DMG" 2>&1 | grep -v "^hdiutil:" +echo " DMG: $(du -sh "$DMG" | awk '{print $1}') $DMG" + +# ZIP fallback +(cd "$OUT_DIR" && zip -qr "$APP_NAME.zip" "$APP_NAME.app") +echo " ZIP: $(du -sh "$ZIP" | awk '{print $1}') $ZIP" + +echo "" +echo "==> Done. Artifacts in $OUT_DIR/" +du -sh "$BUNDLE" "$DMG" "$ZIP" +echo "" +echo "Distribution note: Recipients must right-click → Open on first launch (Gatekeeper)." +echo "Or: xattr -dr com.apple.quarantine '$BUNDLE'" From 3301a37b8c7b0a14cd76336e3824cffc572459aa Mon Sep 17 00:00:00 2001 From: OceanLi <122793010+ohdearquant@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:22:22 -0400 Subject: [PATCH 2/2] fix(macos): apply the completed v1 slice to tracked files The initial v1 commit (47dfe079) captured the app at an intermediate state: the five cut screen files were removed from the tree, but the Screen enum and the routing in seven dependent files still referenced .data/.train/.eval, so the committed tree did not compile on its own (ContentView routed to DataScreen/TrainScreen/EvalScreen, which are absent from the commit). This commits the fully-sliced worktree versions that swift build validates green: Screen enum reduced to {models, chat}, with command bar, nav symbols, run routing, and store scaffolding made consistent. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../LatticeStudio/Components/CommandBar.swift | 4 +- .../LatticeStudio/Screens/ChatScreen.swift | 207 +-------- .../LatticeStudio/Screens/ModelsScreen.swift | 397 ++---------------- .../LatticeStudio/Shell/ContentView.swift | 21 +- .../LatticeStudio/Shell/LeftRail.swift | 3 - .../LatticeStudio/Store/AppStore.swift | 2 - .../LatticeStudio/Store/DomainModels.swift | 13 +- 7 files changed, 59 insertions(+), 588 deletions(-) diff --git a/apps/macos/Sources/LatticeStudio/Components/CommandBar.swift b/apps/macos/Sources/LatticeStudio/Components/CommandBar.swift index 6265997819..42dc0fa5f8 100644 --- a/apps/macos/Sources/LatticeStudio/Components/CommandBar.swift +++ b/apps/macos/Sources/LatticeStudio/Components/CommandBar.swift @@ -361,9 +361,7 @@ extension CommandSpec { /// is installed. ContentView.handleCommand matches args against store.models at runtime. static let latticeDefaults: [CommandSpec] = [ CommandSpec(title: "models", args: [], description: "Browse downloaded models"), - CommandSpec(title: "data", args: [], description: "Manage training datasets"), - CommandSpec(title: "train", args: ["", ""], description: "Start a LoRA training run"), - CommandSpec(title: "chat", args: [""], description: "Open chat / A-B compare"), + CommandSpec(title: "chat", args: [""], description: "Open chat"), CommandSpec(title: "stop", args: [], description: "Stop the current run"), ] } diff --git a/apps/macos/Sources/LatticeStudio/Screens/ChatScreen.swift b/apps/macos/Sources/LatticeStudio/Screens/ChatScreen.swift index 973e7bcc6a..7fbcd957e3 100644 --- a/apps/macos/Sources/LatticeStudio/Screens/ChatScreen.swift +++ b/apps/macos/Sources/LatticeStudio/Screens/ChatScreen.swift @@ -1,20 +1,15 @@ import SwiftUI -// MARK: - 04 CHAT +// MARK: - 02 CHAT // -// Single-mode chat interface with run history. +// Single-mode chat interface. // -// Layout (two zones inside ScreenScaffold): -// • TAB PICKER — segmented control "Chat | History" (max 240pt, left-aligned). -// In Chat tab: "New conversation" on the right. -// • TRANSCRIPT — scrollable column of ChatTurn bubbles (Chat tab), max-width 920pt. -// OR run-history DataTable + live banner (History tab). -// • COMPOSER — rounded TextEditor with Send/Stop as a 30×30 in-composer button -// (bottom-trailing overlay). Chat tab only; hidden in History tab. +// Layout (inside ScreenScaffold): +// • TRANSCRIPT — scrollable column of ChatTurn bubbles, max-width 920pt. +// • COMPOSER — rounded TextEditor with Send/Stop as a 30×30 in-composer button. // // State ownership: -// All transcript + selection state lives in AppStore (hoisted from @State) so it -// survives NavigationSplitView teardown when Ocean navigates to another screen. +// All transcript state lives in AppStore so it survives NavigationSplitView teardown. // ChatScreen reads/writes via @Bindable var store. // // Generation model: @@ -25,18 +20,7 @@ import SwiftUI // Completion detection: // Each run carries an onComplete hook (set in send()/retryTurn()) that // AppStore.finish() fires when the subprocess exits — independent of ChatScreen being -// mounted, so a turn still resolves if Ocean navigates away mid-generation. It also -// fires with a failed status when another screen's launch() supersedes the run. -// store.chatAwaitingTurnID: UUID? tracks which turn receives the streamed text. -// -// N-way generation compare (A/B and beyond) lives in EvalScreen (Stage 3). - -// MARK: - ChatTab - -private enum ChatTab: String, CaseIterable { - case chat = "Chat" - case history = "History" -} +// mounted, so a turn still resolves if Ocean navigates away mid-generation. // MARK: - Typing indicator dots @@ -73,10 +57,6 @@ struct ChatScreen: View { // MARK: Local state (ephemeral; does NOT need to survive navigation) - // Tab selection: Chat playground vs History (run notebook) - @State private var chatTab: ChatTab = .chat - // History tab: selected run (owned here so selection survives tab switches within Chat) - @State private var selectedRunID: String? // Composer text is ephemeral — clearing on navigation is acceptable @State private var composerText: String = "" @@ -98,36 +78,6 @@ struct ChatScreen: View { return chatModels.first } - // All known adapters for the selected model (compatible + incompatible). - // The picker surfaces both so the user sees what exists and why some are unavailable. - private var allAdapters: [AdapterInfo] { - selectedModel?.adapters ?? [] - } - - private var adapterOptions: [String] { - // All adapters appear in the picker — usable ones selectable, unusable ones shown - // with a reason so the user is never left wondering why an adapter is "missing". - return ["none"] + allAdapters.map(\.name) - } - - private var selectedAdapter: AdapterInfo? { - guard store.chatSelectedAdapterName != "none" else { return nil } - // Only return an adapter that has a resolvable weight file — otherwise generation - // would silently run the base model under the adapter's label. - return selectedModel?.adapters.first { - $0.name == store.chatSelectedAdapterName && $0.weightFile != nil - } - } - - /// Compatibility check for display. Returns nil when the adapter is usable, or a short - /// human-readable reason string when it cannot be loaded by `generate_lora`. - private func incompatibilityReason(for adapter: AdapterInfo) -> String? { - if adapter.weightFile == nil { - return "no weight file (.safetensors not found)" - } - return nil - } - // isRunning: true while a single-mode run is in-flight. private var isRunning: Bool { store.liveRun(matching: [.chat])?.status == .running @@ -145,8 +95,7 @@ struct ChatScreen: View { // "ready" when the model directory exists on disk; never claim "loaded" since // the app shells out a fresh subprocess per generation and nothing stays in memory. let diskStatus = FileManager.default.fileExists(atPath: model.path.path) ? "ready" : "not found" - let adapterLabel = selectedAdapter?.name ?? "no adapter" - return "\(model.name) · \(diskStatus) · \(adapterLabel)" + return "\(model.name) · \(diskStatus)" } // MARK: Body @@ -154,58 +103,13 @@ struct ChatScreen: View { var body: some View { ScreenScaffold(screen: .chat, subtitle: subtitle) { VStack(spacing: 0) { - // Tab / mode picker row - HStack { - // Chat | History tab picker - Picker("", selection: $chatTab) { - ForEach(ChatTab.allCases, id: \.self) { tab in - Text(tab.rawValue).tag(tab) - } - } - .pickerStyle(.segmented) - .labelsHidden() - .frame(maxWidth: 240) - - Spacer() - - // "New conversation" — only visible in Chat tab - if chatTab == .chat { - // New conversation — clears transcript, keeps model/adapter/settings - Button { - newConversation() - } label: { - Label("New", systemImage: "square.and.pencil") - .font(Theme.Fonts.body) - } - .buttonStyle(LatticeSecondaryButtonStyle()) - .help("Start a new conversation (keeps model & settings)") - .disabled(store.chatTurns.isEmpty) - } - } - .padding(.horizontal, Theme.Space.lg) - .padding(.top, Theme.Space.md) - .padding(.bottom, Theme.Space.sm) - - // Tab content - switch chatTab { - case .chat: - transcriptArea - composerBar - case .history: - RunsContent(store: store, selectedRunID: $selectedRunID) - } + transcriptArea + composerBar } } .inspector(isPresented: $store.inspectorPresented) { - switch chatTab { - case .chat: - settingsInspector - .inspectorColumnWidth(min: 280, ideal: 320, max: 380) - case .history: - RunsContent(store: store, selectedRunID: $selectedRunID) - .inspectorPanel - .inspectorColumnWidth(min: 260, ideal: 300, max: 320) - } + settingsInspector + .inspectorColumnWidth(min: 280, ideal: 320, max: 380) } .onAppear { applyDefaults() } .onChange(of: store.models) { _, _ in applyDefaults() } @@ -214,10 +118,6 @@ struct ChatScreen: View { // the selection so the picker never shows a model that isn't in chatModels. applyDefaults() } - .onChange(of: store.chatSelectedModelName) { _, _ in - // Reset adapter when model changes. - store.chatSelectedAdapterName = "none" - } .onChange(of: store.liveRun?.genText) { _, newText in // Route streaming tokens to the awaiting turn. guard store.liveRun?.kind == .chat else { return } @@ -283,9 +183,6 @@ struct ChatScreen: View { Theme.Palette.hairline.frame(height: 1) } } - - // Adapter picker — all adapters shown; incompatible ones labelled - adapterPickerRow } .instrumentPanel() .padding(.horizontal, Theme.Space.lg) @@ -393,75 +290,6 @@ struct ChatScreen: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - /// Adapter picker row that surfaces ALL adapters — compatible ones selectable, - /// incompatible ones shown with a reason so the user understands why they cannot be used. - @ViewBuilder - private var adapterPickerRow: some View { - HStack { - Text("Adapter") - .instrumentLabel() - Spacer() - Menu { - // "none" option always first - Button { - store.chatSelectedAdapterName = "none" - } label: { - if store.chatSelectedAdapterName == "none" { - Label("none", systemImage: "checkmark") - } else { - Text("none") - } - } - - if !allAdapters.isEmpty { - Divider() - - ForEach(allAdapters) { adapter in - let reason = incompatibilityReason(for: adapter) - let isSelected = store.chatSelectedAdapterName == adapter.name - - if let reason { - // Incompatible: shown but disabled with a reason label. - // The user sees it exists and WHY it cannot be used. - Text("\(adapter.name) — \(reason)") - .foregroundStyle(Theme.Palette.textTertiary) - .font(Theme.Fonts.caption) - } else { - Button { - store.chatSelectedAdapterName = adapter.name - } label: { - if isSelected { - Label(adapter.name, systemImage: "checkmark") - } else { - Text(adapter.name) - } - } - } - } - } - } label: { - // Label shows current selection; incompatible selection shows "(incompatible)" hint - let selName = store.chatSelectedAdapterName - let isIncompatible: Bool = { - guard selName != "none", - let a = allAdapters.first(where: { $0.name == selName }) - else { return false } - return incompatibilityReason(for: a) != nil - }() - Text(isIncompatible ? "\(selName) (!)" : selName) - .font(Theme.Fonts.body) - .foregroundStyle(isIncompatible ? Theme.Palette.amber : Theme.Palette.ink) - } - .menuStyle(.borderlessButton) - .fixedSize() - } - .frame(height: Theme.Space.rowHeight) - .padding(.horizontal, Theme.Space.lg) - .overlay(alignment: .bottom) { - Theme.Palette.hairline.frame(height: 1) - } - } - // MARK: - Single-mode transcript @ViewBuilder @@ -811,14 +639,13 @@ struct ChatScreen: View { }() // Honest hardware label — snapshotted at send time, never changes after dispatch. - // "GPU Metal q4" / "GPU Metal bf16" / "CPU bf16" / "GPU Metal bf16+LoRA" etc. + // "GPU Metal q4" / "GPU Metal bf16" / "CPU bf16" let inferenceLabel: String = { - let adapterTag = selectedAdapter != nil ? "+LoRA" : "" if useGPU { let fmtTag = (selectedModel?.format == .q4) ? "q4" : "bf16" - return "GPU Metal \(fmtTag)\(adapterTag)" + return "GPU Metal \(fmtTag)" } else { - return "CPU bf16\(adapterTag)" + return "CPU bf16" } }() @@ -827,7 +654,7 @@ struct ChatScreen: View { modelDirPath: selectedModel?.path.path, model: selectedModel == nil ? (store.chatSelectedModelName.isEmpty ? nil : store.chatSelectedModelName) : nil, tokenizerDirPath: tokenizerDirURL?.path, - adapterFilePath: selectedAdapter?.weightFile?.path, + adapterFilePath: nil, prompt: chatMLPrompt, maxTokens: maxTokens, seed: seed, @@ -851,7 +678,7 @@ struct ChatScreen: View { modelDir: selectedModel?.path, model: selectedModel == nil ? (store.chatSelectedModelName.isEmpty ? nil : store.chatSelectedModelName) : nil, tokenizerDir: tokenizerDirURL, - adapterPath: selectedAdapter?.weightFile, + adapterPath: nil, prompt: chatMLPrompt, maxTokens: maxTokens, seed: seed, diff --git a/apps/macos/Sources/LatticeStudio/Screens/ModelsScreen.swift b/apps/macos/Sources/LatticeStudio/Screens/ModelsScreen.swift index 65abea75e3..efce30378b 100644 --- a/apps/macos/Sources/LatticeStudio/Screens/ModelsScreen.swift +++ b/apps/macos/Sources/LatticeStudio/Screens/ModelsScreen.swift @@ -3,26 +3,12 @@ import AppKit // MARK: - 01 MODELS // -// Two-tab layout via [Models | Adapters] segmented Picker at the top: -// -// MODELS tab (unchanged): -// • Trailing action row: Refresh + Reveal-in-Finder + Train/Quantize/Chat CTAs -// • Main body: DataTable of store.models -// • Right inspector: ReadoutWells for the selected model + adapter sub-list -// -// ADAPTERS tab (new): -// • Main body: DataTable of store.adapters (scanned from /adapters) -// • Right inspector: ReadoutWells for the selected adapter + Delete button +// Model management screen: +// • Trailing action row: Get Models + Refresh + Reveal-in-Finder + Chat CTA +// • Main body: DataTable of store.models +// • Right inspector: ReadoutWells for the selected model // // Empty state: if binariesReady is false hint "run `make build`" and show modelCachePath. -// One teal primary CTA per screen: "Train →" (filled); others are outline. - -// MARK: - ModelsTab - -private enum ModelsTab: String, CaseIterable { - case models = "Models" - case adapters = "Adapters" -} private let byteFormatter: ByteCountFormatter = { let f = ByteCountFormatter() @@ -34,13 +20,8 @@ private let byteFormatter: ByteCountFormatter = { struct ModelsScreen: View { @Bindable var store: AppStore - // Tab selection — Models or Adapters - @State private var modelsTab: ModelsTab = .models // Local selection state — deselects when models list changes. @State private var selectedModelID: String? - @State private var selectedAdapterID: String? - // Quantize sheet: presented from the model inspector/action row (Phase A re-parenting). - @State private var showQuantizeSheet: Bool = false @State private var showGetModelsSheet: Bool = false @State private var didInitInspector = false @@ -48,21 +29,10 @@ struct ModelsScreen: View { store.models.first { $0.id == selectedModelID } } - private var selectedAdapter: AdapterInfo? { - store.adapters.first { $0.id == selectedAdapterID } - } - private var subtitle: String { - switch modelsTab { - case .models: - let count = store.models.count - if count == 0 { return "no models found" } - return "\(count) model\(count == 1 ? "" : "s") · \(store.modelCachePath)" - case .adapters: - let count = store.adapters.count - if count == 0 { return "no adapters found" } - return "\(count) adapter\(count == 1 ? "" : "s")" - } + let count = store.models.count + if count == 0 { return "no models found" } + return "\(count) model\(count == 1 ? "" : "s") · \(store.modelCachePath)" } var body: some View { @@ -71,50 +41,15 @@ struct ModelsScreen: View { subtitle: subtitle, trailing: { actionRow } ) { - VStack(spacing: 0) { - // Segmented tab picker — mirrors ChatScreen pattern - HStack { - Picker("", selection: $modelsTab) { - ForEach(ModelsTab.allCases, id: \.self) { tab in - Text(tab.rawValue).tag(tab) - } - } - .pickerStyle(.segmented) - .labelsHidden() - .frame(maxWidth: 240) - Spacer() - } - .padding(.horizontal, Theme.Space.lg) - .padding(.top, Theme.Space.md) - .padding(.bottom, Theme.Space.sm) - - // Tab content - switch modelsTab { - case .models: - if store.models.isEmpty { - emptyState - } else { - modelTable - } - case .adapters: - adapterTable - } + if store.models.isEmpty { + emptyState + } else { + modelTable } } .inspector(isPresented: $store.inspectorPresented) { - switch modelsTab { - case .models: - inspectorPanel - .inspectorColumnWidth(min: 260, ideal: 300, max: 320) - case .adapters: - adapterInspectorPanel - .inspectorColumnWidth(min: 260, ideal: 300, max: 320) - } - } - .sheet(isPresented: $showQuantizeSheet) { - QuantizeScreen(store: store) - .frame(minWidth: 760, idealWidth: 900, maxWidth: .infinity, - minHeight: 540, idealHeight: 640, maxHeight: .infinity) + inspectorPanel + .inspectorColumnWidth(min: 260, ideal: 300, max: 320) } .sheet(isPresented: $showGetModelsSheet) { GetModelsSheet(store: store) @@ -156,74 +91,28 @@ struct ModelsScreen: View { .keyboardShortcut("r", modifiers: .command) .help("Refresh model list (⌘R)") - // Tab-specific actions - switch modelsTab { - case .models: - // Reveal in Finder — only when a model is selected - if let model = selectedModel { - Button { - NSWorkspace.shared.activateFileViewerSelecting([model.path]) - } label: { - Text("Reveal") - .font(Theme.Fonts.body) - } - .buttonStyle(LatticeSecondaryButtonStyle()) + // Reveal in Finder — only when a model is selected + if let model = selectedModel { + Button { + NSWorkspace.shared.activateFileViewerSelecting([model.path]) + } label: { + Text("Reveal") + .font(Theme.Fonts.body) } + .buttonStyle(LatticeSecondaryButtonStyle()) Divider() .frame(height: 16) - // Navigation CTAs — Train is the single teal primary - if let model = selectedModel { - Button { - store.use(model, on: .train) - } label: { - Text("Train") - .font(Theme.Fonts.body) - } - .buttonStyle(LatticePrimaryButtonStyle()) - .help("Send selected model to Train (⌘3)") - - Button { - store.workingModel = model - showQuantizeSheet = true - } label: { - Text("Quantize…") - .font(Theme.Fonts.body) - } - .buttonStyle(LatticeSecondaryButtonStyle()) - .help("Quantize selected model (Q4 / QuaRot)") - - Button { - store.use(model, on: .chat) - } label: { - Text("Chat") - .font(Theme.Fonts.body) - } - .buttonStyle(LatticeSecondaryButtonStyle()) - .help("Send selected model to Chat (⌘4)") - - Button { - store.use(model, on: .eval) - } label: { - Text("Eval →") - .font(Theme.Fonts.body) - } - .buttonStyle(LatticeSecondaryButtonStyle()) - .help("Send selected model to Eval workspace (⌘5)") - } - - case .adapters: - // Reveal in Finder — only when an adapter is selected - if let adapter = selectedAdapter { - Button { - NSWorkspace.shared.activateFileViewerSelecting([adapter.path]) - } label: { - Text("Reveal") - .font(Theme.Fonts.body) - } - .buttonStyle(LatticeSecondaryButtonStyle()) + // Chat CTA — the single navigation action + Button { + store.use(model, on: .chat) + } label: { + Text("Chat") + .font(Theme.Fonts.body) } + .buttonStyle(LatticeSecondaryButtonStyle()) + .help("Send selected model to Chat (⌘2)") } } } @@ -299,162 +188,9 @@ struct ModelsScreen: View { minWidth: 40, isNumeric: false ) { $0.hasTokenizer ? "✓" : "—" }, - - ColumnDef( - id: "adapters", - header: "#ADAPTERS", - alignment: .trailing, - minWidth: 72, - isNumeric: true - ) { "\($0.adapters.count)" }, - ] - } - - // MARK: Adapter table - - private var adapterTable: some View { - ScrollView { - DataTable( - rows: store.adapters, - columns: adapterColumns, - selectedID: $selectedAdapterID, - comfortable: store.rowComfortable - ) - } - .instrumentPanel() - } - - private var adapterColumns: [ColumnDef] { - [ - ColumnDef( - id: "name", - header: "NAME", - alignment: .leading, - minWidth: 180, - isNumeric: false - ) { $0.name }, - - ColumnDef( - id: "baseModel", - header: "BASE MODEL", - alignment: .leading, - minWidth: 160, - isNumeric: false - ) { $0.baseModel ?? "—" }, - - ColumnDef( - id: "rank", - header: "RANK", - alignment: .trailing, - minWidth: 56, - isNumeric: true - ) { $0.rank.map(String.init) ?? "—" }, - - ColumnDef( - id: "scale", - header: "SCALE", - alignment: .trailing, - minWidth: 64, - isNumeric: true - ) { - if let s = $0.scale { return String(format: "%.0f", s) } - if let a = $0.alpha { return String(format: "%.0f", a) } - return "—" - }, - - ColumnDef( - id: "size", - header: "SIZE", - alignment: .trailing, - minWidth: 80, - isNumeric: true - ) { byteFormatter.string(fromByteCount: $0.sizeBytes) }, ] } - // MARK: Adapter inspector panel - - @ViewBuilder - private var adapterInspectorPanel: some View { - if let adapter = selectedAdapter { - ScrollView { - VStack(alignment: .leading, spacing: Theme.Space.xl) { - adapterInspector(adapter) - } - .padding(Theme.Space.lg) - } - .instrumentPanel() - } else { - VStack { - Spacer() - Text("SELECT AN ADAPTER") - .instrumentLabel() - Spacer() - } - .frame(maxWidth: .infinity) - .instrumentPanel() - } - } - - private func adapterInspector(_ adapter: AdapterInfo) -> some View { - VStack(alignment: .leading, spacing: Theme.Space.md) { - // Adapter name header - Text(adapter.name) - .font(Theme.Fonts.title) - .foregroundStyle(Theme.Palette.ink) - .lineLimit(1) - - // Readout wells — 2-column grid - let wells = adapterWells(for: adapter) - LazyVGrid( - columns: [GridItem(.flexible(), spacing: Theme.Space.md), GridItem(.flexible(), spacing: Theme.Space.md)], - spacing: Theme.Space.md - ) { - ForEach(wells, id: \.label) { well in - ReadoutWell(label: well.label, value: well.value, unit: well.unit, minHeight: 56) - } - } - - // Delete button - Button { - store.deleteAdapter(adapter) - selectedAdapterID = nil - } label: { - Text("Delete") - .font(Theme.Fonts.body) - } - .buttonStyle(LatticeSecondaryButtonStyle()) - .padding(.top, Theme.Space.sm) - } - } - - private func adapterWells(for adapter: AdapterInfo) -> [WellSpec] { - var ws: [WellSpec] = [] - if let rank = adapter.rank { - ws.append(WellSpec("RANK", "\(rank)")) - } - // Show scale (MLX) or alpha (PEFT), whichever is present - if let scale = adapter.scale { - ws.append(WellSpec("SCALE", String(format: "%.0f", scale))) - } else if let alpha = adapter.alpha { - ws.append(WellSpec("ALPHA", String(format: "%.0f", alpha))) - } - if let baseModel = adapter.baseModel { - ws.append(WellSpec("BASE MODEL", baseModel)) - } - if let numLayers = adapter.numLayers { - ws.append(WellSpec("LAYERS", "\(numLayers)")) - } - if adapter.checkpointCount > 0 { - ws.append(WellSpec("CHECKPOINTS", "\(adapter.checkpointCount)")) - } - ws.append(WellSpec("SIZE", byteFormatter.string(fromByteCount: adapter.sizeBytes))) - if let modules = adapter.targetModules { - ws.append(WellSpec("MODULES", modules)) - } - return ws - } - // MARK: Inspector panel @ViewBuilder @@ -463,9 +199,6 @@ struct ModelsScreen: View { ScrollView { VStack(alignment: .leading, spacing: Theme.Space.xl) { modelInspector(model) - if !model.adapters.isEmpty { - adapterList(model.adapters) - } } .padding(Theme.Space.lg) } @@ -556,78 +289,6 @@ struct ModelsScreen: View { return ws } - private func adapterList(_ adapters: [AdapterInfo]) -> some View { - VStack(alignment: .leading, spacing: Theme.Space.sm) { - // Section label - Text("ADAPTERS") - .instrumentLabel() - .padding(.bottom, 2) - - // Hairline divider - Theme.Palette.hairline.frame(height: 1) - - ForEach(adapters) { adapter in - adapterRow(adapter) - Theme.Palette.hairline.frame(height: 1) - } - } - } - - private func adapterRow(_ adapter: AdapterInfo) -> some View { - HStack(spacing: 0) { - VStack(alignment: .leading, spacing: 2) { - Text(adapter.name) - .font(Theme.Fonts.body) - .foregroundStyle(Theme.Palette.ink) - .lineLimit(1) - - HStack(spacing: Theme.Space.sm) { - if let rank = adapter.rank { - inlineStat("rank", "r\(rank)") - } - if let alpha = adapter.alpha { - inlineStat("α", String(format: "%.0f", alpha)) - } - if let modules = adapter.targetModules { - inlineStat("modules", modules) - } - } - } - - Spacer() - - VStack(alignment: .trailing, spacing: 2) { - Text(byteFormatter.string(fromByteCount: adapter.sizeBytes)) - .font(Theme.Fonts.cell) - .foregroundStyle(Theme.Palette.inkDim) - .monospacedDigit() - - Button { - NSWorkspace.shared.activateFileViewerSelecting([adapter.path]) - } label: { - Text("Reveal") - .font(Theme.Fonts.cell) - .foregroundStyle(Theme.Palette.signal) - } - .buttonStyle(.plain) - } - } - .padding(.vertical, Theme.Space.xs) - } - - private func inlineStat(_ label: String, _ value: String) -> some View { - HStack(spacing: 2) { - Text(label) - .font(Theme.Fonts.cell) - .foregroundStyle(Theme.Palette.inkDim) - .textCase(.uppercase) - Text(value) - .font(Theme.Fonts.cell) - .foregroundStyle(Theme.Palette.ink) - .monospacedDigit() - } - } - // MARK: Empty state private var emptyState: some View { diff --git a/apps/macos/Sources/LatticeStudio/Shell/ContentView.swift b/apps/macos/Sources/LatticeStudio/Shell/ContentView.swift index eb5dd134d6..d48b4eee21 100644 --- a/apps/macos/Sources/LatticeStudio/Shell/ContentView.swift +++ b/apps/macos/Sources/LatticeStudio/Shell/ContentView.swift @@ -65,7 +65,7 @@ struct ContentView: View { } // Route a ⌘K command to a screen / action. A leading model-name argument - // (e.g. `train qwen3.5 r8`) preselects the working model for that screen. + // preselects the working model for that screen. private func handleCommand(_ cmd: String, _ args: [String]) { func retarget() { guard let arg = args.first(where: { !$0.isEmpty })?.lowercased() else { return } @@ -74,11 +74,8 @@ struct ContentView: View { } } switch cmd { - case "train": retarget(); store.selection = .train - case "chat", - "runs": retarget(); store.selection = .chat + case "chat": retarget(); store.selection = .chat case "models": store.selection = .models - case "data": store.selection = .data case "stop": store.stopRun() default: break } @@ -109,13 +106,12 @@ struct ContentView: View { }() Button { - // Jump to the screen that owns this run kind — not always Chat. + // Jump to the screen that owns this run kind. switch run.kind { - case .chat: store.selection = .chat - case .train: store.selection = .train - case .quantizeQ4, .quantizeQuaRot: store.selection = .models - case .eval: store.selection = .eval - case .embed: store.selection = .eval + case .chat: + store.selection = .chat + default: + store.selection = .models } } label: { HStack(spacing: 6) { @@ -149,10 +145,7 @@ struct ContentView: View { @ViewBuilder private var detail: some View { switch store.selection { case .models: ModelsScreen(store: store) - case .data: DataScreen(store: store) - case .train: TrainScreen(store: store) case .chat: ChatScreen(store: store) - case .eval: EvalScreen(store: store) } } diff --git a/apps/macos/Sources/LatticeStudio/Shell/LeftRail.swift b/apps/macos/Sources/LatticeStudio/Shell/LeftRail.swift index 83740a9eb9..82cc5265a7 100644 --- a/apps/macos/Sources/LatticeStudio/Shell/LeftRail.swift +++ b/apps/macos/Sources/LatticeStudio/Shell/LeftRail.swift @@ -226,10 +226,7 @@ extension Screen { var symbol: String { switch self { case .models: "shippingbox" - case .data: "tablecells" - case .train: "chart.line.uptrend.xyaxis" case .chat: "text.bubble" - case .eval: "waveform.path.ecg" } } } diff --git a/apps/macos/Sources/LatticeStudio/Store/AppStore.swift b/apps/macos/Sources/LatticeStudio/Store/AppStore.swift index d63f5aeab2..84ab4615f9 100644 --- a/apps/macos/Sources/LatticeStudio/Store/AppStore.swift +++ b/apps/macos/Sources/LatticeStudio/Store/AppStore.swift @@ -216,8 +216,6 @@ final class AppStore { // The model currently targeted across TRAIN / QUANTIZE / CHAT. Set from MODELS via `use(_:on:)`. var workingModel: ModelInfo? - // The dataset currently selected in DATA. Consumed by TRAIN in a later task. - var workingDataset: DatasetFileStat? // Sensible default target: first non-embedding model, else first of any. var defaultModel: ModelInfo? { models.first { !$0.isEmbedding } ?? models.first } // The effective target a screen should drive: explicit working model, else the default. diff --git a/apps/macos/Sources/LatticeStudio/Store/DomainModels.swift b/apps/macos/Sources/LatticeStudio/Store/DomainModels.swift index 1760be5d5b..29b62280c0 100644 --- a/apps/macos/Sources/LatticeStudio/Store/DomainModels.swift +++ b/apps/macos/Sources/LatticeStudio/Store/DomainModels.swift @@ -3,29 +3,26 @@ import SwiftUI // MARK: - Navigation enum Screen: String, CaseIterable, Identifiable, Hashable { - case models, data, train, chat, eval + case models, chat var id: String { rawValue } var index: String { switch self { - case .models: "01"; case .data: "02"; case .train: "03"; case .chat: "04"; case .eval: "05" + case .models: "01"; case .chat: "02" } } var title: String { switch self { - case .models: "MODELS"; case .data: "DATA"; case .train: "TRAIN"; case .chat: "CHAT"; case .eval: "EVAL" + case .models: "MODELS"; case .chat: "CHAT" } } var shortcut: KeyEquivalent { switch self { - case .models: "1"; case .data: "2"; case .train: "3"; case .chat: "4"; case .eval: "5" + case .models: "1"; case .chat: "2" } } - /// Whether this screen has a right inspector (toggle sidebar). Drives the - /// shared window-toolbar toggle visibility. The eval screen uses a left config - /// rail (like embed did) and does not use the system inspector panel. - var hasInspector: Bool { self != .eval } + var hasInspector: Bool { return true } } // MARK: - Models on disk