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 0000000000..a560c2d9a6 Binary files /dev/null and b/apps/macos/Resources/LatticeStudio.icns differ 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..42dc0fa5f8 --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Components/CommandBar.swift @@ -0,0 +1,394 @@ +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: "chat", args: [""], description: "Open chat"), + 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..7fbcd957e3 --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Screens/ChatScreen.swift @@ -0,0 +1,802 @@ +import SwiftUI + +// MARK: - 02 CHAT +// +// Single-mode chat interface. +// +// 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 state lives in AppStore so it survives NavigationSplitView teardown. +// 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. + +// 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) + + // 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 + } + + // 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" + return "\(model.name) · \(diskStatus)" + } + + // MARK: Body + + var body: some View { + ScreenScaffold(screen: .chat, subtitle: subtitle) { + VStack(spacing: 0) { + transcriptArea + composerBar + } + } + .inspector(isPresented: $store.inspectorPresented) { + settingsInspector + .inspectorColumnWidth(min: 280, ideal: 320, max: 380) + } + .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.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) + } + } + } + .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) + } + + // 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" + let inferenceLabel: String = { + if useGPU { + let fmtTag = (selectedModel?.format == .q4) ? "q4" : "bf16" + return "GPU Metal \(fmtTag)" + } else { + return "CPU bf16" + } + }() + + // 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: nil, + 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: nil, + 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..efce30378b --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Screens/ModelsScreen.swift @@ -0,0 +1,344 @@ +import SwiftUI +import AppKit + +// MARK: - 01 MODELS +// +// 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. + +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 + + // Local selection state — deselects when models list changes. + @State private var selectedModelID: String? + @State private var showGetModelsSheet: Bool = false + @State private var didInitInspector = false + + private var selectedModel: ModelInfo? { + store.models.first { $0.id == selectedModelID } + } + + private var subtitle: String { + let count = store.models.count + if count == 0 { return "no models found" } + return "\(count) model\(count == 1 ? "" : "s") · \(store.modelCachePath)" + } + + var body: some View { + ScreenScaffold( + screen: .models, + subtitle: subtitle, + trailing: { actionRow } + ) { + if store.models.isEmpty { + emptyState + } else { + modelTable + } + } + .inspector(isPresented: $store.inspectorPresented) { + inspectorPanel + .inspectorColumnWidth(min: 260, ideal: 300, max: 320) + } + .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)") + + // 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) + + // 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)") + } + } + } + + // 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 ? "✓" : "—" }, + ] + } + + // MARK: Inspector panel + + @ViewBuilder + private var inspectorPanel: some View { + if let model = selectedModel { + ScrollView { + VStack(alignment: .leading, spacing: Theme.Space.xl) { + modelInspector(model) + } + .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 + } + + // 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..d48b4eee21 --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Shell/ContentView.swift @@ -0,0 +1,166 @@ +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 + // 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 "chat": retarget(); store.selection = .chat + case "models": store.selection = .models + 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. + switch run.kind { + case .chat: + store.selection = .chat + default: + store.selection = .models + } + } 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 .chat: ChatScreen(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..82cc5265a7 --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Shell/LeftRail.swift @@ -0,0 +1,232 @@ +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 .chat: "text.bubble" + } + } +} diff --git a/apps/macos/Sources/LatticeStudio/Store/AppStore.swift b/apps/macos/Sources/LatticeStudio/Store/AppStore.swift new file mode 100644 index 0000000000..84ab4615f9 --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Store/AppStore.swift @@ -0,0 +1,395 @@ +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? + // 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..29b62280c0 --- /dev/null +++ b/apps/macos/Sources/LatticeStudio/Store/DomainModels.swift @@ -0,0 +1,371 @@ +import SwiftUI + +// MARK: - Navigation + +enum Screen: String, CaseIterable, Identifiable, Hashable { + case models, chat + var id: String { rawValue } + + var index: String { + switch self { + case .models: "01"; case .chat: "02" + } + } + var title: String { + switch self { + case .models: "MODELS"; case .chat: "CHAT" + } + } + var shortcut: KeyEquivalent { + switch self { + case .models: "1"; case .chat: "2" + } + } + + var hasInspector: Bool { return true } +} + +// 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'"