From 1c5e54290c95fef19ad1fe5303035e5f9ffdeb12 Mon Sep 17 00:00:00 2001 From: Bapt Date: Mon, 17 Aug 2026 19:47:02 +0200 Subject: [PATCH 1/7] Guard against correctable misspellings in completions --- .../SuggestionCoordinator+Prediction.swift | 21 ++++-- .../Output/CompletionSeamGuard.swift | 63 ++++++++++++++--- .../Evals/LlamaSuggestionEvalTests.swift | 4 +- .../Output/CompletionSeamGuardTests.swift | 70 +++++++++++++++++++ 4 files changed, 143 insertions(+), 15 deletions(-) diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift index bd436b0c..24c3aab4 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift @@ -608,10 +608,16 @@ extension SuggestionCoordinator { } private static func seamSuppressionReason(for verdict: CompletionSeamGuard.Verdict) -> String { - if case .seamMisspelling = verdict { + switch verdict { + case .seamMisspelling: return "seamMisspelling" + case .leadingWordMisspelling: + return "leadingWordMisspelling" + case .junkPunctuationRun: + return "seamJunkPunctuationRun" + case .allow: + return "unknownSeamGuardSuppression" } - return "seamJunkPunctuationRun" } /// Promotes a generated result to `ready` only when it is still fresh for the current field. @@ -730,13 +736,16 @@ extension SuggestionCoordinator { return } - // Last line of defense before display: junk punctuation runs and mid-word splices that - // misspell the word being typed read as glitches, so showing nothing beats showing them. - // The spell lookup runs at most once per generation and only in the mid-word case. + // Last line of defense before display: junk punctuation runs, mid-word splices, and newly + // started words that the native checker can actually correct read as glitches, so showing + // nothing beats showing them. The leading-word check is intentionally fail-open for names + // and jargon with no correction candidate. let seamVerdict = CompletionSeamGuard.verdict( precedingText: liveContext.precedingText, completion: result.text, - isKnownWord: { !spellChecker.isTypo($0) } + isKnownWord: { !spellChecker.isTypo($0) }, + isTypo: { spellChecker.isTypo($0) }, + bestCorrection: { spellChecker.bestCorrection(for: $0) } ) if seamVerdict != .allow { clearSuggestion() diff --git a/Cotabby/Support/Suggestion/Output/CompletionSeamGuard.swift b/Cotabby/Support/Suggestion/Output/CompletionSeamGuard.swift index d1d520cc..ba45d459 100644 --- a/Cotabby/Support/Suggestion/Output/CompletionSeamGuard.swift +++ b/Cotabby/Support/Suggestion/Output/CompletionSeamGuard.swift @@ -10,16 +10,22 @@ import Foundation /// - **Junk run**: a run of four or more identical punctuation/symbol characters inside the /// completion, unless the run merely extends an identical run the user already has at the caret /// (continuing an existing `----` divider is legitimate). -/// - **Seam misspelling**: only in the mid-word case (caret inside a word, completion starts with -/// word characters), the joined word formed across the seam must be known to the spell checker. -/// Skipped for capitalized words (names and brands are routinely out-of-dictionary), for short -/// joins (under four letters), for words with digits, and for CJK text (no space-delimited word -/// boundaries, and the dictionaries do not cover it). +/// - **Seam misspelling**: in the mid-word case (caret inside a word, completion starts with word +/// characters), the joined word formed across the seam must be known to the spell checker. +/// - **Leading-word misspelling**: when the completion starts a new word, the first generated word +/// is checked only when the caller can both identify it as a typo and offer a correction. This is +/// deliberately narrower than dictionary membership so names, jargon, and model vocabulary still +/// pass through when the native checker has no actionable fix. +/// +/// Both spelling checks skip capitalized words (names and brands are routinely out-of-dictionary), +/// short words (under four letters), words with digits, and CJK text (no space-delimited word +/// boundaries, and the dictionaries do not cover it). nonisolated enum CompletionSeamGuard { enum Verdict: Equatable { case allow case junkPunctuationRun case seamMisspelling(word: String) + case leadingWordMisspelling(word: String) } /// Identical punctuation/symbol characters in a row that count as junk when freshly introduced. @@ -35,12 +41,16 @@ nonisolated enum CompletionSeamGuard { !introducesJunkPunctuationRun(precedingText: precedingText, completion: completion) } - /// `isKnownWord` is injected so the pure rule stays testable and the caller picks the spell - /// checking backend; it is only invoked when the mid-word rule actually applies. + /// The spell-checking closures are injected so the pure rule stays testable and the caller picks + /// the backend. `isKnownWord` covers the mid-word seam; the optional typo/correction pair enables + /// the conservative leading-word check without forcing every existing caller to pay a spell + /// lookup. static func verdict( precedingText: String, completion: String, - isKnownWord: (String) -> Bool + isKnownWord: (String) -> Bool, + isTypo: ((String) -> Bool)? = nil, + bestCorrection: ((String) -> String?)? = nil ) -> Verdict { if introducesJunkPunctuationRun(precedingText: precedingText, completion: completion) { return .junkPunctuationRun @@ -53,6 +63,14 @@ nonisolated enum CompletionSeamGuard { return .seamMisspelling(word: seamWord) } + if let leadingWord = misspellingCandidateLeadingWord( + precedingText: precedingText, + completion: completion + ), let isTypo, isTypo(leadingWord), let bestCorrection, + bestCorrection(leadingWord) != nil { + return .leadingWordMisspelling(word: leadingWord) + } + return .allow } @@ -117,6 +135,35 @@ nonisolated enum CompletionSeamGuard { return seamWord } + /// The first complete word in a completion that begins at a word boundary, or nil when the + /// completion is continuing the word at the caret. Only the leading word is checked: Cotabby + /// accepts suggestions word-by-word, so later words get their own opportunity to pass through + /// this guard after the user accepts the first chunk. + private static func misspellingCandidateLeadingWord( + precedingText: String, + completion: String + ) -> String? { + // A letter immediately following a letter belongs to the mid-word seam rule above. A + // leading space makes it a new word even when the preceding text ends in a letter. + guard precedingText.last?.isLetter != true || completion.first?.isWhitespace == true else { + return nil + } + + let afterWhitespace = completion.drop(while: { $0.isWhitespace }) + guard let firstCharacter = afterWhitespace.first, firstCharacter.isLetter else { + return nil + } + + let word = String(afterWhitespace.prefix(while: { $0.isLetter })) + guard word.count >= minimumSeamWordLength, + firstCharacter.isLowercase, + !word.dropFirst().contains(where: { $0.isUppercase }), + !containsCJK(word) else { + return nil + } + return word + } + private static func trailingRunLength(of text: String, character: Character) -> Int { text.reversed().prefix(while: { $0 == character }).count } diff --git a/CotabbyTests/Evals/LlamaSuggestionEvalTests.swift b/CotabbyTests/Evals/LlamaSuggestionEvalTests.swift index b6eb36eb..3a0ddf7a 100644 --- a/CotabbyTests/Evals/LlamaSuggestionEvalTests.swift +++ b/CotabbyTests/Evals/LlamaSuggestionEvalTests.swift @@ -119,7 +119,9 @@ final class LlamaSuggestionEvalTests: XCTestCase { let verdict = CompletionSeamGuard.verdict( precedingText: evalCase.precedingText, completion: candidate, - isKnownWord: { !spellChecker.isTypo($0) } + isKnownWord: { !spellChecker.isTypo($0) }, + isTypo: { spellChecker.isTypo($0) }, + bestCorrection: { spellChecker.bestCorrection(for: $0) } ) if verdict != .allow { shownText = nil diff --git a/CotabbyTests/Support/Suggestion/Output/CompletionSeamGuardTests.swift b/CotabbyTests/Support/Suggestion/Output/CompletionSeamGuardTests.swift index 675e2a56..2186ce07 100644 --- a/CotabbyTests/Support/Suggestion/Output/CompletionSeamGuardTests.swift +++ b/CotabbyTests/Support/Suggestion/Output/CompletionSeamGuardTests.swift @@ -14,6 +14,14 @@ final class CompletionSeamGuardTests: XCTestCase { private let knowsEverything: (String) -> Bool = { _ in true } private let knowsNothing: (String) -> Bool = { _ in false } + private func typo(_ words: Set) -> (String) -> Bool { + { words.contains($0.lowercased()) } + } + + private func corrections(_ values: [String: String]) -> (String) -> String? { + { values[$0.lowercased()] } + } + // MARK: - Junk punctuation runs func testFreshPunctuationRunIsSuppressed() { @@ -201,4 +209,66 @@ final class CompletionSeamGuardTests: XCTestCase { .allow ) } + + // MARK: - Leading-word misspellings + + func testMisspelledLeadingWordWithCorrectionIsSuppressed() { + XCTAssertEqual( + CompletionSeamGuard.verdict( + precedingText: "Je veux ", + completion: "ecrir plus vite", + isKnownWord: knowsEverything, + isTypo: typo(["ecrir"]), + bestCorrection: corrections(["ecrir": "écrire"]) + ), + .leadingWordMisspelling(word: "ecrir") + ) + } + + func testLeadingWordWithoutCorrectionIsAllowed() { + // An unknown name or domain term should not disappear merely because the native checker has + // no suggestion for it. + XCTAssertEqual( + CompletionSeamGuard.verdict( + precedingText: "Use ", + completion: "cotabby avec soin", + isKnownWord: knowsEverything, + isTypo: typo(["cotabby"]), + bestCorrection: corrections([:]) + ), + .allow + ) + } + + func testCapitalizedLeadingWordIsAllowed() { + XCTAssertEqual( + CompletionSeamGuard.verdict( + precedingText: "Ask ", + completion: "Cotypist about it", + isKnownWord: knowsEverything, + isTypo: typo(["cotypist"]), + bestCorrection: corrections(["cotypist": "copyist"]) + ), + .allow + ) + } + + func testMidWordCompletionDoesNotRunLeadingWordChecks() { + XCTAssertEqual( + CompletionSeamGuard.verdict( + precedingText: "Je veux ecr", + completion: "irregular", + isKnownWord: knowsEverything, + isTypo: { _ in + XCTFail("leading-word typo check must not run for a mid-word completion") + return true + }, + bestCorrection: { _ in + XCTFail("leading-word correction must not run for a mid-word completion") + return "écrire" + } + ), + .allow + ) + } } From e4e54315792951ff23429a2596c5be872e1754f8 Mon Sep 17 00:00:00 2001 From: Bapt Date: Mon, 17 Aug 2026 20:09:09 +0200 Subject: [PATCH 2/7] Fix streamed leading-word spelling guard --- .../SuggestionCoordinator+Prediction.swift | 45 ++++- .../Output/CompletionSeamGuard.swift | 152 ++++++++++++---- .../Streaming/SuggestionStreamingState.swift | 16 ++ .../Evals/LlamaSuggestionEvalTests.swift | 11 +- .../Output/CompletionSeamGuardTests.swift | 166 +++++++++++++----- .../SuggestionStreamingStateTests.swift | 12 ++ 6 files changed, 311 insertions(+), 91 deletions(-) diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift index 24c3aab4..1d3319c1 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift @@ -390,9 +390,9 @@ extension SuggestionCoordinator { return } - // Streaming half of the seam guard: the pure junk-run rule only. The spell-lookup half - // is an XPC and partials drain at token cadence, so it stays on the final apply, which - // authoritatively replaces or suppresses whatever streamed. + // Junk checks remain cheap enough for every partial. The first generated word is buffered + // until its boundary arrives, then its spelling decision is cached for the generation so + // the AppKit/XPC lookup never runs at token cadence. guard CompletionSeamGuard.allowsStreamedPartial( precedingText: liveContext.precedingText, completion: partial.text @@ -400,6 +400,27 @@ extension SuggestionCoordinator { return } + switch suggestionStreamingState.leadingWordGateState { + case .pending: + switch CompletionSeamGuard.streamedLeadingWordVerdict( + precedingText: liveContext.precedingText, + completion: partial.text, + spellingAssessment: { self.completionSpellingAssessment(for: $0) } + ) { + case .wait: + return + case .allow: + suggestionStreamingState.resolveLeadingWordGate(.allowed) + case .suppress: + suggestionStreamingState.resolveLeadingWordGate(.suppressed) + return + } + case .suppressed: + return + case .allowed: + break + } + _ = interactionState.startSession( fullText: partial.text, liveContext: liveContext, @@ -484,6 +505,20 @@ extension SuggestionCoordinator { ?? spellChecker.bestCorrection(for: word) } + /// Collapses native typo detection and correction availability into the seam guard's single + /// spelling contract. Keeping this adapter at the orchestration boundary lets the pure guard + /// express its policy without knowing about `NSSpellChecker` or accepting contradictory hooks. + private func completionSpellingAssessment( + for word: String + ) -> CompletionSeamGuard.SpellingAssessment { + guard spellChecker.isTypo(word) else { + return .known + } + return spellChecker.bestCorrection(for: word) == nil + ? .uncorrectableTypo + : .correctableTypo + } + /// Replaces a completed typo after Space without creating a visible correction session. /// /// Automatic mutation is intentionally limited to a committed word boundary. The shared planner @@ -743,9 +778,7 @@ extension SuggestionCoordinator { let seamVerdict = CompletionSeamGuard.verdict( precedingText: liveContext.precedingText, completion: result.text, - isKnownWord: { !spellChecker.isTypo($0) }, - isTypo: { spellChecker.isTypo($0) }, - bestCorrection: { spellChecker.bestCorrection(for: $0) } + spellingAssessment: { self.completionSpellingAssessment(for: $0) } ) if seamVerdict != .allow { clearSuggestion() diff --git a/Cotabby/Support/Suggestion/Output/CompletionSeamGuard.swift b/Cotabby/Support/Suggestion/Output/CompletionSeamGuard.swift index ba45d459..43650623 100644 --- a/Cotabby/Support/Suggestion/Output/CompletionSeamGuard.swift +++ b/Cotabby/Support/Suggestion/Output/CompletionSeamGuard.swift @@ -1,9 +1,8 @@ import Foundation -/// Post-generation guard for the two classic visible failures at the caret seam: junk punctuation -/// runs ("....", "$$$$") and mid-word splices that turn the word being typed into a misspelling -/// ("gre" + "atful"). Showing nothing beats showing either, and both checks are pure string work -/// on a single short completion, so the guard costs microseconds once per generation. +/// Post-generation guard for visible output failures: junk punctuation runs ("....", "$$$$"), +/// mid-word splices that misspell the joined word ("gre" + "atful"), and correctable misspellings +/// in the first generated word. Showing nothing beats presenting any of these as an insertion. /// /// Both rules are deliberately narrow so they fire rarely: /// @@ -21,6 +20,15 @@ import Foundation /// short words (under four letters), words with digits, and CJK text (no space-delimited word /// boundaries, and the dictionaries do not cover it). nonisolated enum CompletionSeamGuard { + /// One explicit spelling result keeps callers from supplying contradictory combinations such + /// as "typo without a correction callback". The guard only needs to distinguish actionable + /// typos from unknown-but-uncorrectable vocabulary at a leading-word boundary. + enum SpellingAssessment: Equatable { + case known + case uncorrectableTypo + case correctableTypo + } + enum Verdict: Equatable { case allow case junkPunctuationRun @@ -28,29 +36,34 @@ nonisolated enum CompletionSeamGuard { case leadingWordMisspelling(word: String) } + /// Streaming must not expose the first generated word until it is complete enough to assess. + /// Once this resolves to allow or suppress, the coordinator caches it for the generation so + /// `NSSpellChecker` is never called at token cadence. + enum StreamedLeadingWordVerdict: Equatable { + case wait + case allow + case suppress + } + /// Identical punctuation/symbol characters in a row that count as junk when freshly introduced. private static let junkRunLength = 4 /// Joined seam words shorter than this are too ambiguous to judge ("a" + "t"). private static let minimumSeamWordLength = 4 - /// Streaming-path variant: only the pure junk-run rule. Partials drain at token cadence, so - /// the spell-lookup half of the guard (an XPC round trip) stays off that path; the full - /// verdict still gates the final result, which authoritatively replaces whatever streamed. + /// Cheap streaming-path junk rule. The separate leading-word streaming verdict buffers until a + /// complete word exists, then performs and caches exactly one spelling decision. static func allowsStreamedPartial(precedingText: String, completion: String) -> Bool { !introducesJunkPunctuationRun(precedingText: precedingText, completion: completion) } - /// The spell-checking closures are injected so the pure rule stays testable and the caller picks - /// the backend. `isKnownWord` covers the mid-word seam; the optional typo/correction pair enables - /// the conservative leading-word check without forcing every existing caller to pay a spell - /// lookup. + /// The spelling assessment is injected so the pure rule stays testable and the caller picks the + /// backend. A single result describes the whole invariant: mid-word seams reject any typo, + /// while newly generated words reject only correctable typos. static func verdict( precedingText: String, completion: String, - isKnownWord: (String) -> Bool, - isTypo: ((String) -> Bool)? = nil, - bestCorrection: ((String) -> String?)? = nil + spellingAssessment: (String) -> SpellingAssessment ) -> Verdict { if introducesJunkPunctuationRun(precedingText: precedingText, completion: completion) { return .junkPunctuationRun @@ -59,21 +72,41 @@ nonisolated enum CompletionSeamGuard { if let seamWord = misspellingCandidateSeamWord( precedingText: precedingText, completion: completion - ), !isKnownWord(seamWord) { + ), spellingAssessment(seamWord) != .known { return .seamMisspelling(word: seamWord) } - if let leadingWord = misspellingCandidateLeadingWord( + if case let .candidate(leadingWord, _) = leadingWordProbe( precedingText: precedingText, completion: completion - ), let isTypo, isTypo(leadingWord), let bestCorrection, - bestCorrection(leadingWord) != nil { + ), spellingAssessment(leadingWord) == .correctableTypo { return .leadingWordMisspelling(word: leadingWord) } return .allow } + /// Leading-word half of the streaming guard. Incomplete first words remain buffered; testing a + /// prefix such as `ecr` would create false positives and repeating the lookup on every token + /// would put an AppKit/XPC call on the hot streaming path. + static func streamedLeadingWordVerdict( + precedingText: String, + completion: String, + spellingAssessment: (String) -> SpellingAssessment + ) -> StreamedLeadingWordVerdict { + switch leadingWordProbe(precedingText: precedingText, completion: completion) { + case .notApplicable: + return .allow + case .incomplete: + return .wait + case let .candidate(word, isComplete): + guard isComplete else { + return .wait + } + return spellingAssessment(word) == .correctableTypo ? .suppress : .allow + } + } + // MARK: - Junk punctuation runs private static func introducesJunkPunctuationRun( @@ -135,33 +168,78 @@ nonisolated enum CompletionSeamGuard { return seamWord } - /// The first complete word in a completion that begins at a word boundary, or nil when the - /// completion is continuing the word at the caret. Only the leading word is checked: Cotabby - /// accepts suggestions word-by-word, so later words get their own opportunity to pass through - /// this guard after the user accepts the first chunk. - private static func misspellingCandidateLeadingWord( + private enum LeadingWordProbe { + case notApplicable + case incomplete + case candidate(word: String, isComplete: Bool) + } + + /// Finds the first lexical word after boundary whitespace or punctuation. Apostrophes and + /// hyphens between letters remain part of the word (`doesn't`, `state-of-the-art`) so the spell + /// checker sees the same natural-language token the user sees. + private static func leadingWordProbe( precedingText: String, completion: String - ) -> String? { - // A letter immediately following a letter belongs to the mid-word seam rule above. A - // leading space makes it a new word even when the preceding text ends in a letter. - guard precedingText.last?.isLetter != true || completion.first?.isWhitespace == true else { - return nil + ) -> LeadingWordProbe { + guard !completion.isEmpty else { + return .incomplete } - let afterWhitespace = completion.drop(while: { $0.isWhitespace }) - guard let firstCharacter = afterWhitespace.first, firstCharacter.isLetter else { - return nil + guard let wordStart = completion.firstIndex(where: { $0.isLetter }) else { + // Whitespace and opening punctuation may arrive before the first streamed word. Digits + // make the token code/version-like, so the conservative spelling rule does not apply. + return completion.allSatisfy({ $0.isWhitespace || $0.isPunctuation || $0.isSymbol }) + ? .incomplete + : .notApplicable } - let word = String(afterWhitespace.prefix(while: { $0.isLetter })) - guard word.count >= minimumSeamWordLength, - firstCharacter.isLowercase, - !word.dropFirst().contains(where: { $0.isUppercase }), + let boundaryPrefix = completion[..= minimumSeamWordLength else { + return isComplete ? .notApplicable : .incomplete + } + return .candidate(word: word, isComplete: isComplete) + } + + private static func isWordConnector(_ character: Character) -> Bool { + character == "'" || character == "’" || character == "-" } private static func trailingRunLength(of text: String, character: Character) -> Int { diff --git a/Cotabby/Support/Suggestion/Streaming/SuggestionStreamingState.swift b/Cotabby/Support/Suggestion/Streaming/SuggestionStreamingState.swift index 940b4477..8b326e54 100644 --- a/Cotabby/Support/Suggestion/Streaming/SuggestionStreamingState.swift +++ b/Cotabby/Support/Suggestion/Streaming/SuggestionStreamingState.swift @@ -6,6 +6,12 @@ /// monotonically. It does not schedule work or render UI; the coordinator remains responsible for /// those side effects. struct SuggestionStreamingState { + enum LeadingWordGateState: Equatable { + case pending + case allowed + case suppressed + } + /// One partial paired with the replaceable-work identity that produced it. struct PendingPartial { let result: SuggestionResult @@ -15,6 +21,7 @@ struct SuggestionStreamingState { private(set) var pendingPartial: PendingPartial? private(set) var isDrainScheduled = false private(set) var renderedText: String? + private(set) var leadingWordGateState: LeadingWordGateState = .pending /// Starts a new stream without clearing an already-enqueued drain callback. /// @@ -24,6 +31,7 @@ struct SuggestionStreamingState { mutating func beginGeneration() { renderedText = nil pendingPartial = nil + leadingWordGateState = .pending } /// Stores the newest partial and returns whether the coordinator must schedule a drain. @@ -58,6 +66,13 @@ struct SuggestionStreamingState { renderedText = text } + /// Caches the first-word spelling decision so an allowed stream does not repeat an AppKit/XPC + /// lookup for every subsequent token. Suppression is likewise terminal for this generation. + mutating func resolveLeadingWordGate(_ state: LeadingWordGateState) { + precondition(state != .pending, "The leading-word gate can only resolve to a terminal state") + leadingWordGateState = state + } + /// Drops state associated with a torn-down suggestion session. /// /// As with `beginGeneration`, a scheduled callback remains responsible for clearing the drain @@ -65,5 +80,6 @@ struct SuggestionStreamingState { mutating func clearSession() { renderedText = nil pendingPartial = nil + leadingWordGateState = .pending } } diff --git a/CotabbyTests/Evals/LlamaSuggestionEvalTests.swift b/CotabbyTests/Evals/LlamaSuggestionEvalTests.swift index 3a0ddf7a..c95ad8a2 100644 --- a/CotabbyTests/Evals/LlamaSuggestionEvalTests.swift +++ b/CotabbyTests/Evals/LlamaSuggestionEvalTests.swift @@ -119,9 +119,14 @@ final class LlamaSuggestionEvalTests: XCTestCase { let verdict = CompletionSeamGuard.verdict( precedingText: evalCase.precedingText, completion: candidate, - isKnownWord: { !spellChecker.isTypo($0) }, - isTypo: { spellChecker.isTypo($0) }, - bestCorrection: { spellChecker.bestCorrection(for: $0) } + spellingAssessment: { word in + guard spellChecker.isTypo(word) else { + return .known + } + return spellChecker.bestCorrection(for: word) == nil + ? .uncorrectableTypo + : .correctableTypo + } ) if verdict != .allow { shownText = nil diff --git a/CotabbyTests/Support/Suggestion/Output/CompletionSeamGuardTests.swift b/CotabbyTests/Support/Suggestion/Output/CompletionSeamGuardTests.swift index 2186ce07..4fbc5d62 100644 --- a/CotabbyTests/Support/Suggestion/Output/CompletionSeamGuardTests.swift +++ b/CotabbyTests/Support/Suggestion/Output/CompletionSeamGuardTests.swift @@ -6,20 +6,16 @@ import XCTest /// continuations that surround them. Every guard must fire rarely; most of these tests are /// allow-cases for exactly that reason. final class CompletionSeamGuardTests: XCTestCase { - /// A stub dictionary: the listed words are known, everything else is a misspelling. - private func knowing(_ words: Set) -> (String) -> Bool { - { words.contains($0.lowercased()) } + /// A stub dictionary: the listed words are known, everything else is an uncorrectable typo. + private func knowing( + _ words: Set + ) -> (String) -> CompletionSeamGuard.SpellingAssessment { + { words.contains($0.lowercased()) ? .known : .uncorrectableTypo } } - private let knowsEverything: (String) -> Bool = { _ in true } - private let knowsNothing: (String) -> Bool = { _ in false } - - private func typo(_ words: Set) -> (String) -> Bool { - { words.contains($0.lowercased()) } - } - - private func corrections(_ values: [String: String]) -> (String) -> String? { - { values[$0.lowercased()] } + private let knowsEverything: (String) -> CompletionSeamGuard.SpellingAssessment = { _ in .known } + private let knowsNothing: (String) -> CompletionSeamGuard.SpellingAssessment = { + _ in .uncorrectableTypo } // MARK: - Junk punctuation runs @@ -29,7 +25,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "Wait", completion: " what....", - isKnownWord: knowsEverything + spellingAssessment: knowsEverything ), .junkPunctuationRun ) @@ -40,7 +36,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "Price: ", completion: "$$$$", - isKnownWord: knowsEverything + spellingAssessment: knowsEverything ), .junkPunctuationRun ) @@ -52,7 +48,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "Well", completion: "... maybe", - isKnownWord: knowsEverything + spellingAssessment: knowsEverything ), .allow ) @@ -65,7 +61,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "Hello.", completion: "....", - isKnownWord: knowsEverything + spellingAssessment: knowsEverything ), .junkPunctuationRun ) @@ -88,7 +84,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "----", completion: "------", - isKnownWord: knowsEverything + spellingAssessment: knowsEverything ), .allow ) @@ -99,7 +95,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "----", completion: " section ======", - isKnownWord: knowsEverything + spellingAssessment: knowsEverything ), .junkPunctuationRun ) @@ -110,7 +106,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "That is so", completion: " coooool", - isKnownWord: knowsEverything + spellingAssessment: knowsEverything ), .allow ) @@ -123,7 +119,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "I am so gre", completion: "atful for this", - isKnownWord: knowing(["great", "grateful"]) + spellingAssessment: knowing(["great", "grateful"]) ), .seamMisspelling(word: "greatful") ) @@ -134,7 +130,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "I am so gre", completion: "at to hear it", - isKnownWord: knowing(["great"]) + spellingAssessment: knowing(["great"]) ), .allow ) @@ -146,7 +142,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "I am so ", completion: "greatful", - isKnownWord: knowsNothing + spellingAssessment: knowsNothing ), .allow ) @@ -158,7 +154,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "Ask Cota", completion: "bby about it", - isKnownWord: knowsNothing + spellingAssessment: knowsNothing ), .allow ) @@ -169,7 +165,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "a", completion: "t the office", - isKnownWord: knowsNothing + spellingAssessment: knowsNothing ), .allow ) @@ -182,7 +178,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "version 2", completion: "024 release", - isKnownWord: knowsNothing + spellingAssessment: knowsNothing ), .allow ) @@ -193,7 +189,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "これはとても良", completion: "い天気ですね", - isKnownWord: knowsNothing + spellingAssessment: knowsNothing ), .allow ) @@ -204,7 +200,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "Thanks again for your help", completion: " with the move last weekend.", - isKnownWord: knowing(["with"]) + spellingAssessment: knowing(["with"]) ), .allow ) @@ -217,9 +213,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "Je veux ", completion: "ecrir plus vite", - isKnownWord: knowsEverything, - isTypo: typo(["ecrir"]), - bestCorrection: corrections(["ecrir": "écrire"]) + spellingAssessment: { $0 == "ecrir" ? .correctableTypo : .known } ), .leadingWordMisspelling(word: "ecrir") ) @@ -232,9 +226,7 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "Use ", completion: "cotabby avec soin", - isKnownWord: knowsEverything, - isTypo: typo(["cotabby"]), - bestCorrection: corrections([:]) + spellingAssessment: { $0 == "cotabby" ? .uncorrectableTypo : .known } ), .allow ) @@ -245,30 +237,114 @@ final class CompletionSeamGuardTests: XCTestCase { CompletionSeamGuard.verdict( precedingText: "Ask ", completion: "Cotypist about it", - isKnownWord: knowsEverything, - isTypo: typo(["cotypist"]), - bestCorrection: corrections(["cotypist": "copyist"]) + spellingAssessment: { _ in + XCTFail("capitalized leading words must not reach the spell checker") + return .correctableTypo + } ), .allow ) } - func testMidWordCompletionDoesNotRunLeadingWordChecks() { + func testMidWordCompletionOnlyAssessesTheJoinedSeamWord() { XCTAssertEqual( CompletionSeamGuard.verdict( precedingText: "Je veux ecr", completion: "irregular", - isKnownWord: knowsEverything, - isTypo: { _ in - XCTFail("leading-word typo check must not run for a mid-word completion") - return true - }, - bestCorrection: { _ in - XCTFail("leading-word correction must not run for a mid-word completion") - return "écrire" + spellingAssessment: { word in + XCTAssertEqual(word, "ecrirregular") + return .known } ), .allow ) } + + func testQuotedLeadingWordIsSuppressed() { + XCTAssertEqual( + CompletionSeamGuard.verdict( + precedingText: "Il répond ", + completion: "“ecrir” plus vite", + spellingAssessment: { $0 == "ecrir" ? .correctableTypo : .known } + ), + .leadingWordMisspelling(word: "ecrir") + ) + } + + func testParenthesizedLeadingWordAfterTextIsSuppressed() { + XCTAssertEqual( + CompletionSeamGuard.verdict( + precedingText: "Il répond", + completion: ": (ecrir) plus vite", + spellingAssessment: { $0 == "ecrir" ? .correctableTypo : .known } + ), + .leadingWordMisspelling(word: "ecrir") + ) + } + + func testContractionIsAssessedAsOneWord() { + XCTAssertEqual( + CompletionSeamGuard.verdict( + precedingText: "It ", + completion: "doesn't matter", + spellingAssessment: { word in + XCTAssertEqual(word, "doesn't") + return .known + } + ), + .allow + ) + } + + // MARK: - Streamed leading words + + func testStreamedLeadingWordWaitsUntilItsBoundaryArrives() { + XCTAssertEqual( + CompletionSeamGuard.streamedLeadingWordVerdict( + precedingText: "Je veux ", + completion: "ecrir", + spellingAssessment: { _ in + XCTFail("an incomplete streamed word must not reach the spell checker") + return .known + } + ), + .wait + ) + } + + func testStreamedContractionWaitsAfterADanglingApostrophe() { + XCTAssertEqual( + CompletionSeamGuard.streamedLeadingWordVerdict( + precedingText: "It ", + completion: "does'", + spellingAssessment: { _ in + XCTFail("a dangling apostrophe may still continue the streamed word") + return .known + } + ), + .wait + ) + } + + func testStreamedCorrectableLeadingWordIsSuppressedAtItsBoundary() { + XCTAssertEqual( + CompletionSeamGuard.streamedLeadingWordVerdict( + precedingText: "Je veux ", + completion: "ecrir ", + spellingAssessment: { $0 == "ecrir" ? .correctableTypo : .known } + ), + .suppress + ) + } + + func testStreamedKnownLeadingWordIsAllowedAtItsBoundary() { + XCTAssertEqual( + CompletionSeamGuard.streamedLeadingWordVerdict( + precedingText: "Je veux ", + completion: "écrire ", + spellingAssessment: { $0 == "écrire" ? .known : .correctableTypo } + ), + .allow + ) + } } diff --git a/CotabbyTests/Support/Suggestion/Streaming/SuggestionStreamingStateTests.swift b/CotabbyTests/Support/Suggestion/Streaming/SuggestionStreamingStateTests.swift index b970aa8f..c002f089 100644 --- a/CotabbyTests/Support/Suggestion/Streaming/SuggestionStreamingStateTests.swift +++ b/CotabbyTests/Support/Suggestion/Streaming/SuggestionStreamingStateTests.swift @@ -25,10 +25,12 @@ final class SuggestionStreamingStateTests: XCTestCase { XCTAssertTrue(state.enqueue(result(text: " old"), workID: 1)) state.recordRendered(" old") + state.resolveLeadingWordGate(.allowed) state.beginGeneration() XCTAssertNil(state.renderedText) XCTAssertNil(state.pendingPartial) + XCTAssertEqual(state.leadingWordGateState, .pending) XCTAssertTrue(state.isDrainScheduled) XCTAssertFalse(state.enqueue(result(text: " new"), workID: 2)) @@ -41,11 +43,13 @@ final class SuggestionStreamingStateTests: XCTestCase { var state = SuggestionStreamingState() state.enqueue(result(text: " pending"), workID: 4) state.recordRendered(" pending") + state.resolveLeadingWordGate(.suppressed) state.clearSession() XCTAssertNil(state.renderedText) XCTAssertNil(state.pendingPartial) + XCTAssertEqual(state.leadingWordGateState, .pending) XCTAssertTrue(state.isDrainScheduled) XCTAssertNil(state.drain()) XCTAssertFalse(state.isDrainScheduled) @@ -61,6 +65,14 @@ final class SuggestionStreamingStateTests: XCTestCase { XCTAssertFalse(state.canRender(" wild")) } + func test_leadingWordGateCachesATerminalDecisionForTheGeneration() { + var state = SuggestionStreamingState() + + XCTAssertEqual(state.leadingWordGateState, .pending) + state.resolveLeadingWordGate(.allowed) + XCTAssertEqual(state.leadingWordGateState, .allowed) + } + private func result(text: String) -> SuggestionResult { SuggestionResult( generation: 7, From d57ca72086d399e030befa827834ccd1850125fe Mon Sep 17 00:00:00 2001 From: Bapt Date: Mon, 17 Aug 2026 20:24:33 +0200 Subject: [PATCH 3/7] Exempt numeric completion tokens --- .../Output/CompletionSeamGuard.swift | 10 +++++ .../Output/CompletionSeamGuardTests.swift | 41 +++++++++++++++++++ .../SuggestionStreamingStateTests.swift | 1 + 3 files changed, 52 insertions(+) diff --git a/Cotabby/Support/Suggestion/Output/CompletionSeamGuard.swift b/Cotabby/Support/Suggestion/Output/CompletionSeamGuard.swift index 43650623..5b238e3f 100644 --- a/Cotabby/Support/Suggestion/Output/CompletionSeamGuard.swift +++ b/Cotabby/Support/Suggestion/Output/CompletionSeamGuard.swift @@ -198,6 +198,15 @@ nonisolated enum CompletionSeamGuard { return .notApplicable } + // A digit anywhere in the same whitespace-delimited token makes it code/version-like. Scan + // the whole token before extracting its leading letter run so `ecrir2` is not misread as the + // correctable natural-language word `ecrir`. + let tokenEnd = completion[wordStart...].firstIndex(where: { $0.isWhitespace }) + ?? completion.endIndex + guard !completion[wordStart.. Bool { character == "'" || character == "’" || character == "-" } diff --git a/CotabbyTests/Support/Suggestion/Output/CompletionSeamGuardTests.swift b/CotabbyTests/Support/Suggestion/Output/CompletionSeamGuardTests.swift index 4fbc5d62..20883f79 100644 --- a/CotabbyTests/Support/Suggestion/Output/CompletionSeamGuardTests.swift +++ b/CotabbyTests/Support/Suggestion/Output/CompletionSeamGuardTests.swift @@ -208,6 +208,7 @@ final class CompletionSeamGuardTests: XCTestCase { // MARK: - Leading-word misspellings + /// A lowercase generated typo is hidden only when the checker has an actionable correction. func testMisspelledLeadingWordWithCorrectionIsSuppressed() { XCTAssertEqual( CompletionSeamGuard.verdict( @@ -219,6 +220,7 @@ final class CompletionSeamGuardTests: XCTestCase { ) } + /// Unknown vocabulary remains visible when the checker cannot offer a replacement. func testLeadingWordWithoutCorrectionIsAllowed() { // An unknown name or domain term should not disappear merely because the native checker has // no suggestion for it. @@ -232,6 +234,7 @@ final class CompletionSeamGuardTests: XCTestCase { ) } + /// Capitalized names bypass spelling entirely to avoid dictionary-driven false positives. func testCapitalizedLeadingWordIsAllowed() { XCTAssertEqual( CompletionSeamGuard.verdict( @@ -246,6 +249,7 @@ final class CompletionSeamGuardTests: XCTestCase { ) } + /// Mid-word completions assess the joined word rather than reclassifying the generated suffix. func testMidWordCompletionOnlyAssessesTheJoinedSeamWord() { XCTAssertEqual( CompletionSeamGuard.verdict( @@ -260,6 +264,7 @@ final class CompletionSeamGuardTests: XCTestCase { ) } + /// Opening quotation marks still leave the following letters at a valid word boundary. func testQuotedLeadingWordIsSuppressed() { XCTAssertEqual( CompletionSeamGuard.verdict( @@ -271,6 +276,7 @@ final class CompletionSeamGuardTests: XCTestCase { ) } + /// Punctuation introduced after existing text cannot hide the first generated typo. func testParenthesizedLeadingWordAfterTextIsSuppressed() { XCTAssertEqual( CompletionSeamGuard.verdict( @@ -282,6 +288,7 @@ final class CompletionSeamGuardTests: XCTestCase { ) } + /// Interior apostrophes stay attached so a contraction is never checked as a truncated stem. func testContractionIsAssessedAsOneWord() { XCTAssertEqual( CompletionSeamGuard.verdict( @@ -296,8 +303,24 @@ final class CompletionSeamGuardTests: XCTestCase { ) } + /// A digit makes the whole leading token code/version-like, including its letter prefix. + func testLetterAndDigitLeadingTokenIsAllowedWithoutSpellLookup() { + XCTAssertEqual( + CompletionSeamGuard.verdict( + precedingText: "Use ", + completion: "ecrir2 here", + spellingAssessment: { _ in + XCTFail("letter-and-digit tokens must bypass spelling") + return .correctableTypo + } + ), + .allow + ) + } + // MARK: - Streamed leading words + /// Streaming buffers a lowercase prefix because checking it before its boundary is unreliable. func testStreamedLeadingWordWaitsUntilItsBoundaryArrives() { XCTAssertEqual( CompletionSeamGuard.streamedLeadingWordVerdict( @@ -312,6 +335,7 @@ final class CompletionSeamGuardTests: XCTestCase { ) } + /// A trailing apostrophe may still join the next letters, so it cannot finalize the word. func testStreamedContractionWaitsAfterADanglingApostrophe() { XCTAssertEqual( CompletionSeamGuard.streamedLeadingWordVerdict( @@ -326,6 +350,7 @@ final class CompletionSeamGuardTests: XCTestCase { ) } + /// Once its boundary arrives, a correctable streamed typo is suppressed before presentation. func testStreamedCorrectableLeadingWordIsSuppressedAtItsBoundary() { XCTAssertEqual( CompletionSeamGuard.streamedLeadingWordVerdict( @@ -337,6 +362,7 @@ final class CompletionSeamGuardTests: XCTestCase { ) } + /// A known streamed word becomes presentable as soon as its boundary makes it complete. func testStreamedKnownLeadingWordIsAllowedAtItsBoundary() { XCTAssertEqual( CompletionSeamGuard.streamedLeadingWordVerdict( @@ -347,4 +373,19 @@ final class CompletionSeamGuardTests: XCTestCase { .allow ) } + + /// Streaming also exempts a completed letter-and-digit token without consulting spelling. + func testStreamedLetterAndDigitLeadingTokenIsAllowedWithoutSpellLookup() { + XCTAssertEqual( + CompletionSeamGuard.streamedLeadingWordVerdict( + precedingText: "Use ", + completion: "ecrir2 ", + spellingAssessment: { _ in + XCTFail("letter-and-digit tokens must bypass streamed spelling") + return .correctableTypo + } + ), + .allow + ) + } } diff --git a/CotabbyTests/Support/Suggestion/Streaming/SuggestionStreamingStateTests.swift b/CotabbyTests/Support/Suggestion/Streaming/SuggestionStreamingStateTests.swift index c002f089..0e7f4760 100644 --- a/CotabbyTests/Support/Suggestion/Streaming/SuggestionStreamingStateTests.swift +++ b/CotabbyTests/Support/Suggestion/Streaming/SuggestionStreamingStateTests.swift @@ -65,6 +65,7 @@ final class SuggestionStreamingStateTests: XCTestCase { XCTAssertFalse(state.canRender(" wild")) } + /// A terminal first-word verdict remains reusable until the next generation resets the state. func test_leadingWordGateCachesATerminalDecisionForTheGeneration() { var state = SuggestionStreamingState() From ce39aa6d12998d952253e74fb0046cbade2739e2 Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:48:35 -0400 Subject: [PATCH 4/7] Keep applyStreamedPartial under the SwiftLint complexity cap The nested leading-word gate switch pushed applyStreamedPartial to a cyclomatic complexity of 11, failing the strict lint gate. Move the gate into passesStreamedLeadingWordGate, mirroring how handleTypoGate keeps generateFromCurrentFocus within budget. Behavior is unchanged: a pending gate consults the seam guard once, a settled gate answers without another spell lookup. Also fix the CompletionSeamGuard header, which still said "Both rules" after the leading-word rule made three. Co-Authored-By: Claude Fable 5.1 --- .../SuggestionCoordinator+Prediction.swift | 52 ++++++++++++------- .../Output/CompletionSeamGuard.swift | 2 +- 2 files changed, 34 insertions(+), 20 deletions(-) diff --git a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift index 1d3319c1..8e613f3f 100644 --- a/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift @@ -399,26 +399,11 @@ extension SuggestionCoordinator { ) else { return } - - switch suggestionStreamingState.leadingWordGateState { - case .pending: - switch CompletionSeamGuard.streamedLeadingWordVerdict( - precedingText: liveContext.precedingText, - completion: partial.text, - spellingAssessment: { self.completionSpellingAssessment(for: $0) } - ) { - case .wait: - return - case .allow: - suggestionStreamingState.resolveLeadingWordGate(.allowed) - case .suppress: - suggestionStreamingState.resolveLeadingWordGate(.suppressed) - return - } - case .suppressed: + guard passesStreamedLeadingWordGate( + precedingText: liveContext.precedingText, + completion: partial.text + ) else { return - case .allowed: - break } _ = interactionState.startSession( @@ -435,6 +420,35 @@ extension SuggestionCoordinator { ) } + /// Resolves the generation-scoped leading-word gate for one streamed partial and returns whether + /// the partial may render. A pending gate consults the seam guard, which either keeps buffering + /// (`wait`) or settles the gate for the rest of this generation; a settled gate answers without + /// touching the spell checker again. Kept separate so `applyStreamedPartial` stays within the + /// project's cyclomatic-complexity budget. + private func passesStreamedLeadingWordGate(precedingText: String, completion: String) -> Bool { + switch suggestionStreamingState.leadingWordGateState { + case .allowed: + return true + case .suppressed: + return false + case .pending: + switch CompletionSeamGuard.streamedLeadingWordVerdict( + precedingText: precedingText, + completion: completion, + spellingAssessment: { self.completionSpellingAssessment(for: $0) } + ) { + case .wait: + return false + case .allow: + suggestionStreamingState.resolveLeadingWordGate(.allowed) + return true + case .suppress: + suggestionStreamingState.resolveLeadingWordGate(.suppressed) + return false + } + } + } + /// Runs the typo gate for the current word. Returns `true` when it handled the cycle by suppressing, /// offering, or applying a correction; `false` proceeds with a normal continuation. Kept separate /// so `generateFromCurrentFocus` stays within the project's cyclomatic-complexity budget. diff --git a/Cotabby/Support/Suggestion/Output/CompletionSeamGuard.swift b/Cotabby/Support/Suggestion/Output/CompletionSeamGuard.swift index 5b238e3f..7c4fa261 100644 --- a/Cotabby/Support/Suggestion/Output/CompletionSeamGuard.swift +++ b/Cotabby/Support/Suggestion/Output/CompletionSeamGuard.swift @@ -4,7 +4,7 @@ import Foundation /// mid-word splices that misspell the joined word ("gre" + "atful"), and correctable misspellings /// in the first generated word. Showing nothing beats presenting any of these as an insertion. /// -/// Both rules are deliberately narrow so they fire rarely: +/// All three rules are deliberately narrow so they fire rarely: /// /// - **Junk run**: a run of four or more identical punctuation/symbol characters inside the /// completion, unless the run merely extends an identical run the user already has at the caret From cd3e7dfcf29cc11754c834962ad94cf64c49b5a2 Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Thu, 3 Sep 2026 18:48:37 -0400 Subject: [PATCH 5/7] Pin down leading-word guard boundary semantics in tests Cover the cases that were easy to misread while reviewing the guard: the final verdict suppresses a correctable last word with no trailing boundary (only the streamed verdict waits for one), a connector continuing the caret word stays in the mid-word rule, hyphenated tokens are assessed whole, and words under four letters skip the lookup entirely. Co-Authored-By: Claude Fable 5.1 --- .../Output/CompletionSeamGuardTests.swift | 59 +++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/CotabbyTests/Support/Suggestion/Output/CompletionSeamGuardTests.swift b/CotabbyTests/Support/Suggestion/Output/CompletionSeamGuardTests.swift index 20883f79..fb672bbd 100644 --- a/CotabbyTests/Support/Suggestion/Output/CompletionSeamGuardTests.swift +++ b/CotabbyTests/Support/Suggestion/Output/CompletionSeamGuardTests.swift @@ -318,6 +318,65 @@ final class CompletionSeamGuardTests: XCTestCase { ) } + /// The final result is complete by definition, so a correctable last word needs no trailing + /// boundary to be suppressed; only the streaming verdict waits for one. + func testFinalVerdictSuppressesCorrectableLeadingWordWithoutTrailingBoundary() { + XCTAssertEqual( + CompletionSeamGuard.verdict( + precedingText: "Je veux ", + completion: "ecrir", + spellingAssessment: { $0 == "ecrir" ? .correctableTypo : .known } + ), + .leadingWordMisspelling(word: "ecrir") + ) + } + + /// A connector continuing the caret word ("don" + "'t") is the mid-word case, not a new word. + func testConnectorContinuationOfTheCaretWordSkipsTheLeadingWordRule() { + XCTAssertEqual( + CompletionSeamGuard.verdict( + precedingText: "I don", + completion: "'t know", + spellingAssessment: { _ in + XCTFail("a connector continuation must not be assessed as a leading word") + return .correctableTypo + } + ), + .allow + ) + } + + /// Interior hyphens bind the token, so "state-of-the-art" is assessed once, as the user sees it. + func testHyphenatedLeadingWordIsAssessedAsOneToken() { + XCTAssertEqual( + CompletionSeamGuard.verdict( + precedingText: "A ", + completion: "state-of-the-art tool", + spellingAssessment: { word in + XCTAssertEqual(word, "state-of-the-art") + return .known + } + ), + .allow + ) + } + + /// Words under four letters are too ambiguous to judge, so even a classic typo like "teh" + /// passes without a lookup. This documents a deliberate limit, not an oversight. + func testShortLeadingWordIsAllowedWithoutSpellLookup() { + XCTAssertEqual( + CompletionSeamGuard.verdict( + precedingText: "Send ", + completion: "teh report", + spellingAssessment: { _ in + XCTFail("short leading words must bypass spelling") + return .correctableTypo + } + ), + .allow + ) + } + // MARK: - Streamed leading words /// Streaming buffers a lowercase prefix because checking it before its boundary is unreliable. From 7326c1bdee3d2ffe1ed497925918bded7f50ad48 Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:55:58 -0400 Subject: [PATCH 6/7] Fix CI on main: SwiftLint 0.65.1 line length and aria2 stderr race Two main-branch failures surfaced on every open PR once the macos-latest image moved from SwiftLint 0.65.0 to 0.65.1. SwiftLint 0.65.1 fixed `ignores_urls` so that property accesses whose member names are valid top-level domains (`.app`, `.info`) no longer make a line count as a URL. That exposed the 144-character launch log line in AppDelegate, which 0.65.0 had silently skipped. Wrap it. Aria2DownloadService read its stderr buffer inside the termination handler while the readability handler, which runs on its own queue, could still be holding the process's final write. On the slower runner the buffer was empty and the error degraded to "Process terminated with exit code 7", failing test_downloadSurfacesProcessExitAndStderr. Drain both pipes to EOF in the termination handler before building the result; that cannot block because the child's write ends closed with it. The parsing is shared between the handlers and the drain so bytes are treated the same either way. Co-Authored-By: Claude Fable 5.1 --- Cotabby/App/Core/AppDelegate.swift | 4 ++- .../Aria2DownloadService.swift | 25 +++++++++++++++---- 2 files changed, 23 insertions(+), 6 deletions(-) diff --git a/Cotabby/App/Core/AppDelegate.swift b/Cotabby/App/Core/AppDelegate.swift index 4e755b4a..8e827f00 100644 --- a/Cotabby/App/Core/AppDelegate.swift +++ b/Cotabby/App/Core/AppDelegate.swift @@ -138,7 +138,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate { let version = Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "?" let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "?" - CotabbyLogger.app.info("Cotabby \(version) (build \(build)) launching on macOS \(ProcessInfo.processInfo.operatingSystemVersionString)") + CotabbyLogger.app.info( + "Cotabby \(version) (build \(build)) launching on macOS \(ProcessInfo.processInfo.operatingSystemVersionString)" + ) applyLaunchAtLoginDefaultIfNeeded() startRuntimeIfPreferredEngineRequiresIt() focusModel.start() diff --git a/Cotabby/Services/ModelManagement/Aria2DownloadService.swift b/Cotabby/Services/ModelManagement/Aria2DownloadService.swift index cd83b245..c97c8ce9 100644 --- a/Cotabby/Services/ModelManagement/Aria2DownloadService.swift +++ b/Cotabby/Services/ModelManagement/Aria2DownloadService.swift @@ -89,8 +89,9 @@ nonisolated final class Aria2DownloadService: @unchecked Sendable { process.standardOutput = outputPipe process.standardError = errorPipe - outputPipe.fileHandleForReading.readabilityHandler = { [progressHandler] handle in - let data = handle.availableData + // Shared by the readability handlers and the termination drain below, so bytes that arrive + // either way are parsed identically. + let consumeOutput: @Sendable (Data) -> Void = { [progressHandler] data in guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return } @@ -101,20 +102,34 @@ nonisolated final class Aria2DownloadService: @unchecked Sendable { } } } - - errorPipe.fileHandleForReading.readabilityHandler = { handle in - let data = handle.availableData + let consumeError: @Sendable (Data) -> Void = { data in guard !data.isEmpty, let text = String(data: data, encoding: .utf8) else { return } errorBuffer.append(text) } + outputPipe.fileHandleForReading.readabilityHandler = { handle in + consumeOutput(handle.availableData) + } + + errorPipe.fileHandleForReading.readabilityHandler = { handle in + consumeError(handle.availableData) + } + return try await withCheckedThrowingContinuation { continuation in process.terminationHandler = { [processState] terminatedProcess in outputPipe.fileHandleForReading.readabilityHandler = nil errorPipe.fileHandleForReading.readabilityHandler = nil + // The readability handlers run on their own queue, so a process that exits right + // after its last write can terminate before those bytes were consumed; on a slow + // machine that turned "simulated aria failure" into a bare exit code. Draining to + // EOF here cannot block: the child's write ends closed when it exited, and the + // parent's copies were closed at launch. + consumeOutput(outputPipe.fileHandleForReading.readDataToEndOfFile()) + consumeError(errorPipe.fileHandleForReading.readDataToEndOfFile()) + continuation.resume( with: Self.completionResult( status: terminatedProcess.terminationStatus, From e29ee71dcf2dcc3bc0af92258425020e2b2179cd Mon Sep 17 00:00:00 2001 From: akramj13 <125495000+akramj13@users.noreply.github.com> Date: Fri, 4 Sep 2026 20:09:06 -0400 Subject: [PATCH 7/7] Serialize aria2 pipe reads with the termination drain Detaching a readability handler does not wait for a callback that is already running, so one could have pulled the final stderr bytes with availableData and not yet appended them when the termination handler drained the pipe and read the buffer. Run every pipe read, callbacks and drain alike, on one serial queue and snapshot the message on that same queue: an in-flight callback finishes appending before the drain starts, and a late callback finds the pipe at EOF. Co-Authored-By: Claude Fable 5.1 --- .../Aria2DownloadService.swift | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/Cotabby/Services/ModelManagement/Aria2DownloadService.swift b/Cotabby/Services/ModelManagement/Aria2DownloadService.swift index c97c8ce9..2b3db582 100644 --- a/Cotabby/Services/ModelManagement/Aria2DownloadService.swift +++ b/Cotabby/Services/ModelManagement/Aria2DownloadService.swift @@ -109,12 +109,20 @@ nonisolated final class Aria2DownloadService: @unchecked Sendable { errorBuffer.append(text) } + // Every read from either pipe, whether a readability callback or the termination drain + // below, runs on this one serial queue. That is what makes the drain complete: a callback + // that already pulled bytes with `availableData` finishes appending them before the drain + // starts, and a callback that lands after the drain finds the pipe at EOF. Detaching a + // handler alone does not wait for an in-flight callback, so without the queue the final + // stderr write could still be lost on a slow machine. + let pipeReadQueue = DispatchQueue(label: "com.cotabby.aria2.pipe-read") + outputPipe.fileHandleForReading.readabilityHandler = { handle in - consumeOutput(handle.availableData) + pipeReadQueue.sync { consumeOutput(handle.availableData) } } errorPipe.fileHandleForReading.readabilityHandler = { handle in - consumeError(handle.availableData) + pipeReadQueue.sync { consumeError(handle.availableData) } } return try await withCheckedThrowingContinuation { continuation in @@ -122,19 +130,23 @@ nonisolated final class Aria2DownloadService: @unchecked Sendable { outputPipe.fileHandleForReading.readabilityHandler = nil errorPipe.fileHandleForReading.readabilityHandler = nil - // The readability handlers run on their own queue, so a process that exits right - // after its last write can terminate before those bytes were consumed; on a slow - // machine that turned "simulated aria failure" into a bare exit code. Draining to - // EOF here cannot block: the child's write ends closed when it exited, and the - // parent's copies were closed at launch. - consumeOutput(outputPipe.fileHandleForReading.readDataToEndOfFile()) - consumeError(errorPipe.fileHandleForReading.readDataToEndOfFile()) + // A process that exits right after its last write can terminate before the + // readability callbacks consumed those bytes; on a slow machine that turned + // "simulated aria failure" into a bare exit code. Drain both pipes to EOF on the + // read queue, then snapshot the message on the same queue so nothing appends after + // the snapshot. The drain cannot block: the child's write ends closed when it + // exited, and the parent's copies were closed at launch. + let errorMessage = pipeReadQueue.sync { + consumeOutput(outputPipe.fileHandleForReading.readDataToEndOfFile()) + consumeError(errorPipe.fileHandleForReading.readDataToEndOfFile()) + return errorBuffer.value + } continuation.resume( with: Self.completionResult( status: terminatedProcess.terminationStatus, requestedOutcome: processState.finish(), - errorMessage: errorBuffer.value, + errorMessage: errorMessage, targetURL: targetURL ) )