SPB Git

spb/forge-studio Public

The Instruments of LLM training — a native macOS cockpit for Forge. Train language models from scratch on Apple Silicon without a terminal.

Swift 95.7% Shell 4.3%
10.7 KB · 288 lines swift
Raw Blame History
1// Author: Simon-Pierre Boucher — contact@spboucher.ai2//3// The main loss chart: raw train (low opacity) + bias-corrected EMA + val4// points, log/linear Y, best-val rule, hover crosshair with a full callout5// (step, train, EMA, val, ppl, lr, tok/s), pinch-zoom + horizontal pan with6// a "follow live" pill when zoomed during a running train, double-click to7// reset. Data arrives pre-downsampled from MetricsStore.8import Charts9import SwiftUI1011// Shared X-window + crosshair state: the main chart drives it, secondary12// charts observe it, so zooming/hovering is synchronized everywhere.13@Observable14final class ChartXState {15    var visibleLength: Double?  // nil = fit all16    var scrollX: Double = 017    var hoverX: Double?18}1920struct TrainingChartView: View {21    let snapshot: MetricsStore.Snapshot?22    let isLive: Bool23    @Binding var smoothing: Double24    @Binding var logScale: Bool25    var xState: ChartXState26    var checkpointSteps: [Int] = []27    var onCheckpointTap: ((Int) -> Void)?2829    @State private var followLive = true30    @State private var magnifyBase: Double?3132    private var visibleLength: Double? {33        get { xState.visibleLength }34        nonmutating set { xState.visibleLength = newValue }35    }36    private var scrollX: Double {37        get { xState.scrollX }38        nonmutating set { xState.scrollX = newValue }39    }40    private var hoverX: Double? {41        get { xState.hoverX }42        nonmutating set { xState.hoverX = newValue }43    }4445    private var maxX: Double { snapshot?.train.last?.x ?? 1 }46    private var minX: Double { snapshot?.train.first?.x ?? 0 }4748    var body: some View {49        VStack(alignment: .leading, spacing: 8) {50            controls51            chart52                .frame(minHeight: 320)53        }54    }5556    private var controls: some View {57        HStack {58            Text("Loss").font(.title3.weight(.semibold))59            if let h = hoverInfo {60                calloutText(h)61            }62            Spacer()63            if isLive && visibleLength != nil && !followLive {64                Button {65                    followLive = true66                } label: {67                    Label("suivre ⏵", systemImage: "forward.fill")68                        .font(.caption)69                }70                .buttonStyle(.borderedProminent)71                .controlSize(.small)72            }73            Toggle("log Y", isOn: $logScale).toggleStyle(.checkbox)74            Button("fit") {75                visibleLength = nil76                followLive = true77            }78            .controlSize(.small)79            HStack(spacing: 4) {80                Text("EMA")81                Slider(value: $smoothing, in: 0...0.99).frame(width: 110)82                Text(String(format: "%.2f", smoothing)).monospacedDigit()83            }84            .font(.caption)85        }86    }8788    private var chart: some View {89        Chart {90            ForEach(snapshot?.train ?? [], id: \.x) { p in91                LineMark(x: .value("step", p.x), y: .value("loss", p.y),92                         series: .value("s", "train"))93                    .foregroundStyle(.blue.opacity(0.22))94            }95            ForEach(snapshot?.trainEMA ?? [], id: \.x) { p in96                LineMark(x: .value("step", p.x), y: .value("ema", p.y),97                         series: .value("s", "train EMA"))98                    .foregroundStyle(.blue)99            }100            ForEach(snapshot?.val ?? [], id: \.x) { p in101                LineMark(x: .value("step", p.x), y: .value("val", p.y),102                         series: .value("s", "val"))103                    .foregroundStyle(.orange)104                PointMark(x: .value("step", p.x), y: .value("val", p.y))105                    .foregroundStyle(.orange)106                    .symbolSize(24)107            }108            if let best = snapshot?.bestVal {109                RuleMark(y: .value("best", best.loss))110                    .foregroundStyle(.orange.opacity(0.4))111                    .lineStyle(.init(lineWidth: 1, dash: [4, 4]))112                    .annotation(position: .topTrailing) {113                        Text(String(format: "best val %.3f @ %d", best.loss, best.step))114                            .font(.caption2)115                            .foregroundStyle(.orange)116                    }117            }118            ForEach(checkpointSteps, id: \.self) { step in119                RuleMark(x: .value("ckpt", Double(step)))120                    .foregroundStyle(.green.opacity(0.25))121                    .lineStyle(.init(lineWidth: 1, dash: [2, 4]))122                    .annotation(position: .top, alignment: .leading) {123                        Image(systemName: "externaldrive.fill")124                            .font(.system(size: 7))125                            .foregroundStyle(.green.opacity(0.6))126                    }127            }128            if let x = hoverX {129                RuleMark(x: .value("hover", x))130                    .foregroundStyle(.secondary.opacity(0.5))131                    .lineStyle(.init(lineWidth: 1))132            }133        }134        .modifier(LogScaleModifier(enabled: logScale))135        .modifier(ScrollModifier(136            visibleLength: visibleLength,137            scrollX: Binding(get: { xState.scrollX },138                             set: { xState.scrollX = $0 })))139        .chartLegend(.visible)140        .chartOverlay { proxy in141            GeometryReader { geo in142                Color.clear143                    .contentShape(Rectangle())144                    .onContinuousHover { phase in145                        switch phase {146                        case .active(let pt):147                            let plot = geo[proxy.plotFrame!]148                            hoverX = proxy.value(atX: pt.x - plot.origin.x, as: Double.self)149                        case .ended:150                            hoverX = nil151                        }152                    }153                    .gesture(magnify)154                    .onTapGesture(count: 2) {155                        visibleLength = nil156                        followLive = true157                    }158                    .onTapGesture(count: 1) {159                        // Click near a checkpoint rule selects it (M4:160                        // "clickable → jumps to that checkpoint").161                        guard let x = hoverX,162                              let nearest = checkpointSteps163                                  .min(by: { abs(Double($0) - x) < abs(Double($1) - x) }),164                              abs(Double(nearest) - x) < max(4, (maxX - minX) * 0.01)165                        else { return }166                        onCheckpointTap?(nearest)167                    }168            }169        }170        .onChange(of: xState.scrollX) { old, new in171            // A user-initiated pan while live breaks auto-follow.172            if isLive, followLive, let len = visibleLength,173               abs(new - (maxX - len)) > len * 0.05 {174                followLive = false175            }176        }177        .onChange(of: snapshot?.count ?? 0) {178            if isLive, followLive, let len = visibleLength {179                scrollX = max(minX, maxX - len)180            }181        }182    }183184    private var magnify: some Gesture {185        MagnifyGesture()186            .onChanged { g in187                let base = magnifyBase ?? (visibleLength ?? (maxX - minX))188                magnifyBase = base189                let span = maxX - minX190                let newLen = min(max(base / g.magnification, span * 0.01), span)191                visibleLength = newLen < span * 0.999 ? newLen : nil192                if followLive, let len = visibleLength {193                    scrollX = max(minX, maxX - len)194                }195            }196            .onEnded { _ in magnifyBase = nil }197    }198199    // Nearest-point lookup for the callout (train series is x-sorted).200    private struct HoverInfo {201        var step: Int202        var train: Double?203        var ema: Double?204        var val: Double?205    }206207    private var hoverInfo: HoverInfo? {208        guard let x = hoverX, let snap = snapshot, !snap.train.isEmpty else { return nil }209        func nearest(_ s: [Downsampler.XY]) -> Downsampler.XY? {210            guard !s.isEmpty else { return nil }211            var lo = 0, hi = s.count - 1212            while lo < hi {213                let mid = (lo + hi) / 2214                if s[mid].x < x { lo = mid + 1 } else { hi = mid }215            }216            if lo > 0, abs(s[lo - 1].x - x) < abs(s[lo].x - x) { lo -= 1 }217            return s[lo]218        }219        let t = nearest(snap.train)220        return HoverInfo(step: Int(t?.x ?? x),221                         train: t?.y,222                         ema: nearest(snap.trainEMA)?.y,223                         val: nearest(snap.val).flatMap {224                             abs($0.x - x) < max(8, (maxX - minX) * 0.02) ? $0.y : nil225                         })226    }227228    private func calloutText(_ h: HoverInfo) -> some View {229        HStack(spacing: 10) {230            Text("step \(h.step)").foregroundStyle(.secondary)231            if let t = h.train {232                Text(String(format: "train %.4f", t)).foregroundStyle(.blue)233                Text(String(format: "ppl %.1f", exp(t))).foregroundStyle(.secondary)234            }235            if let e = h.ema {236                Text(String(format: "EMA %.4f", e)).foregroundStyle(.blue)237            }238            if let v = h.val {239                Text(String(format: "val %.4f", v)).foregroundStyle(.orange)240            }241        }242        .font(.caption.monospacedDigit())243        .padding(.horizontal, 8)244        .padding(.vertical, 3)245        .background(.quaternary.opacity(0.5), in: Capsule())246    }247}248249// Conditional modifiers kept out of the Chart body so the result builder250// stays type-checkable.251private struct LogScaleModifier: ViewModifier {252    let enabled: Bool253    func body(content: Content) -> some View {254        if enabled { content.chartYScale(type: .log) } else { content }255    }256}257258// Read-only link to the main chart's X window (secondary charts).259struct LinkedScrollModifier: ViewModifier {260    var xState: ChartXState261    func body(content: Content) -> some View {262        if let len = xState.visibleLength {263            content264                .chartScrollableAxes(.horizontal)265                .chartXVisibleDomain(length: len)266                .chartScrollPosition(x: Binding(get: { xState.scrollX },267                                                set: { _ in })) // main chart drives268        } else {269            content270        }271    }272}273274private struct ScrollModifier: ViewModifier {275    let visibleLength: Double?276    @Binding var scrollX: Double277    func body(content: Content) -> some View {278        if let len = visibleLength {279            content280                .chartScrollableAxes(.horizontal)281                .chartXVisibleDomain(length: len)282                .chartScrollPosition(x: $scrollX)283        } else {284            content285        }286    }287}288