From 47ae16b5b9dd65ab72e174e6c9ae21274cfc4671 Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:29:11 +0200 Subject: [PATCH 01/16] Permission monitor: the two-second poll is a named constant Co-Authored-By: Claude Opus 5.5 --- Sources/Pladder/PermissionMonitor.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Sources/Pladder/PermissionMonitor.swift b/Sources/Pladder/PermissionMonitor.swift index 4368d2f..fa5be86 100644 --- a/Sources/Pladder/PermissionMonitor.swift +++ b/Sources/Pladder/PermissionMonitor.swift @@ -16,6 +16,8 @@ final class PermissionMonitor { @ObservationIgnored private var pollTask: Task? + private static let pollInterval: Duration = .seconds(2) + var needsAccessibility: Bool { !accessibilityTrusted } var needsMicrophone: Bool { microphoneStatus != .authorized } var needsAnyPermission: Bool { needsAccessibility || needsMicrophone } @@ -64,7 +66,7 @@ final class PermissionMonitor { pollTask?.cancel() pollTask = Task { [weak self] in while !Task.isCancelled { - try? await Task.sleep(for: .seconds(2)) + try? await Task.sleep(for: Self.pollInterval) guard let self else { return } self.refresh() } From d1487b5eb683858169b9d92743f97b616b848a4e Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:29:12 +0200 Subject: [PATCH 02/16] CI: a comment naming a docs/ heading that does not exist fails; ARCHITECTURE.md says where each number lives Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docs.yml | 27 ++++++++ docs/ARCHITECTURE.md | 65 ++++++++++++++++++ scripts/check-doc-pointers.sh | 120 ++++++++++++++++++++++++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 .github/workflows/docs.yml create mode 100755 scripts/check-doc-pointers.sh diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml new file mode 100644 index 0000000..903227e --- /dev/null +++ b/.github/workflows/docs.yml @@ -0,0 +1,27 @@ +name: Docs + +on: + push: + branches: [main] + paths: + - 'docs/**' + - 'Sources/**' + - 'Tests/**' + - 'scripts/check-doc-pointers.sh' + - '.github/workflows/docs.yml' + pull_request: + paths: + - 'docs/**' + - 'Sources/**' + - 'Tests/**' + - 'scripts/check-doc-pointers.sh' + - '.github/workflows/docs.yml' + +jobs: + doc-pointers: + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - name: Comments point at headings that exist + run: ./scripts/check-doc-pointers.sh diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index d362749..586da5e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -5,6 +5,10 @@ made is in the Decisions table of [CLAUDE.md](../CLAUDE.md#decisions); why the release-to-paste path is fast is in [PERFORMANCE.md](PERFORMANCE.md). This file is the map: what runs where, in what order, and what each part relies on. +Comments in the code point at its sections by name, and +`scripts/check-doc-pointers.sh` fails CI when a pointer names a heading +that does not exist. Rename a section together with its pointers. + ## Modules | Target | Holds | Frameworks that matter | @@ -485,3 +489,64 @@ Errors and progress go to stderr, so stdout holds only results. Fixtures are audio files with a sibling `.txt` holding the script, made by `scripts/make-fixtures.sh`. The procedures are in [BENCHMARKS.md](BENCHMARKS.md). + +## Where the numbers live + +The timings and limits this file and CLAUDE.md quote, and the constant each +comes from. A change to a constant updates its row here and the prose that +quotes it. Where an initialiser's default is the constant, the app relies on +that default. + +| Number | What | Constant | File | +|---|---|---|---| +| 0.3 s | Shortest recording that is transcribed | `DictationCoordinator.minimumDuration` | `PladderCore/DictationCoordinator.swift` | +| 4 words | Shortest dictation that is polished | `DictationCoordinator.minimumPolishWords` | `PladderCore/DictationCoordinator.swift` | +| 10 min | Recording cap | `DictationCoordinator.maximumDuration` | `PladderCore/DictationCoordinator.swift` | +| 1 s | Feed loop cadence | `DictationCoordinator.feedInterval` | `PladderCore/DictationCoordinator.swift` | +| 500 ms | Live pass cadence (Live Transcript) | `DictationCoordinator.livePassInterval` | `PladderCore/DictationCoordinator.swift` | +| 2 s | Warm loop cadence | `DictationCoordinator.warmupInterval` | `PladderCore/DictationCoordinator.swift` | +| 0.5 s | Length of a warm pass, 8,000 samples at 16 kHz | `DictationCoordinator.warmupSamples` (batch engine), `FluidAudioIncrementalEngine.warmupSamples` (streaming) | `PladderCore/DictationCoordinator.swift`, `PladderEngines/FluidAudioIncrementalEngine.swift` | +| 2 s | How long an error shows before the state goes idle | `DictationCoordinator.errorDisplayDuration` | `PladderCore/DictationCoordinator.swift` | +| 3 s | Quit waits this long for `shutdown()` | `AppDelegate.shutdownDeadline` | `Pladder/PladderApp.swift` | +| 5 s | SIGTERM ends the process after this | `AppDelegate.signalExitDeadline` | `Pladder/PladderApp.swift` | +| 5 s | Grid a live pass's window is cut on, 80,000 samples | `FluidAudioIncrementalEngine.windowHopSamples` | `PladderEngines/FluidAudioIncrementalEngine.swift` | +| 250 ms | Engine status poll while loading | `EngineLoader.init(pollInterval:)` | `PladderCore/EngineLoader.swift` | +| 400 ms | A shorter press of a hybrid key latches | `DictationCoordinator.holdThreshold`, handed to `HotkeyGestureTracker` | `PladderCore/DictationCoordinator.swift` | +| 50 ms | Bounce window, and the settle after it | `DictationCoordinator.bounceWindow`, handed to `HotkeyGestureTracker` | `PladderCore/DictationCoordinator.swift` | +| 1 s | Interrupted press window | `HotkeyChordSet.init(interruptionWindow:)`, handed to each `HotkeyChordTracker` | `PladderCore/HotkeyChordSet.swift` | +| 3 s | Secure Event Input sustained before Carbon takes over | `SustainedCondition.init(threshold:)` | `PladderCore/Support/SustainedCondition.swift` | +| 2 s | Retry of a tap that could not be installed | `GlobalHotkeyMonitor.retryInterval` | `PladderSystem/GlobalHotkeyMonitor.swift` | +| 2 s | Permission and Secure Event Input poll | `PermissionMonitor.pollInterval` | `Pladder/PermissionMonitor.swift` | +| 64 MB | Largest clipboard item the snapshot keeps | `ClipboardSnapshot.maximumItemBytes` | `PladderSystem/ClipboardSnapshot.swift` | +| 400 ms | Earliest restore after Cmd+V; also when an owed Return goes out if nothing reads | `ClipboardKeeper.restoreFloor` | `PladderSystem/ClipboardKeeper.swift` | +| 200 ms | Restore after the target app's last read | `ClipboardKeeper.readSettle` | `PladderSystem/ClipboardKeeper.swift` | +| 8 s | Restore when nothing reads | `ClipboardKeeper.restoreCap` | `PladderSystem/ClipboardKeeper.swift` | +| 50 ms | Return after the target app reads the paste | `PasteboardOutput.init(submitDelay:)` | `PladderSystem/PasteboardOutput.swift` | +| 200 ms | Mute lands this long after key-down | `OutputMuteController.init(delay:)` | `PladderCore/Support/OutputMuteController.swift` | +| 150 ms | The pill appears this long into a recording | `OverlayController.presentDelay` | `Pladder/Overlay/OverlayController.swift` | +| 0.18 | Custom-word match: edit distance over the longer length must be under this | `CustomWordCorrector.threshold` | `PladderCore/Processors/CustomWordCorrector.swift` | +| 0.3 | Factor on the score when the Soundex codes agree | `CustomWordCorrector.soundexBonus` | `PladderCore/Processors/CustomWordCorrector.swift` | +| 4 tokens | Longest run tried as one custom word | `CustomWordCorrector.maxNGram` | `PladderCore/Processors/CustomWordCorrector.swift` | +| 3 characters | Key or candidate this short must match exactly | `CustomWordCorrector.exactOnlyLength` | `PladderCore/Processors/CustomWordCorrector.swift` | +| 1 edit, 5 characters | Soundex buys at most one edit on keys this short | `CustomWordCorrector.shortKeySoundexEdits`, `shortKeyLength` | `PladderCore/Processors/CustomWordCorrector.swift` | +| 8 s | Polish budget | `TranscriptPolisher.init(timeout:)`, `S1MiniPolisher.init(timeout:)` | `PladderRefine/TranscriptPolisher.swift`, `PladderRefine/S1MiniPolisher.swift` | +| 300 / 250 words | Polish chunk size, Apple's model / S1-mini | `TranscriptPolisher.chunkSize`, `S1MiniPolisher.chunkSize` | `PladderRefine/TranscriptPolisher.swift`, `PladderRefine/S1MiniPolisher.swift` | +| 600 / 400 words | A dictation longer than this is chunked | `TranscriptPolisher.chunkThreshold`, `S1MiniPolisher.chunkThreshold` | `PladderRefine/TranscriptPolisher.swift`, `PladderRefine/S1MiniPolisher.swift` | +| 1.5 × chunk size | Forced cut between words | `PolishChunking.hardLimit` | `PladderRefine/PolishChunking.swift` | +| 16 MB | Checksum read size, and how often a cancel is checked | `ModelFiles.sha256(chunkSize:)` | `PladderRefine/ModelFiles.swift` | +| 60 s | How long a pasted field is watched | `AXPasteObserver.init(observationWindow:)` | `PladderSystem/AXPasteObserver.swift` | +| 8 × 125 ms | Retries to find the paste in the field | `AXPasteObserver.init(anchorRetries:anchorRetryInterval:)` | `PladderSystem/AXPasteObserver.swift` | +| 2 words | Most words on either side of a correction | `CorrectionDiff.maximumWordsPerSide` | `PladderCore/Learning/CorrectionDiff.swift` | +| 3 spans | More changed spans is a rewrite | `CorrectionDiff.maximumHunks` | `PladderCore/Learning/CorrectionDiff.swift` | +| half, at least 2 words | More changed words of the paste is a rewrite | `CorrectionDiff.rewriteFraction`, `rewriteAllowance` | `PladderCore/Learning/CorrectionDiff.swift` | +| 60 % | Share of the window a reading must still hold to be diffed | `CorrectionDiff.anchorCoverage` | `PladderCore/Learning/CorrectionDiff.swift` | +| 2 edits, 1 up to 3 letters | Phonetic gate: largest edit distance, and for words this short | `PhoneticGate.maximumDistance`, `shortKeyDistance`, `shortKeyLength` | `PladderCore/Learning/PhoneticGate.swift` | +| 3 | Proposals per paste | `CorrectionLearner.maximumProposalsPerPaste` | `PladderCore/Learning/CorrectionLearner.swift` | +| 400 characters | Sentence around the correction the reviewer sees | `CorrectionLearner.sentenceLimit` | `PladderCore/Learning/CorrectionLearner.swift` | +| 10 s | Correction review budget | `FoundationModelsCorrectionReviewer.init(timeout:)` | `PladderRefine/FoundationModelsCorrectionReviewer.swift` | + +Some numbers are measurements, not constants, and change only with a new +measurement: the cold encoder pass costing about 110 ms more than a warm one +([PERFORMANCE.md](PERFORMANCE.md#what-is-still-slow), 0.114 s on an M1), +apps reading the pasteboard 0 to 25 ms after Cmd+V, and Universal Clipboard +reading an unmarked write within about 15 ms. diff --git a/scripts/check-doc-pointers.sh b/scripts/check-doc-pointers.sh new file mode 100755 index 0000000..60e9763 --- /dev/null +++ b/scripts/check-doc-pointers.sh @@ -0,0 +1,120 @@ +#!/bin/bash +# Fails when a Swift comment points at a docs/*.md heading that does not exist. +# A pointer is `docs/X.md, "Section"` or `"Section" in docs/X.md`; a comment +# split over several `//` lines is read as one. Headings are levels 1 to 3, +# outside code fences, with backticks removed. A bare `docs/X.md` is fine as +# long as the file exists. +# +# Plain bash 3.2 and POSIX awk, so it runs the same on macOS and ubuntu. + +set -eo pipefail +cd "$(dirname "$0")/.." + +headings=$(mktemp) +trap 'rm -f "$headings"' EXIT + +for doc in docs/*.md; do + awk -v doc="$doc" ' + { sub(/\r$/, "") } + /^[ \t]*(```|~~~)/ { fenced = !fenced; next } + fenced { next } + match($0, /^#+ /) && RLENGTH <= 4 { + text = substr($0, RLENGTH + 1) + gsub(/`/, "", text) + sub(/[ \t#]+$/, "", text) + print doc "\t" text + } + END { print doc "\t" } + ' "$doc" >> "$headings" +done + +files=() +while IFS= read -r file; do files+=("$file"); done < <(find Sources Tests -name '*.swift' 2>/dev/null | LC_ALL=C sort) +if [ ${#files[@]} -eq 0 ]; then + echo "no Swift files under Sources or Tests" >&2 + exit 1 +fi + +awk -v headings="$headings" ' + BEGIN { + while ((getline line < headings) > 0) { + tab = index(line, "\t") + doc = substr(line, 1, tab - 1) + exists[doc] = 1 + section = substr(line, tab + 1) + if (section != "") known[doc SUBSEP section] = 1 + } + close(headings) + } + + function report(offset, message, i) { + for (i = runLines; i > 1 && starts[i] > offset; i--) {} + printf "%s:%d: %s\n", runFile, runFirst + i - 1, message + failures++ + } + + # A missing file is reported once, by the bare-name pass. + function check(doc, section, offset) { + pointers++ + if ((doc in exists) && !((doc SUBSEP section) in known)) + report(offset, "no heading \"" section "\" in " doc) + } + + # Every pointer in the joined comment run, in both orders, then every bare file name. + function scan( rest, base, hit, doc, section, q) { + rest = run; base = 0 + while (match(rest, /docs\/[A-Z]+\.md, "[^"]+"/)) { + hit = substr(rest, RSTART, RLENGTH) + doc = substr(hit, 1, index(hit, ",") - 1) + q = index(hit, "\"") + section = substr(hit, q + 1, length(hit) - q - 1) + check(doc, section, base + RSTART) + base += RSTART + RLENGTH - 1 + rest = substr(rest, RSTART + RLENGTH) + } + rest = run; base = 0 + while (match(rest, /"[^"]+" in docs\/[A-Z]+\.md/)) { + hit = substr(rest, RSTART, RLENGTH) + q = index(substr(hit, 2), "\"") + 1 + section = substr(hit, 2, q - 2) + doc = substr(hit, q + 5) + check(doc, section, base + RSTART) + base += RSTART + RLENGTH - 1 + rest = substr(rest, RSTART + RLENGTH) + } + rest = run; base = 0 + while (match(rest, /docs\/[A-Z]+\.md/)) { + doc = substr(rest, RSTART, RLENGTH) + if (!(doc in exists)) report(base + RSTART, "no file " doc) + base += RSTART + RLENGTH - 1 + rest = substr(rest, RSTART + RLENGTH) + } + } + + function flush() { + if (runLines > 0) scan() + run = ""; runLines = 0 + } + + FNR == 1 { flush(); files++ } + + { + line = $0 + sub(/\r$/, "", line) + if (match(line, /^[ \t]*\/\/+[ \t]?/)) { + text = substr(line, RLENGTH + 1) + if (runLines == 0) { runFile = FILENAME; runFirst = FNR } + else run = run " " + starts[++runLines] = length(run) + 1 + run = run text + } else { + flush() + } + } + + END { + flush() + if (failures > 0) exit 1 + printf "%d pointers to docs/ headings checked in %d Swift files.\n", pointers, files + } +' "${files[@]}" From e403b158a3bfad6a38eb381cc105a88316a7dddf Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:41:28 +0200 Subject: [PATCH 03/16] Tests: ManualClock wakes a waiter when its sleeper arrives, and waitUntil joins eventually in PladderTestSupport `waitForSleeper(at:)` polled `deadlines` a millisecond at a time; a sleep now resumes the waiters it satisfies, and `waitForSleepers(_:)` does the same for a count, replacing four `waitUntil { clock.sleeperCount >= 2 }`. Both still end by themselves after two seconds, as a Gate does. `waitUntil` moves from DictationCoordinatorTests to PladderTestSupport, and the coordinator tests' own `eventually` goes in favour of the shared one. Co-Authored-By: Claude Opus 5.5 --- .../CoordinatorRegressionTests.swift | 4 +- .../DictationCoordinatorTests.swift | 24 +----- .../PladderCoreTests/EngineLoaderTests.swift | 1 + Tests/PladderTestSupport/ManualClock.swift | 77 ++++++++++++++----- Tests/PladderTestSupport/TestSupport.swift | 21 +++++ 5 files changed, 85 insertions(+), 42 deletions(-) diff --git a/Tests/PladderCoreTests/CoordinatorRegressionTests.swift b/Tests/PladderCoreTests/CoordinatorRegressionTests.swift index c9d3681..67702f2 100644 --- a/Tests/PladderCoreTests/CoordinatorRegressionTests.swift +++ b/Tests/PladderCoreTests/CoordinatorRegressionTests.swift @@ -290,7 +290,7 @@ import Testing let (c, output, _) = makeCoordinator(clock: clock) await c.startIdle() await c.hotkeyPressed() - #expect(await waitUntil { clock.sleeperCount >= 2 }) + #expect(await clock.waitForSleepers(2)) clock.advance(by: c.maximumDuration - .seconds(1)) #expect(c.state.isRecording) clock.advance(by: .seconds(1)) @@ -354,7 +354,7 @@ import Testing await c.hotkeyPressed() #expect(await waitUntil { warmPasses() == 1 }) for expected in 2...4 { - #expect(await waitUntil { clock.sleeperCount >= 2 }) + #expect(await clock.waitForSleepers(2)) clock.advance(by: c.warmupInterval) #expect(await waitUntil { warmPasses() == expected }) } diff --git a/Tests/PladderCoreTests/DictationCoordinatorTests.swift b/Tests/PladderCoreTests/DictationCoordinatorTests.swift index 59d0091..1e24e3d 100644 --- a/Tests/PladderCoreTests/DictationCoordinatorTests.swift +++ b/Tests/PladderCoreTests/DictationCoordinatorTests.swift @@ -389,26 +389,6 @@ func makeStreamingCoordinator( return (coordinator, output, capture, engine) } -@MainActor -func waitUntil(_ timeout: Duration = .seconds(2), _ condition: @MainActor () -> Bool) async -> Bool { - let deadline = ContinuousClock.now + timeout - while ContinuousClock.now < deadline { - if condition() { return true } - try? await Task.sleep(for: .milliseconds(10)) - } - return condition() -} - -// A dropped recording is idle at once; its microphone stops on a task after. -func eventually(_ timeout: Duration = .seconds(2), _ condition: () async -> Bool) async -> Bool { - let deadline = ContinuousClock.now + timeout - while ContinuousClock.now < deadline { - if await condition() { return true } - try? await Task.sleep(for: .milliseconds(10)) - } - return await condition() -} - extension DictationCoordinator { func startIdle(sourceLocation: SourceLocation = #_sourceLocation) async { start() @@ -1342,7 +1322,7 @@ final class EventLog: @unchecked Sendable { await c.press(hotkey) hotkey.release() #expect(await waitUntil { c.isLatched }) - #expect(await waitUntil { clock.sleeperCount >= 2 }) + #expect(await clock.waitForSleepers(2)) clock.advance(by: c.maximumDuration) await c.cycleFinished() #expect(output.inserted.count == 1) @@ -1479,7 +1459,7 @@ final class EventLog: @unchecked Sendable { #expect(await waitUntil { c.state == .idle }) #expect(hotkey.cancelKeyEnabled == [true, false, true, false]) await c.hotkeyPressed() - #expect(await waitUntil { clock.sleeperCount >= 2 }) + #expect(await clock.waitForSleepers(2)) clock.advance(by: c.maximumDuration) #expect(await waitUntil { !c.state.isRecording }) await c.inFlight?.value diff --git a/Tests/PladderCoreTests/EngineLoaderTests.swift b/Tests/PladderCoreTests/EngineLoaderTests.swift index ab2242e..49d0cee 100644 --- a/Tests/PladderCoreTests/EngineLoaderTests.swift +++ b/Tests/PladderCoreTests/EngineLoaderTests.swift @@ -1,4 +1,5 @@ import Foundation +import PladderTestSupport import Testing @testable import PladderCore diff --git a/Tests/PladderTestSupport/ManualClock.swift b/Tests/PladderTestSupport/ManualClock.swift index 2ab6f7b..e7d199d 100644 --- a/Tests/PladderTestSupport/ManualClock.swift +++ b/Tests/PladderTestSupport/ManualClock.swift @@ -10,11 +10,31 @@ public final class ManualClock: Clock, Sendable { let continuation: CheckedContinuation } + private struct Watcher { + let id: Int + let isMet: @Sendable ([Instant]) -> Bool + let continuation: CheckedContinuation + } + private struct State { var now: Instant var nextID = 0 var sleepers: [Sleeper] = [] var cancelled: Set = [] + var watchers: [Watcher] = [] + var expired: Set = [] + + mutating func takeID() -> Int { + defer { nextID += 1 } + return nextID + } + + mutating func takeMetWatchers() -> [Watcher] { + let deadlines = sleepers.map(\.deadline) + let met = watchers.filter { $0.isMet(deadlines) } + watchers.removeAll { watcher in met.contains { $0.id == watcher.id } } + return met + } } private let state: Mutex @@ -47,18 +67,16 @@ public final class ManualClock: Clock, Sendable { } public func sleep(until deadline: Instant, tolerance: Duration? = nil) async throws { - let id = state.withLock { state -> Int in - defer { state.nextID += 1 } - return state.nextID - } + let id = state.withLock { $0.takeID() } try await withTaskCancellationHandler { try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - let early = state.withLock { state -> Result? in - if state.cancelled.remove(id) != nil || Task.isCancelled { return .failure(CancellationError()) } - if deadline <= state.now { return .success(()) } + let (early, met) = state.withLock { state -> (Result?, [Watcher]) in + if state.cancelled.remove(id) != nil || Task.isCancelled { return (.failure(CancellationError()), []) } + if deadline <= state.now { return (.success(()), []) } state.sleepers.append(Sleeper(id: id, deadline: deadline, continuation: continuation)) - return nil + return (nil, state.takeMetWatchers()) } + for watcher in met { watcher.continuation.resume(returning: true) } if let early { continuation.resume(with: early) } } } onCancel: { @@ -73,19 +91,42 @@ public final class ManualClock: Clock, Sendable { } } - // Waits in real time, but briefly, until a detached task has reached its sleep. public func waitForSleeper(at offset: Duration) async -> Bool { let deadline = start + offset - return await eventually { self.deadlines.contains(deadline) } + return await waitFor { $0.contains(deadline) } } -} -// Polls in real time, a millisecond at a time for up to about two seconds, for what a -// detached task does on its own schedule. -public func eventually(_ condition: @Sendable () async -> Bool) async -> Bool { - for _ in 0..<2_000 { - if await condition() { return true } - try? await Task.sleep(for: .milliseconds(1)) + public func waitForSleepers(_ count: Int) async -> Bool { + await waitFor { $0.count >= count } + } + + private func waitFor(_ isMet: @escaping @Sendable ([Instant]) -> Bool) async -> Bool { + let id = state.withLock { $0.takeID() } + // Ends the wait after two seconds, as a `Gate` does, so a regression fails rather than hangs. + let timeout = Task { + guard (try? await Task.sleep(for: .seconds(2))) != nil else { return } + self.expire(id) + } + defer { timeout.cancel() } + return await withCheckedContinuation { (continuation: CheckedContinuation) in + let early = state.withLock { state -> Bool? in + if state.expired.remove(id) != nil { return false } + if isMet(state.sleepers.map(\.deadline)) { return true } + state.watchers.append(Watcher(id: id, isMet: isMet, continuation: continuation)) + return nil + } + if let early { continuation.resume(returning: early) } + } + } + + private func expire(_ id: Int) { + let watcher = state.withLock { state -> Watcher? in + if let index = state.watchers.firstIndex(where: { $0.id == id }) { + return state.watchers.remove(at: index) + } + state.expired.insert(id) + return nil + } + watcher?.continuation.resume(returning: false) } - return await condition() } diff --git a/Tests/PladderTestSupport/TestSupport.swift b/Tests/PladderTestSupport/TestSupport.swift index 8d350f3..b662b60 100644 --- a/Tests/PladderTestSupport/TestSupport.swift +++ b/Tests/PladderTestSupport/TestSupport.swift @@ -61,3 +61,24 @@ public func scratchDirectory(_ name: String) throws -> URL { try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) return dir } + +// Polls in real time: the fakes' counters are not observable. +@MainActor +public func waitUntil(_ timeout: Duration = .seconds(2), _ condition: @MainActor () -> Bool) async -> Bool { + let deadline = ContinuousClock.now + timeout + while ContinuousClock.now < deadline { + if condition() { return true } + try? await Task.sleep(for: .milliseconds(10)) + } + return condition() +} + +// Polls in real time, a millisecond at a time for up to about two seconds, for what a +// detached task does on its own schedule. +public func eventually(_ condition: @Sendable () async -> Bool) async -> Bool { + for _ in 0..<2_000 { + if await condition() { return true } + try? await Task.sleep(for: .milliseconds(1)) + } + return await condition() +} From 6c45e6b479d05670e91993cd8a780b00327dd1a4 Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:41:35 +0200 Subject: [PATCH 04/16] Tests: the coordinator fakes hold on a Gate instead of sleeping `FakeCapture.stop`, `FakeRefiner.refine` and the counting engine's warm pass slept 50 to 200 ms so a cancel, a press or a release would land while they ran. Each now waits on a Gate the test opens once it has seen that state. The warm pass's wait cannot be cancelled, like a CoreML pass in flight. Co-Authored-By: Claude Opus 5.5 --- .../CoordinatorRegressionTests.swift | 12 ++++-- .../DictationCoordinatorTests.swift | 42 ++++++++++++------- 2 files changed, 35 insertions(+), 19 deletions(-) diff --git a/Tests/PladderCoreTests/CoordinatorRegressionTests.swift b/Tests/PladderCoreTests/CoordinatorRegressionTests.swift index 67702f2..a7f6996 100644 --- a/Tests/PladderCoreTests/CoordinatorRegressionTests.swift +++ b/Tests/PladderCoreTests/CoordinatorRegressionTests.swift @@ -53,12 +53,16 @@ import Testing @Test func aPressRightAfterACancelKeepsItsUtterance() async { let engine = FakeStreamingEngine() let (c, output, capture, _) = await makeStreamingCoordinator(style: .compact, engine: engine) - await capture.setStopDelay(.milliseconds(150)) + let stopGate = Gate() + await capture.setStopGate(stopGate) await c.startIdle() await c.hotkeyPressed() let cancel = Task { await c.cancelRecording() } #expect(await waitUntil { c.state == .idle }) - await c.hotkeyPressed() + let press = Task { await c.hotkeyPressed() } + #expect(await waitUntil { c.state.isRecording }) + await stopGate.open() + await press.value #expect(c.state.isRecording) await cancel.value c.hotkeyReleased() @@ -92,7 +96,8 @@ import Testing @Test func aPressCancelledWhileItWaitsLeavesTheMicrophoneAlone() async { let (c, _, capture) = makeCoordinator() - await capture.setStopDelay(.milliseconds(150)) + let stopGate = Gate() + await capture.setStopGate(stopGate) await c.startIdle() await c.hotkeyPressed() let cancel = Task { await c.cancelRecording() } @@ -103,6 +108,7 @@ import Testing #expect(await waitUntil { c.state == .idle }) let press = Task { await c.hotkeyPressed() } #expect(await waitUntil { c.state.isRecording }) + await stopGate.open() _ = await (cancel.value, cancelledPress.value, cancelPress.value, press.value) #expect(c.state.isRecording) #expect(await capture.startCount == 2) diff --git a/Tests/PladderCoreTests/DictationCoordinatorTests.swift b/Tests/PladderCoreTests/DictationCoordinatorTests.swift index 1e24e3d..9495f59 100644 --- a/Tests/PladderCoreTests/DictationCoordinatorTests.swift +++ b/Tests/PladderCoreTests/DictationCoordinatorTests.swift @@ -17,7 +17,7 @@ actor FakeCapture: AudioCapture { var startCount = 0 var stopCount = 0 var levelContinuation: AsyncStream.Continuation? - var stopDelay: Duration = .zero + var stopGate: Gate? func start() async throws -> AsyncStream { startCount += 1 @@ -33,7 +33,7 @@ actor FakeCapture: AudioCapture { func stop() async -> CapturedAudio { stopCount += 1 levelContinuation?.finish() - if stopDelay > .zero { try? await Task.sleep(for: stopDelay) } + await stopGate?.wait() return CapturedAudio(samples: samplesToReturn) } @@ -41,7 +41,7 @@ actor FakeCapture: AudioCapture { func setSamples(_ s: [Float]) { samplesToReturn = s } func setDrainSamples(_ s: [Float]) { drainSamples = s } - func setStopDelay(_ d: Duration) { stopDelay = d } + func setStopGate(_ gate: Gate) { stopGate = gate } func emitLevel(_ l: Float) { levelContinuation?.yield(l) } } @@ -126,20 +126,20 @@ final class FakeRefiner: TranscriptRefiner, @unchecked Sendable { private var _calls: [String] = [] private var _prepareCount = 0 private let result: String? - private let delay: Duration + private let gate: Gate? var calls: [String] { lock.withLock { _calls } } var prepareCount: Int { lock.withLock { _prepareCount } } - init(result: String? = "polished", delay: Duration = .zero) { + init(result: String? = "polished", gate: Gate? = nil) { self.result = result - self.delay = delay + self.gate = gate } func prepare() async { lock.withLock { _prepareCount += 1 } } func refine(_ text: String) async -> String? { lock.withLock { _calls.append(text) } - if delay > .zero { try? await Task.sleep(for: delay) } + await gate?.wait() return result } } @@ -184,15 +184,15 @@ actor CountingEngine: TranscriptionEngine { nonisolated let displayName = "Counting" private(set) var status: EngineStatus = .unloaded private let recorder = Recorder() - private let delay: Duration + private let warmGate: Gate? - init(delay: Duration = .zero) { self.delay = delay } + init(warmGate: Gate? = nil) { self.warmGate = warmGate } nonisolated var calls: [Int] { recorder.all } func load() async throws { status = .ready } func transcribe(_ samples: [Float]) async throws -> Transcript { recorder.append(samples.count) - if delay > .zero { try? await Task.sleep(for: delay) } + if samples.count == 8_000 { await warmGate?.wait() } return Transcript(text: "counted", audioDuration: Double(samples.count) / CapturedAudio.sampleRate, processingTime: 0, engineID: id) } func unload() { status = .unloaded } @@ -357,10 +357,10 @@ extension EngineRegistry.Entry { @MainActor func makeCountingCoordinator( - engineDelay: Duration = .zero, + warmGate: Gate? = nil, clock: any Clock = ContinuousClock() ) -> (DictationCoordinator, FakeOutput, FakeCapture, CountingEngine) { - let engine = CountingEngine(delay: engineDelay) + let engine = CountingEngine(warmGate: warmGate) var settings = DictationSettings(engineID: CountingEngine.engineID) settings.appendTrailingSpace = false let (coordinator, output, capture) = makeCoordinator(engines: [.serving(engine)], settings: settings, clock: clock) @@ -647,12 +647,15 @@ final class EventLog: @unchecked Sendable { let a = FakeHotkey() let b = FakeHotkey() let (c, _, capture) = makeCoordinator(hotkeyMonitor: a) - await capture.setStopDelay(.milliseconds(50)) + let stopGate = Gate() + await capture.setStopGate(stopGate) await c.startIdle() await c.press(a) c.update(c.settings, standInHotkey: c.standInHotkey, monitor: b) #expect(c.state == .idle) b.press() + #expect(await waitUntil { c.state.isRecording }) + await stopGate.open() #expect(await waitUntil { c.handledHotkeyEvents == 2 }) #expect(c.state.isRecording) #expect(await capture.startCount == 2) @@ -846,9 +849,14 @@ final class EventLog: @unchecked Sendable { } @Test func releaseDuringWarmupStillInserts() async { - let (c, output, _, _) = makeCountingCoordinator(engineDelay: .milliseconds(200)) + let warmGate = Gate() + let (c, output, _, engine) = makeCountingCoordinator(warmGate: warmGate) await c.startIdle() - await c.dictate() + await c.hotkeyPressed() + #expect(await waitUntil { engine.calls == [8_000] }) + c.hotkeyReleased() + await warmGate.open() + await c.inFlight?.value #expect(output.inserted.count == 1) #expect(c.state == .idle) } @@ -1085,7 +1093,8 @@ final class EventLog: @unchecked Sendable { } @Test func polishingStateIsPublishedWhileTheModelRuns() async { - let refiner = FakeRefiner(delay: .milliseconds(200)) + let gate = Gate() + let refiner = FakeRefiner(gate: gate) let (c, output, _) = makeCoordinator(engineText: Self.sentence, settings: Self.settings(), refiner: refiner) await c.startIdle() await c.hotkeyPressed() @@ -1094,6 +1103,7 @@ final class EventLog: @unchecked Sendable { #expect(await waitUntil { c.state == .polishing }) #expect(c.willPolish) #expect(output.inserted.isEmpty) + await gate.open() await c.inFlight?.value #expect(c.state == .idle) #expect(!c.willPolish) From 1ab225944f34d8f18843f511c12545903aa38c3b Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:41:39 +0200 Subject: [PATCH 05/16] Tests: the engine loader test waits for the superseded load on a Gate, not a 10 ms poll Co-Authored-By: Claude Opus 5.5 --- Tests/PladderCoreTests/EngineLoaderTests.swift | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/Tests/PladderCoreTests/EngineLoaderTests.swift b/Tests/PladderCoreTests/EngineLoaderTests.swift index 49d0cee..62bdf56 100644 --- a/Tests/PladderCoreTests/EngineLoaderTests.swift +++ b/Tests/PladderCoreTests/EngineLoaderTests.swift @@ -83,6 +83,7 @@ private actor GatedEngine: TranscriptionEngine { private var progress: Double private var isOpen = false private var waiters: [CheckedContinuation] = [] + private let loaded = Gate() init(id: EngineID, progress: Double) { self.id = id @@ -95,13 +96,11 @@ private actor GatedEngine: TranscriptionEngine { await withCheckedContinuation { waiters.append($0) } } status = .ready + await loaded.open() } func waitUntilLoaded() async -> Bool { - for _ in 0..<100 { - if status == .ready { return true } - try? await Task.sleep(for: .milliseconds(10)) - } + await loaded.wait() return status == .ready } From de4b068f8c4a740232859c8a653b8135e5944178 Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:41:42 +0200 Subject: [PATCH 06/16] Tests: OrderedTasks' cancel winds down on a Gate, in one run instead of fifty The stand-in cancel slept 5 ms and the test ran fifty times so the ensure would sometimes reach the actor first. It now waits on a Gate and marks the download stopped only after it, so an ensure that does not wait for the cancel loses its download whether it reaches the actor before the cancel or during its wind-down. Co-Authored-By: Claude Opus 5.5 --- .../PladderCoreTests/OrderedTasksTests.swift | 27 +++++++++++-------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/Tests/PladderCoreTests/OrderedTasksTests.swift b/Tests/PladderCoreTests/OrderedTasksTests.swift index 4f59dd6..47aa34d 100644 --- a/Tests/PladderCoreTests/OrderedTasksTests.swift +++ b/Tests/PladderCoreTests/OrderedTasksTests.swift @@ -5,26 +5,31 @@ import Testing private actor Downloads { private(set) var running = false + private let windDown: Gate + + init(windDown: Gate) { self.windDown = windDown } + func ensure() { running = true } + // Winding down, as a cancelled URLSession task does, and only then marked stopped. func cancel() async { + await windDown.wait() running = false - // Winding down, as a cancelled URLSession task does. - try? await Task.sleep(for: .milliseconds(5)) } } @MainActor @Suite(.timeLimit(.minutes(1))) struct OrderedTasksTests { @Test func aCancelThenEnsureLeavesTheDownloadRunning() async { - for _ in 0..<50 { - let downloads = Downloads() - await downloads.ensure() - let tasks = OrderedTasks() - tasks.enqueue { await downloads.cancel() } - tasks.enqueue { await downloads.ensure() } - await tasks.drained() - #expect(await downloads.running) - } + let windDown = Gate() + let downloads = Downloads(windDown: windDown) + await downloads.ensure() + let tasks = OrderedTasks() + tasks.enqueue { await downloads.cancel() } + tasks.enqueue { await downloads.ensure() } + await windDown.untilSomeoneWaits() + await windDown.open() + await tasks.drained() + #expect(await downloads.running) } @Test func eachPieceStartsAfterThePreviousOneFinished() async { From 8894ccc75a3a50389811eef080ed7396910447fe Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:41:52 +0200 Subject: [PATCH 07/16] Output mute: the arm delay sleeps on an injected clock Defaulted to ContinuousClock, so the app's call site is unchanged. The two tests that slept four times the delay to be sure an arm had given up now drive it with ManualClock. Off the release path: only `recordingStarted`, called at key-down, reads the clock. Co-Authored-By: Claude Opus 5.5 --- .../Support/OutputMuteController.swift | 5 +++- .../OutputMuteControllerTests.swift | 26 +++++++++---------- 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/Sources/PladderCore/Support/OutputMuteController.swift b/Sources/PladderCore/Support/OutputMuteController.swift index 369ac66..1e15d0e 100644 --- a/Sources/PladderCore/Support/OutputMuteController.swift +++ b/Sources/PladderCore/Support/OutputMuteController.swift @@ -5,6 +5,7 @@ import Foundation public actor OutputMuteController: OutputMuter { private let control: any OutputMuteControl private let delay: Duration + private let clock: any Clock private let log: @Sendable (String) -> Void // A pending arm that wakes to find this changed belongs to a session already over. @@ -15,10 +16,12 @@ public actor OutputMuteController: OutputMuter { public init( control: any OutputMuteControl, delay: Duration = .milliseconds(200), + clock: any Clock = ContinuousClock(), log: @escaping @Sendable (String) -> Void = { _ in } ) { self.control = control self.delay = delay + self.clock = clock self.log = log } @@ -29,7 +32,7 @@ public actor OutputMuteController: OutputMuter { latestSession = session generation += 1 let mine = generation - try? await Task.sleep(for: delay) + try? await clock.sleep(for: delay) guard generation == mine else { return } guard restore == nil else { // An earlier session muted it and its end has not come; this session's end restores it. diff --git a/Tests/PladderCoreTests/OutputMuteControllerTests.swift b/Tests/PladderCoreTests/OutputMuteControllerTests.swift index 12ccace..dfd5dc4 100644 --- a/Tests/PladderCoreTests/OutputMuteControllerTests.swift +++ b/Tests/PladderCoreTests/OutputMuteControllerTests.swift @@ -61,36 +61,33 @@ private final class FakeMuteControl: OutputMuteControl, @unchecked Sendable { } } -// Short enough to keep the tests fast, long enough that a "before the delay" call -// really lands before it. +// Short, so the tests that let it pass in real time stay fast. private let testDelay = Duration.milliseconds(10) private func makeController( _ control: FakeMuteControl, delay: Duration = testDelay, + clock: any Clock = ContinuousClock(), log: @escaping @Sendable (String) -> Void = { _ in } ) -> OutputMuteController { - OutputMuteController(control: control, delay: delay, log: log) + OutputMuteController(control: control, delay: delay, clock: clock, log: log) } private func waitForGeneration(_ muter: OutputMuteController, _ target: Int) async { while await muter.armedGeneration < target { await Task.yield() } } -private func pastTheDelay() async { - try? await Task.sleep(for: testDelay * 4) -} - @Suite(.timeLimit(.minutes(1))) struct OutputMuteControllerTests { @Test func tapAndReleaseBeforeTheDelayNeverMutes() async { let control = FakeMuteControl() - let muter = makeController(control) + let clock = ManualClock() + let muter = makeController(control, clock: clock) let started = Task { await muter.recordingStarted(session: 1) } - await waitForGeneration(muter, 1) + #expect(await clock.waitForSleeper(at: testDelay)) await muter.recordingEnded(session: 1) + clock.advance(by: testDelay) await started.value - await pastTheDelay() #expect(control.setCalls.isEmpty) #expect(control.state(of: 1) == false) @@ -186,16 +183,17 @@ private func pastTheDelay() async { @Test func aRestartInsideTheDelayStillEndsUpMuted() async { let control = FakeMuteControl() - let muter = makeController(control) + let clock = ManualClock() + let muter = makeController(control, clock: clock) let first = Task { await muter.recordingStarted(session: 1) } - await waitForGeneration(muter, 1) + #expect(await clock.waitForSleepers(1)) await muter.recordingEnded(session: 1) let second = Task { await muter.recordingStarted(session: 2) } - await waitForGeneration(muter, 3) + #expect(await clock.waitForSleepers(2)) + clock.advance(by: testDelay) await first.value await second.value - await pastTheDelay() #expect(control.state(of: 1) == true) #expect(control.setCalls == [.mute(1)]) From a1eaec9540279cbd18f8763201e7eeedd7944954 Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:41:55 +0200 Subject: [PATCH 08/16] S1-mini: the test of a load interrupted by unload awaits the abandoned load instead of sleeping 20 ms `abandoned` becomes `private(set)` so the test, through `@testable import`, can wait for the first load to finish and try to store its model. Co-Authored-By: Claude Opus 5.5 --- Sources/PladderRefine/S1MiniPolisher.swift | 2 +- Tests/PladderRefineTests/S1MiniTests.swift | 4 +--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/Sources/PladderRefine/S1MiniPolisher.swift b/Sources/PladderRefine/S1MiniPolisher.swift index 40b7c16..c46d394 100644 --- a/Sources/PladderRefine/S1MiniPolisher.swift +++ b/Sources/PladderRefine/S1MiniPolisher.swift @@ -39,7 +39,7 @@ public actor S1MiniPolisher: ReportingRefiner { private var loading: Task<(any PromptCompleter)?, Never>? // llama.cpp ignores cancellation, so a load `unload()` gave up on still reads weights; // the next load waits for it rather than holding two models at once. - private var abandoned: Task<(any PromptCompleter)?, Never>? + private(set) var abandoned: Task<(any PromptCompleter)?, Never>? // Bumped by `unload()`, so a load it interrupted cannot store its model. private var generation = 0 private static let log = Logger(subsystem: "de.dinooo13.pladder", category: "polish") diff --git a/Tests/PladderRefineTests/S1MiniTests.swift b/Tests/PladderRefineTests/S1MiniTests.swift index 9c437b1..ce53931 100644 --- a/Tests/PladderRefineTests/S1MiniTests.swift +++ b/Tests/PladderRefineTests/S1MiniTests.swift @@ -136,9 +136,7 @@ private struct StandInModel: PromptCompleter { _ = await polisher.polish("send it") await polisher.unload() await gate.open() - // Time for the first load to finish and try to store its model; the right answer - // does not depend on it. - try await Task.sleep(for: .milliseconds(20)) + _ = await polisher.abandoned?.value #expect(await polisher.refine("send it") == "Second.") } From 8d2cc708cc2d6d8ee38fe313c91de8d114d25a51 Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:42:01 +0200 Subject: [PATCH 09/16] Tests: the sleeps that must stay real time say why Each is a negative path, where nothing happens that a test could wait for, or a cancellable sleep standing in for a download that the cancel must interrupt. Co-Authored-By: Claude Opus 5.5 --- Tests/PladderCoreTests/EngineLoaderTests.swift | 3 ++- Tests/PladderCoreTests/OrderedTasksTests.swift | 1 + Tests/PladderRefineTests/ModelFilesTests.swift | 1 + Tests/PladderRefineTests/S1MiniTests.swift | 1 + 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Tests/PladderCoreTests/EngineLoaderTests.swift b/Tests/PladderCoreTests/EngineLoaderTests.swift index 62bdf56..f5ac5d7 100644 --- a/Tests/PladderCoreTests/EngineLoaderTests.swift +++ b/Tests/PladderCoreTests/EngineLoaderTests.swift @@ -64,7 +64,8 @@ import Testing await first.open() #expect(await first.waitUntilLoaded()) - // Time for the superseded load task to resume on the main actor. + // Negative path: a superseded load leaves nothing to wait for, so this gives its task + // time to resume on the main actor. try? await Task.sleep(for: .milliseconds(50)) #expect(loader.engine.id == second.id) #expect(loader.status == .downloading(progress: 0.5)) diff --git a/Tests/PladderCoreTests/OrderedTasksTests.swift b/Tests/PladderCoreTests/OrderedTasksTests.swift index 47aa34d..b7e9870 100644 --- a/Tests/PladderCoreTests/OrderedTasksTests.swift +++ b/Tests/PladderCoreTests/OrderedTasksTests.swift @@ -42,6 +42,7 @@ private actor Downloads { } tasks.enqueue { order.append("second") } await gate.untilSomeoneWaits() + // Gives a wrongly started second piece time to show. try? await Task.sleep(for: .milliseconds(10)) #expect(order.all.isEmpty) await gate.open() diff --git a/Tests/PladderRefineTests/ModelFilesTests.swift b/Tests/PladderRefineTests/ModelFilesTests.swift index 498d190..59edee4 100644 --- a/Tests/PladderRefineTests/ModelFilesTests.swift +++ b/Tests/PladderRefineTests/ModelFilesTests.swift @@ -121,6 +121,7 @@ private actor StandInTransport: ModelFileTransport { if call == 1 { try Data("wei".utf8).write(to: staging) await started.open() + // A cancellable sleep stands in for the download; the test is that cancel interrupts it. try await Task.sleep(for: .seconds(3)) } try Data("weights".utf8).write(to: staging) diff --git a/Tests/PladderRefineTests/S1MiniTests.swift b/Tests/PladderRefineTests/S1MiniTests.swift index ce53931..a64f986 100644 --- a/Tests/PladderRefineTests/S1MiniTests.swift +++ b/Tests/PladderRefineTests/S1MiniTests.swift @@ -154,6 +154,7 @@ private struct StandInModel: PromptCompleter { _ = await polisher.polish("send it") await polisher.unload() let second = Task { await polisher.prepare() } + // Negative path: gives a second load that wrongly skipped the wait time to start. try await Task.sleep(for: .milliseconds(30)) #expect(loadsStarted.all == [0]) await gate.open() From cff3dcb654ce6cd8e427cc66e29f00fb2029a600 Mon Sep 17 00:00:00 2001 From: Fabian Meyer <44942030+dinooo13@users.noreply.github.com> Date: Fri, 16 Oct 2026 15:37:56 +0200 Subject: [PATCH 10/16] CLI: bench-coordinator times the coordinator's own share of release-to-paste The real coordinator and processors, with stand-ins of fixed latency for the engine, microphone, paste and polish; what is left of the total is the coordinator's hand-over and its hops between stages. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + Sources/PladderCLI/BenchCoordinator.swift | 326 ++++++++++++++++++++++ Sources/PladderCLI/main.swift | 4 + docs/ARCHITECTURE.md | 1 + docs/BENCHMARKS.md | 52 ++++ 5 files changed, 384 insertions(+) create mode 100644 Sources/PladderCLI/BenchCoordinator.swift diff --git a/CLAUDE.md b/CLAUDE.md index 3b70e85..dba4322 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -28,6 +28,7 @@ swift run -c release pladder-cli