Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -374,7 +374,7 @@
/// normalizer rewrites never shrink visible ghost text, and the materialize check stops
/// partials the moment the field text moves on without a keystroke (a keystroke already
/// bumped the work id before this runs).
private func applyStreamedPartial(_ partial: SuggestionResult, workID: UInt64) {

Check failure on line 377 in Cotabby/App/Coordinators/Suggestion/SuggestionCoordinator+Prediction.swift

View workflow job for this annotation

GitHub Actions / SwiftLint

Function should have complexity 10 or less; currently complexity is 11 (cyclomatic_complexity)
guard workController.isCurrent(workID) else {
return
}
Expand All @@ -390,16 +390,37 @@
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
) 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:
return
case .allowed:
break
}

_ = interactionState.startSession(
fullText: partial.text,
liveContext: liveContext,
Expand Down Expand Up @@ -484,6 +505,20 @@
?? 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
Expand Down Expand Up @@ -608,10 +643,16 @@
}

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.
Expand Down Expand Up @@ -730,13 +771,14 @@
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) }
spellingAssessment: { self.completionSpellingAssessment(for: $0) }
)
if seamVerdict != .allow {
clearSuggestion()
Expand Down
167 changes: 151 additions & 16 deletions Cotabby/Support/Suggestion/Output/CompletionSeamGuard.swift
Original file line number Diff line number Diff line change
@@ -1,25 +1,48 @@
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:
///
/// - **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 {
/// 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
case seamMisspelling(word: String)
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.
Expand All @@ -28,19 +51,19 @@ nonisolated enum CompletionSeamGuard {
/// 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)
}

/// `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 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
spellingAssessment: (String) -> SpellingAssessment
) -> Verdict {
if introducesJunkPunctuationRun(precedingText: precedingText, completion: completion) {
return .junkPunctuationRun
Expand All @@ -49,13 +72,41 @@ nonisolated enum CompletionSeamGuard {
if let seamWord = misspellingCandidateSeamWord(
precedingText: precedingText,
completion: completion
), !isKnownWord(seamWord) {
), spellingAssessment(seamWord) != .known {
return .seamMisspelling(word: seamWord)
}

if case let .candidate(leadingWord, _) = leadingWordProbe(
precedingText: precedingText,
completion: completion
), 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(
Expand Down Expand Up @@ -117,6 +168,90 @@ nonisolated enum CompletionSeamGuard {
return seamWord
}

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
) -> LeadingWordProbe {
guard !completion.isEmpty else {
return .incomplete
}

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 boundaryPrefix = completion[..<wordStart]
guard !boundaryPrefix.contains(where: { $0.isNumber }) else {
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..<tokenEnd].contains(where: { $0.isNumber }) else {
return .notApplicable
}

// A bare apostrophe or hyphen can continue the word before the caret (`don` + `'t`). Any
// other prefix character establishes a real boundary before the generated word.
if precedingText.last?.isLetter == true,
boundaryPrefix.allSatisfy({ isWordConnector($0) }) {
return .notApplicable
}

var wordEnd = wordStart
var endsInDanglingConnector = false
while wordEnd < completion.endIndex {
let character = completion[wordEnd]
if character.isLetter {
wordEnd = completion.index(after: wordEnd)
continue
}
let next = completion.index(after: wordEnd)
if isWordConnector(character), next < completion.endIndex,
completion[next].isLetter {
wordEnd = next
continue
}
endsInDanglingConnector = isWordConnector(character)
&& next == completion.endIndex
break
}

let word = String(completion[wordStart..<wordEnd])
let letters = word.filter(\.isLetter)
let isComplete = wordEnd < completion.endIndex && !endsInDanglingConnector
guard letters.first?.isLowercase == true,
!letters.dropFirst().contains(where: { $0.isUppercase }),
!containsCJK(word) else {
return .notApplicable
}
guard letters.count >= minimumSeamWordLength else {
return isComplete ? .notApplicable : .incomplete
}
return .candidate(word: word, isComplete: isComplete)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

/// Apostrophes and hyphens bind adjacent letter runs into one natural-language token.
private static func isWordConnector(_ character: Character) -> Bool {
character == "'" || character == "’" || character == "-"
}

private static func trailingRunLength(of text: String, character: Character) -> Int {
text.reversed().prefix(while: { $0 == character }).count
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
///
Expand All @@ -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.
Expand Down Expand Up @@ -58,12 +66,20 @@ 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
/// flag when it eventually runs.
mutating func clearSession() {
renderedText = nil
pendingPartial = nil
leadingWordGateState = .pending
}
}
9 changes: 8 additions & 1 deletion CotabbyTests/Evals/LlamaSuggestionEvalTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -119,7 +119,14 @@ final class LlamaSuggestionEvalTests: XCTestCase {
let verdict = CompletionSeamGuard.verdict(
precedingText: evalCase.precedingText,
completion: candidate,
isKnownWord: { !spellChecker.isTypo($0) }
spellingAssessment: { word in
guard spellChecker.isTypo(word) else {
return .known
}
return spellChecker.bestCorrection(for: word) == nil
? .uncorrectableTypo
: .correctableTypo
}
)
if verdict != .allow {
shownText = nil
Expand Down
Loading
Loading