From 1c5e54290c95fef19ad1fe5303035e5f9ffdeb12 Mon Sep 17 00:00:00 2001 From: Bapt Date: Mon, 17 Aug 2026 19:47:02 +0200 Subject: [PATCH 1/3] 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/3] 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/3] 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()