From 81a47d02d66abbc964774f3e48c739b99eea7df1 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 1/2] 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 89eea88fa4c0d40efe46953726c203dab130d513 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 2/2] 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[@]}"