// Author: Simon-Pierre Boucher — contact@spboucher.ai // // Per-run time series. Append-only (raw data is never thrown away); the UI // asks for downsampled snapshots sized to the chart's pixel width. An actor // so live ingest and UI reads never race. import Foundation actor MetricsStore { private(set) var points: [MetricPoint] = [] private var parser = LogParser() private var csvOffset: UInt64 = 0 struct Snapshot: Sendable { var train: [Downsampler.XY] var trainEMA: [Downsampler.XY] var val: [Downsampler.XY] var lr: [Downsampler.XY] var tokensPerSec: [Downsampler.XY] var gradNorm: [Downsampler.XY] var lastPoint: MetricPoint? var bestVal: (step: Int, loss: Double)? var count: Int var lastGrowth: Date? // watchdog: when new rows last arrived } private var lastGrowth: Date? func append(_ p: MetricPoint) { points.append(p) } func reset() { points.removeAll() parser = LogParser() csvOffset = 0 } /// Incremental tail of log.csv: reads only bytes past the last offset, /// so polling during a live run costs O(new lines). func ingestCSV(at url: URL) { guard let fh = try? FileHandle(forReadingFrom: url) else { return } defer { try? fh.close() } try? fh.seek(toOffset: csvOffset) guard let data = try? fh.readToEnd(), !data.isEmpty else { return } // Only consume complete lines; leave a partial tail for next poll. var consumable = data if let lastNL = data.lastIndex(of: 0x0A) { consumable = data[data.startIndex...lastNL] } else { return } csvOffset += UInt64(consumable.count) guard let text = String(data: consumable, encoding: .utf8) else { return } var grew = false for line in text.split(separator: "\n") { if let p = parser.parseCSVLine(String(line)) { points.append(p) grew = true } } if grew { lastGrowth = .now } } func snapshot(maxPoints: Int, smoothing: Double) -> Snapshot { func series(_ f: (MetricPoint) -> Double?) -> [Downsampler.XY] { points.compactMap { p in f(p).map { .init(x: Double(p.step), y: $0) } } } let train = series { $0.trainLoss } let emaValues = Smoothing.ema(train.map(\.y), smoothing: smoothing) let ema = zip(train, emaValues).map { Downsampler.XY(x: $0.x, y: $1) } let val = series { $0.valLoss } var best: (Int, Double)? for p in points { if let v = p.valLoss, v.isFinite, best == nil || v < best!.1 { best = (p.step, v) } } func ds(_ s: [Downsampler.XY]) -> [Downsampler.XY] { Downsampler.lttb(s, threshold: maxPoints) } return Snapshot( train: ds(train), trainEMA: ds(ema), val: val, // val is sparse: keep raw lr: ds(series { $0.lr }), tokensPerSec: ds(series { $0.tokensPerSec }), gradNorm: ds(series { $0.gradNorm }), lastPoint: points.last, bestVal: best, count: points.count, lastGrowth: lastGrowth) } }