From 64a21e65d55f0e38531dafeed1b4a72a1ee924e0 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Sat, 30 May 2026 15:51:02 -0700 Subject: [PATCH] Add stale-completion salvage (type-ahead reconciliation) behind a flag When a prediction finishes generating after the user has kept typing, the result was generated against an older prefix and the coordinator drops it on the generation-number guard. This adds a salvage path: trim the characters typed during the race off the front of the continuation and render the remainder instead of showing nothing. The behavior is gated behind a hidden, default-off setting (cotabbyStaleCompletionSalvageEnabled). The salvage opportunity is logged as "stale-salvageable" even when the flag is off, so the rescue rate is measurable from the logs before the path is enabled. - StaleCompletionReconciler: pure trim plus suffix/prefix overlap rules - apply() now receives the request context and attempts salvage at the stale-drop point, reusing the existing process/selection/suffix guards - Settings flag plumbed through the snapshot and its publisher - Unit tests for the pure reconciler; project regenerated via XcodeGen --- Cotabby.xcodeproj/project.pbxproj | 8 + .../SuggestionCoordinator+Prediction.swift | 93 +++++++++- Cotabby/Models/SuggestionEngineModels.swift | 4 + Cotabby/Models/SuggestionSettingsModel.swift | 34 +++- .../Support/StaleCompletionReconciler.swift | 128 ++++++++++++++ CotabbyTests/CotabbyTestFixtures.swift | 6 +- .../StaleCompletionReconcilerTests.swift | 164 ++++++++++++++++++ 7 files changed, 425 insertions(+), 12 deletions(-) create mode 100644 Cotabby/Support/StaleCompletionReconciler.swift create mode 100644 CotabbyTests/StaleCompletionReconcilerTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 24b3c0c4..c695b615 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -133,6 +133,7 @@ 91D1F16B8C5DA281D4B7F699 /* CustomRulesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD752451330486FE270018B0 /* CustomRulesTests.swift */; }; 924489CEE8171F7AD8579D71 /* FocusDebugOverlayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F5E263AB69029D5E13D5EE8 /* FocusDebugOverlayController.swift */; }; 934885ACC2DEA20B27F10948 /* PromptContextSanitizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D80CC2CCAAFE3F23FB8C37A /* PromptContextSanitizerTests.swift */; }; + 9592370F425B5116E0DD6FD0 /* StaleCompletionReconcilerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE1F514806CEFDA700C77E84 /* StaleCompletionReconcilerTests.swift */; }; 96498E097A5899AFC9F0C853 /* EmojiCatalogMatcherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 292DC9D4D9D5D26AE882E39B /* EmojiCatalogMatcherTests.swift */; }; 96782E57CA26A16409368B69 /* TextDirectionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 328847A0F494360033366791 /* TextDirectionDetector.swift */; }; 9706D778FB549E9E7AE05F4F /* EmojiMatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1441B2D89DAE6878DAD11F17 /* EmojiMatcher.swift */; }; @@ -190,6 +191,7 @@ E912D4617AE1376061DF1F00 /* LanguageSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4793D4EA5D36D7E5CC216C27 /* LanguageSupportTests.swift */; }; E994FE418A961FB234D9057A /* DownloadFileRescuerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2F46767D9D1F0D44E239CA8 /* DownloadFileRescuerTests.swift */; }; E9E4CC657771DF9F4C56183C /* VisualContextCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = A854CAFB1F557BC4CAED8819 /* VisualContextCoordinator.swift */; }; + EA7A31903D43AE3C8B4DAEC0 /* StaleCompletionReconciler.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB9482082EC7A71418EC5BCE /* StaleCompletionReconciler.swift */; }; ED0843752B297D7E9DB2C468 /* EmojiTriggerStateMachineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 723E1EFA85D2E61B6C5F33E8 /* EmojiTriggerStateMachineTests.swift */; }; ED9C51B0D7056F0753AADF2D /* GhostSuggestionLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 043E8AA850F930222DD112C0 /* GhostSuggestionLayout.swift */; }; EDA8E8250FC2F70B206B4894 /* LlamaVisualContextSummarizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = E1D2782B6C7BE3F56BCB22DE /* LlamaVisualContextSummarizer.swift */; }; @@ -397,6 +399,7 @@ DB0CE9AB1286367BA2E82392 /* SettingsContainerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsContainerView.swift; sourceTree = ""; }; DDE858CB1E687E3CEB8FDD5B /* SuggestionRequestFactory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionRequestFactory.swift; sourceTree = ""; }; DDF6A4E9CE93FD53C60E67E3 /* EmojiQueryRun.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiQueryRun.swift; sourceTree = ""; }; + DE1F514806CEFDA700C77E84 /* StaleCompletionReconcilerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaleCompletionReconcilerTests.swift; sourceTree = ""; }; DEB16474A67CE1D210B944C9 /* SuggestionSubsystemContracts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSubsystemContracts.swift; sourceTree = ""; }; E1D2782B6C7BE3F56BCB22DE /* LlamaVisualContextSummarizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaVisualContextSummarizer.swift; sourceTree = ""; }; E217A66717D78E1E49350EC8 /* DownloadOutcomeClassifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadOutcomeClassifierTests.swift; sourceTree = ""; }; @@ -407,6 +410,7 @@ E7F42112F14026E6253BB865 /* PermissionAndContextModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionAndContextModelTests.swift; sourceTree = ""; }; EAAE6B395FAB604DF059280A /* KeyCodeLabels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCodeLabels.swift; sourceTree = ""; }; EB630F9814388203DD1CA2EC /* ShortcutsPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsPaneView.swift; sourceTree = ""; }; + EB9482082EC7A71418EC5BCE /* StaleCompletionReconciler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaleCompletionReconciler.swift; sourceTree = ""; }; ED8672B87CEC72BE3978C6BB /* CotabbyTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CotabbyTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; EE94342B888A5A2CCF66BC93 /* SuggestionRequestFactoryTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionRequestFactoryTests.swift; sourceTree = ""; }; EFD89799BB82AF7A92559AEB /* ClipboardContentDistillerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardContentDistillerTests.swift; sourceTree = ""; }; @@ -661,6 +665,7 @@ 0D80CC2CCAAFE3F23FB8C37A /* PromptContextSanitizerTests.swift */, 4696A84D17890B154533A08F /* PromptPolicyTests.swift */, 2BC293F6125E2B14DCF05AD9 /* SettingsAttentionEvaluatorTests.swift */, + DE1F514806CEFDA700C77E84 /* StaleCompletionReconcilerTests.swift */, C05B0439348261163B37C508 /* SuggestionAvailabilityEvaluatorTests.swift */, C375227649689775275AA4B3 /* SuggestionCoordinatorAcceptanceTests.swift */, CDB25ABC4FFB0E63477CDCB0 /* SuggestionOverlayStabilityGateTests.swift */, @@ -796,6 +801,7 @@ FA4B45B91D4DEAC979C3113E /* PromptContextSanitizer.swift */, 6DC693E00430F46E41CB56E6 /* RequestID.swift */, 2A02336442BB735EE2E8D064 /* SettingsAttentionEvaluator.swift */, + EB9482082EC7A71418EC5BCE /* StaleCompletionReconciler.swift */, 3609CC88A5280B3AA40414DF /* SuggestionAvailabilityEvaluator.swift */, B2F95847D76893C8A5B504B4 /* SuggestionOverlayStabilityGate.swift */, DDE858CB1E687E3CEB8FDD5B /* SuggestionRequestFactory.swift */, @@ -1038,6 +1044,7 @@ 078FDE669437D756678E9AB7 /* SettingsRowLabel.swift in Sources */, 27D4F5CACADE171F142178B4 /* SettingsSidebarView.swift in Sources */, 12995E5DDB11E3395E6AF82F /* ShortcutsPaneView.swift in Sources */, + EA7A31903D43AE3C8B4DAEC0 /* StaleCompletionReconciler.swift in Sources */, 4F369F5284DDCEABF082E59B /* SuggestionAvailabilityEvaluator.swift in Sources */, A0657CE0488F69F0BD559CBC /* SuggestionCoordinator+Acceptance.swift in Sources */, D2F1DD215989BF32675308C2 /* SuggestionCoordinator+Input.swift in Sources */, @@ -1117,6 +1124,7 @@ 934885ACC2DEA20B27F10948 /* PromptContextSanitizerTests.swift in Sources */, 3CF1A4E39F24917DF0470A7D /* PromptPolicyTests.swift in Sources */, C618C5595DA9C57C806A3E03 /* SettingsAttentionEvaluatorTests.swift in Sources */, + 9592370F425B5116E0DD6FD0 /* StaleCompletionReconcilerTests.swift in Sources */, 88BCD795A14E1C9308F7BB31 /* SuggestionAvailabilityEvaluatorTests.swift in Sources */, 5B404450B412A6102F514250 /* SuggestionCoordinatorAcceptanceTests.swift in Sources */, 4C6D8ED0A7B45D2EADF06DA5 /* SuggestionOverlayStabilityGateTests.swift in Sources */, diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift index 50725684..9911f506 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift @@ -120,7 +120,7 @@ extension SuggestionCoordinator { return } - await apply(result: result, workID: workID) + await apply(result: result, requestContext: request.context, workID: workID) } catch SuggestionClientError.cancelled { return } catch { @@ -134,7 +134,7 @@ extension SuggestionCoordinator { } /// Promotes a generated result to `ready` only when it is still fresh for the current field. - func apply(result: SuggestionResult, workID: UInt64) async { + func apply(result: SuggestionResult, requestContext: FocusedInputContext, workID: UInt64) async { guard workController.isCurrent(workID) else { @@ -170,10 +170,34 @@ extension SuggestionCoordinator { lastAcceptedTail = nil // Generation numbers are our stale-result guard. If the text changed while the model was - // thinking, we drop the answer instead of showing a suggestion for old content. + // thinking, the answer was generated against an older prefix. Before dropping it, try to + // salvage it by trimming the characters the user typed during the race (see + // `StaleCompletionReconciler`). Presenting the salvaged tail is gated behind a flag, but the + // opportunity is always logged so the rescue rate is measurable while the flag is off. guard liveContext.generation == result.generation else { - latestRawModelOutput = SuggestionDebugLogger.debugPreview(result.rawText) + + if let salvage = salvagedStaleCompletion( + result: result, + requestContext: requestContext, + liveContext: liveContext + ) { + if settingsSnapshot.isStaleCompletionSalvageEnabled { + presentSalvagedCompletion(salvage, result: result, liveContext: liveContext, workID: workID) + return + } + + logStage( + "stale-salvageable", + workID: workID, + generation: result.generation, + message: "Stale result could be salvaged (\(salvage.confidence.rawValue)); " + + "salvage disabled, dropping.", + rawOutput: result.rawText, + normalizedOutput: salvage.text + ) + } + logStage( "stale-drop", workID: workID, @@ -268,6 +292,67 @@ extension SuggestionCoordinator { ) } + /// Bridges live and request-time context to the pure salvage rules, applying the context-level + /// guardrails the string helper can't see: the result must belong to the same process, the field + /// must have no active selection, and the text after the caret must be unchanged. Any of those + /// failing means the user did more than type ahead, so the continuation no longer attaches. + private func salvagedStaleCompletion( + result: SuggestionResult, + requestContext: FocusedInputContext, + liveContext: FocusedInputContext + ) -> StaleCompletionReconciler.Reconciled? { + guard liveContext.processIdentifier == requestContext.processIdentifier else { + return nil + } + guard liveContext.selection.length == 0 else { + return nil + } + guard liveContext.trailingText == requestContext.trailingText else { + return nil + } + + return StaleCompletionReconciler.reconcile( + continuation: result.text, + prefixAtRequest: requestContext.precedingText, + currentPrefix: liveContext.precedingText + ) + } + + /// Promotes a salvaged stale completion to a ready session anchored at the *current* field state. + /// Mirrors the fresh-result ready path but stamps the live generation (not the stale one) so the + /// session reconciles correctly as the user keeps typing through the recovered tail. + private func presentSalvagedCompletion( + _ salvage: StaleCompletionReconciler.Reconciled, + result: SuggestionResult, + liveContext: FocusedInputContext, + workID: UInt64 + ) { + latestLatencyMilliseconds = Int(result.latency * 1000) + latestGenerationNumber = liveContext.generation + let session = interactionState.startSession( + fullText: salvage.text, + liveContext: liveContext, + latency: result.latency + ) + applySessionDiagnostics(session, acceptanceAction: "Salvaged a stale suggestion after type-ahead.") + state = .ready(text: session.remainingText, latency: session.latency) + presentOverlay( + text: session.remainingText, + at: liveContext.caretRect, + context: liveContext, + isRightToLeft: TextDirectionDetector.isRightToLeft(liveContext.precedingText) + ) + logStage( + "stale-salvage", + workID: workID, + generation: liveContext.generation, + message: "Salvaged a stale result by trimming \(salvage.typedSinceRequest.count) " + + "typed character(s) (\(salvage.confidence.rawValue)).", + rawOutput: result.rawText, + normalizedOutput: session.remainingText + ) + } + /// Converts a runtime or engine failure into visible coordinator state and clears stale UI. func applyFailure(_ message: String, workID: UInt64) async { guard workController.isCurrent(workID) else { diff --git a/Cotabby/Models/SuggestionEngineModels.swift b/Cotabby/Models/SuggestionEngineModels.swift index de669622..c23f6357 100644 --- a/Cotabby/Models/SuggestionEngineModels.swift +++ b/Cotabby/Models/SuggestionEngineModels.swift @@ -89,4 +89,8 @@ struct SuggestionSettingsSnapshot: Equatable, Sendable { /// How much of the buffered suggestion the primary accept key takes per press. Read once per /// accept call so a mid-press setting change can't strand a partially-handled press. let acceptanceGranularity: AcceptanceGranularity + /// Experimental rollout flag for stale-completion salvage. When true, the coordinator trims the + /// user's type-ahead off a stale result and shows the remainder instead of dropping it. Off by + /// default; carried in the snapshot so generation reads the same value the settings model holds. + let isStaleCompletionSalvageEnabled: Bool } diff --git a/Cotabby/Models/SuggestionSettingsModel.swift b/Cotabby/Models/SuggestionSettingsModel.swift index 202b3d3b..b77f7bcb 100644 --- a/Cotabby/Models/SuggestionSettingsModel.swift +++ b/Cotabby/Models/SuggestionSettingsModel.swift @@ -50,6 +50,11 @@ final class SuggestionSettingsModel: ObservableObject { @Published private(set) var globalToggleKeyModifiers: ShortcutModifierMask @Published private(set) var globalToggleKeyLabel: String @Published private(set) var acceptanceGranularity: AcceptanceGranularity + /// Experimental: when true, a completion that finished generating after the user kept typing is + /// salvaged (its typed-ahead overlap is trimmed and the remainder shown) instead of being dropped + /// as stale. Hidden, off by default, toggled via the `cotabbyStaleCompletionSalvageEnabled` + /// default; the salvage opportunity is logged either way so rescue rate is measurable while off. + @Published private(set) var isStaleCompletionSalvageEnabled: Bool private let userDefaults: UserDefaults private static let isGloballyEnabledDefaultsKey = "cotabbyGloballyEnabled" @@ -85,6 +90,7 @@ final class SuggestionSettingsModel: ObservableObject { private static let globalToggleKeyModifiersDefaultsKey = "cotabbyGlobalToggleKeyModifiers" private static let globalToggleKeyLabelDefaultsKey = "cotabbyGlobalToggleKeyLabel" private static let acceptanceGranularityDefaultsKey = "cotabbyAcceptanceGranularity" + private static let staleCompletionSalvageEnabledDefaultsKey = "cotabbyStaleCompletionSalvageEnabled" static let defaultAcceptanceKeyCode: CGKeyCode = 48 static let defaultAcceptanceKeyLabel = "Tab" @@ -246,6 +252,9 @@ final class SuggestionSettingsModel: ObservableObject { .string(forKey: Self.acceptanceGranularityDefaultsKey) .flatMap(AcceptanceGranularity.init(rawValue:)) ?? .word + // Hidden experimental flag: defaults to off so the salvage path stays opt-in per machine. + let resolvedStaleCompletionSalvageEnabled = + userDefaults.object(forKey: Self.staleCompletionSalvageEnabledDefaultsKey) as? Bool ?? false isGloballyEnabled = resolvedGloballyEnabled disabledAppRules = resolvedDisabledAppRules @@ -277,6 +286,7 @@ final class SuggestionSettingsModel: ObservableObject { globalToggleKeyModifiers = resolvedGlobalToggleKeyModifiers globalToggleKeyLabel = resolvedGlobalToggleKeyLabel acceptanceGranularity = resolvedAcceptanceGranularity + isStaleCompletionSalvageEnabled = resolvedStaleCompletionSalvageEnabled userDefaults.set(resolvedGloballyEnabled, forKey: Self.isGloballyEnabledDefaultsKey) persistDisabledAppRules(resolvedDisabledAppRules) @@ -314,6 +324,7 @@ final class SuggestionSettingsModel: ObservableObject { ) userDefaults.set(resolvedGlobalToggleKeyLabel, forKey: Self.globalToggleKeyLabelDefaultsKey) userDefaults.set(resolvedAcceptanceGranularity.rawValue, forKey: Self.acceptanceGranularityDefaultsKey) + userDefaults.set(resolvedStaleCompletionSalvageEnabled, forKey: Self.staleCompletionSalvageEnabledDefaultsKey) // The custom indicator icon feature was removed; scrub any previously-persisted PNG so // users who picked one in an older build get the default cat icon back automatically. @@ -341,7 +352,8 @@ final class SuggestionSettingsModel: ObservableObject { autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation, isFastModeEnabled: isFastModeEnabled, mirrorPreference: mirrorPreference, - acceptanceGranularity: acceptanceGranularity + acceptanceGranularity: acceptanceGranularity, + isStaleCompletionSalvageEnabled: isStaleCompletionSalvageEnabled ) } @@ -415,6 +427,14 @@ final class SuggestionSettingsModel: ObservableObject { userDefaults.set(enabled, forKey: Self.emojiPickerEnabledDefaultsKey) } + func setStaleCompletionSalvageEnabled(_ enabled: Bool) { + guard isStaleCompletionSalvageEnabled != enabled else { + return + } + isStaleCompletionSalvageEnabled = enabled + userDefaults.set(enabled, forKey: Self.staleCompletionSalvageEnabledDefaultsKey) + } + func setAutoAcceptTrailingPunctuation(_ enabled: Bool) { guard autoAcceptTrailingPunctuation != enabled else { return @@ -900,8 +920,9 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { // `presentationToggles` carries the visual-pipeline knobs (clipboard, fast mode, mirror // preference); they share the property of "affects how/when suggestions are shown". // - // The outer CombineLatest4 is at the cap, so `$acceptanceGranularity` is layered above it - // via a second CombineLatest to avoid restructuring the existing groupings. + // The outer CombineLatest4 is at the cap, so `$acceptanceGranularity` and + // `$isStaleCompletionSalvageEnabled` are layered above it via a second combiner to avoid + // restructuring the existing groupings. let primary = Publishers.CombineLatest4( Publishers.CombineLatest4( $isGloballyEnabled, @@ -918,8 +939,8 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { $autoAcceptTrailingPunctuation ) ) - return Publishers.CombineLatest(primary, $acceptanceGranularity) - .map { primaryTuple, granularity in + return Publishers.CombineLatest3(primary, $acceptanceGranularity, $isStaleCompletionSalvageEnabled) + .map { primaryTuple, granularity, staleCompletionSalvageEnabled in let (combinedSettings, presentationToggles, profile, timing) = primaryTuple let (globallyEnabled, disabledAppRules, engine, wordCountPreset) = combinedSettings let (clipboardContextEnabled, fastModeEnabled, mirrorPreference) = presentationToggles @@ -940,7 +961,8 @@ extension SuggestionSettingsModel: SuggestionSettingsProviding { autoAcceptTrailingPunctuation: autoAcceptPunctuation, isFastModeEnabled: fastModeEnabled, mirrorPreference: mirrorPreference, - acceptanceGranularity: granularity + acceptanceGranularity: granularity, + isStaleCompletionSalvageEnabled: staleCompletionSalvageEnabled ) } .removeDuplicates() diff --git a/Cotabby/Support/StaleCompletionReconciler.swift b/Cotabby/Support/StaleCompletionReconciler.swift new file mode 100644 index 00000000..e8a24c5c --- /dev/null +++ b/Cotabby/Support/StaleCompletionReconciler.swift @@ -0,0 +1,128 @@ +import Foundation + +/// File overview: +/// Pure rules for salvaging a completion that finished generating *after* the user kept typing. +/// +/// Inline autocomplete has an unavoidable race. A request is built from the prefix at time `t0`, the +/// model decodes for some milliseconds, and a fast typist can append characters before the result +/// lands at `t1`. The naive response is to throw the now-stale result away (the generation guard in +/// `SuggestionCoordinator.apply`). This helper instead tries to recover it: it trims the characters +/// typed during the race off the front of the continuation, so a request built from +/// "thanks for meet" whose model returned "ing with me today" still renders " with me today" after +/// the user has typed "ing". +/// +/// This type is intentionally pure (identical inputs always yield identical output), so the salvage +/// decision is unit-testable in isolation from Accessibility timing and runtime state. The +/// context-level guardrails (focus shift, selected text, a changed suffix) stay with the coordinator, +/// which owns that live data; this helper only reasons about the three strings involved. +enum StaleCompletionReconciler { + /// How confident we are that the salvaged tail is correct. + /// + /// `exact` means the continuation literally began with the typed-ahead text, so the trim is + /// unambiguous. `overlap` means we recovered the join by matching a suffix of the typed text + /// against a prefix of the continuation, which is plausible but weaker, so callers can log or + /// gate it separately. + enum Confidence: String, Equatable, Sendable { + case exact + case overlap + } + + /// A salvaged continuation plus the evidence behind it. + struct Reconciled: Equatable, Sendable { + /// The continuation with the race-window typing trimmed off the front. + let text: String + /// The characters the user typed between the request going out and the result landing. + let typedSinceRequest: String + let confidence: Confidence + } + + /// Minimum suffix/prefix overlap, in characters, before the fuzzy join is trusted. One- and + /// two-character overlaps fire constantly on spaces and single letters, so they would salvage + /// garbage; three keeps the fallback meaningful without being reckless. + static let defaultMinimumOverlap = 3 + + /// Attempts to salvage `continuation` given the prefix it was generated against and the prefix + /// now in the field. Returns `nil` when the result is unsalvageable: the user deleted past the + /// request baseline, did not actually type ahead, typed something disjoint from the continuation, + /// or the trimmed tail collapses to whitespace. + static func reconcile( + continuation: String, + prefixAtRequest: String, + currentPrefix: String, + minimumOverlap: Int = defaultMinimumOverlap + ) -> Reconciled? { + // The user must only have *added* text since the request. If the current prefix no longer + // begins with the request prefix they deleted past the baseline or the field diverged, and + // any trim we computed would be guesswork. + guard currentPrefix.hasPrefix(prefixAtRequest) else { + return nil + } + + let typed = String(currentPrefix.dropFirst(prefixAtRequest.count)) + + // Salvage exists to recover from type-ahead specifically. With nothing typed there is no + // overlap to remove, so this is a plain stale drop, not a rescue; let the caller handle it. + guard !typed.isEmpty else { + return nil + } + + // Clean case: the user is typing straight along the predicted continuation, so it begins with + // exactly what they typed. Drop that prefix and show the remainder. + if continuation.hasPrefix(typed) { + return finalize( + String(continuation.dropFirst(typed.count)), + typedSinceRequest: typed, + confidence: .exact + ) + } + + // Fuzzy case: the continuation and the typed-ahead text converge partway in. If a suffix of + // what the user typed equals a prefix of the continuation (typed "see you ", model returned + // "you soon"), drop the overlapping head so "you soon" becomes "soon". + let overlap = longestSuffixPrefixOverlap(suffix: typed, prefix: continuation) + if overlap >= minimumOverlap { + return finalize( + String(continuation.dropFirst(overlap)), + typedSinceRequest: typed, + confidence: .overlap + ) + } + + return nil + } + + /// Returns the largest `k` such that the last `k` characters of `suffix` equal the first `k` + /// characters of `prefix`. Operates in `Character` units so multi-scalar graphemes (emoji, + /// composed characters) are never split mid-cluster. + static func longestSuffixPrefixOverlap(suffix: String, prefix: String) -> Int { + let suffixCharacters = Array(suffix) + let prefixCharacters = Array(prefix) + let maxOverlap = min(suffixCharacters.count, prefixCharacters.count) + guard maxOverlap > 0 else { + return 0 + } + + var best = 0 + for candidate in 1...maxOverlap { + let tail = suffixCharacters[(suffixCharacters.count - candidate)...] + let head = prefixCharacters[.. Reconciled? { + guard !text.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { + return nil + } + return Reconciled(text: text, typedSinceRequest: typedSinceRequest, confidence: confidence) + } +} diff --git a/CotabbyTests/CotabbyTestFixtures.swift b/CotabbyTests/CotabbyTestFixtures.swift index 033fd8ee..948b0ba5 100644 --- a/CotabbyTests/CotabbyTestFixtures.swift +++ b/CotabbyTests/CotabbyTestFixtures.swift @@ -222,7 +222,8 @@ enum CotabbyTestFixtures { autoAcceptTrailingPunctuation: Bool = true, isFastModeEnabled: Bool = false, mirrorPreference: MirrorPreference = .auto, - acceptanceGranularity: AcceptanceGranularity = .word + acceptanceGranularity: AcceptanceGranularity = .word, + isStaleCompletionSalvageEnabled: Bool = false ) -> SuggestionSettingsSnapshot { SuggestionSettingsSnapshot( isGloballyEnabled: isGloballyEnabled, @@ -239,7 +240,8 @@ enum CotabbyTestFixtures { autoAcceptTrailingPunctuation: autoAcceptTrailingPunctuation, isFastModeEnabled: isFastModeEnabled, mirrorPreference: mirrorPreference, - acceptanceGranularity: acceptanceGranularity + acceptanceGranularity: acceptanceGranularity, + isStaleCompletionSalvageEnabled: isStaleCompletionSalvageEnabled ) } } diff --git a/CotabbyTests/StaleCompletionReconcilerTests.swift b/CotabbyTests/StaleCompletionReconcilerTests.swift new file mode 100644 index 00000000..77205076 --- /dev/null +++ b/CotabbyTests/StaleCompletionReconcilerTests.swift @@ -0,0 +1,164 @@ +import XCTest +@testable import Cotabby + +/// Tests for the pure rules that salvage a completion which finished generating after the user kept +/// typing. This logic decides whether a stale result can be rescued by trimming the type-ahead +/// overlap, so it is the heart of the new salvage path and worth covering exhaustively in isolation. +final class StaleCompletionReconcilerTests: XCTestCase { + // MARK: - Exact trim + + func test_reconcile_trimsTypedAheadWhenContinuationStartsWithIt() { + let reconciled = StaleCompletionReconciler.reconcile( + continuation: "ing with me today", + prefixAtRequest: "thanks for meet", + currentPrefix: "thanks for meeting" + ) + + XCTAssertEqual(reconciled?.text, " with me today") + XCTAssertEqual(reconciled?.typedSinceRequest, "ing") + XCTAssertEqual(reconciled?.confidence, .exact) + } + + func test_reconcile_preservesLeadingSpaceTheUserHasNotTypedYet() { + // The user typed "see" but not the following space, so the salvaged tail must keep it. + let reconciled = StaleCompletionReconciler.reconcile( + continuation: "see you soon", + prefixAtRequest: "I will ", + currentPrefix: "I will see" + ) + + XCTAssertEqual(reconciled?.text, " you soon") + XCTAssertEqual(reconciled?.confidence, .exact) + } + + // MARK: - No-op cases + + func test_reconcile_returnsNilWhenNothingWasTyped() { + // Salvage exists to recover from type-ahead. An unchanged prefix is a plain stale drop. + let reconciled = StaleCompletionReconciler.reconcile( + continuation: " with me today", + prefixAtRequest: "thanks for meeting", + currentPrefix: "thanks for meeting" + ) + + XCTAssertNil(reconciled) + } + + func test_reconcile_returnsNilWhenUserDeletedPastTheRequestBaseline() { + let reconciled = StaleCompletionReconciler.reconcile( + continuation: "ing with me today", + prefixAtRequest: "thanks for meet", + currentPrefix: "thanks for me" + ) + + XCTAssertNil(reconciled) + } + + func test_reconcile_returnsNilWhenTypedTextIsDisjointFromContinuation() { + let reconciled = StaleCompletionReconciler.reconcile( + continuation: "ing with me today", + prefixAtRequest: "thanks for meet", + currentPrefix: "thanks for meetXYZQR" + ) + + XCTAssertNil(reconciled) + } + + // MARK: - Overlap fallback + + func test_reconcile_recoversJoinViaSuffixPrefixOverlap() { + // The model continued from "I will " with "you soon" while the user typed "see you "; the + // shared "you " lets us recover "soon". + let reconciled = StaleCompletionReconciler.reconcile( + continuation: "you soon", + prefixAtRequest: "I will ", + currentPrefix: "I will see you " + ) + + XCTAssertEqual(reconciled?.text, "soon") + XCTAssertEqual(reconciled?.typedSinceRequest, "see you ") + XCTAssertEqual(reconciled?.confidence, .overlap) + } + + func test_reconcile_rejectsOverlapBelowMinimum() { + // Only a 2-character ("de") overlap exists, under the default minimum of 3. + let reconciled = StaleCompletionReconciler.reconcile( + continuation: "defgh", + prefixAtRequest: "z", + currentPrefix: "zabde" + ) + + XCTAssertNil(reconciled) + } + + func test_reconcile_honorsCustomMinimumOverlap() { + let reconciled = StaleCompletionReconciler.reconcile( + continuation: "defgh", + prefixAtRequest: "z", + currentPrefix: "zabde", + minimumOverlap: 2 + ) + + XCTAssertEqual(reconciled?.text, "fgh") + XCTAssertEqual(reconciled?.confidence, .overlap) + } + + // MARK: - Empty / whitespace guards + + func test_reconcile_returnsNilWhenTheUserTypedThroughTheWholeContinuation() { + let reconciled = StaleCompletionReconciler.reconcile( + continuation: "there", + prefixAtRequest: "hi ", + currentPrefix: "hi there" + ) + + XCTAssertNil(reconciled) + } + + func test_reconcile_returnsNilWhenSalvagedTailIsWhitespaceOnly() { + let reconciled = StaleCompletionReconciler.reconcile( + continuation: "there ", + prefixAtRequest: "hi ", + currentPrefix: "hi there" + ) + + XCTAssertNil(reconciled) + } + + // MARK: - Grapheme safety + + func test_reconcile_trimsByGraphemeForEmoji() { + let reconciled = StaleCompletionReconciler.reconcile( + continuation: "👍 thanks", + prefixAtRequest: "hi ", + currentPrefix: "hi 👍" + ) + + XCTAssertEqual(reconciled?.text, " thanks") + XCTAssertEqual(reconciled?.typedSinceRequest, "👍") + XCTAssertEqual(reconciled?.confidence, .exact) + } + + // MARK: - Overlap primitive + + func test_longestSuffixPrefixOverlap_findsSharedJoin() { + XCTAssertEqual( + StaleCompletionReconciler.longestSuffixPrefixOverlap(suffix: "see you ", prefix: "you soon"), + 4 + ) + } + + func test_longestSuffixPrefixOverlap_isZeroWhenDisjoint() { + XCTAssertEqual( + StaleCompletionReconciler.longestSuffixPrefixOverlap(suffix: "abc", prefix: "xyz"), + 0 + ) + } + + func test_longestSuffixPrefixOverlap_isZeroForEmptyInput() { + XCTAssertEqual( + StaleCompletionReconciler.longestSuffixPrefixOverlap(suffix: "", prefix: "abc"), + 0 + ) + } +}