diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 6ff8ed51..71efd8f4 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -61,6 +61,7 @@ 12995E5DDB11E3395E6AF82F /* ShortcutsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB630F9814388203DD1CA2EC /* ShortcutsPaneView.swift */; }; 1450746C690B3D98203B71EC /* DeviceInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = 29ED42C4BDD0C521101AF95E /* DeviceInfo.swift */; }; 14611368270D611A2D5DC67E /* SettingsRowLabel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 907549CB913B40C28B953A5D /* SettingsRowLabel.swift */; }; + 14B2492F1208888C0C3F8804 /* SpeculativeAcceptanceContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5B101C75C5D3972E33E8E0 /* SpeculativeAcceptanceContextTests.swift */; }; 14C55DC5096F003BD3D2917D /* EmojiPopularityTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 023144451BB30F981D1F9EE6 /* EmojiPopularityTests.swift */; }; 14D77F0B8A195AC2FA8D24A9 /* MirrorOverlayLayoutTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC83D14A7557BC0196E59007 /* MirrorOverlayLayoutTests.swift */; }; 156E6AB3D24134EEC29FDB93 /* FocusSnapshotResolverSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA705EDFE1C41294F0E381F1 /* FocusSnapshotResolverSelectionTests.swift */; }; @@ -108,6 +109,7 @@ 25750F599635ED38E61517A3 /* RandomMacroEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3EC87078D3A4C21DB3252C /* RandomMacroEvaluator.swift */; }; 257C2A5D299365C1D98527A8 /* SpellingLanguageResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0348A7053E5683C68879A71A /* SpellingLanguageResolver.swift */; }; 258EAFB0292290C88520E915 /* SystemSettingsWindowLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5976600F428C1265121D4C0C /* SystemSettingsWindowLocator.swift */; }; + 258EC4BDDBD31A4509B668AE /* SuggestionAnchorCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99C99D2782EAF0492F964EF /* SuggestionAnchorCache.swift */; }; 25D4FC8D191A50F63E6391F9 /* ModelAndPresentationValueTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 03766F6253FF17639230C0F6 /* ModelAndPresentationValueTests.swift */; }; 25F91CEF38400FD1ADB6B1AF /* CompletionRenderModePolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D504BEB224E0C176F5FCFF6E /* CompletionRenderModePolicyTests.swift */; }; 261FA692D19C48E53D6999BC /* DeepGeometryWalkThrottle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 684737E62EE6495A71344923 /* DeepGeometryWalkThrottle.swift */; }; @@ -211,6 +213,7 @@ 4882DF865737ECDC4925F44B /* GeneralPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07480CE96ED0EBD94817C6B1 /* GeneralPaneView.swift */; }; 49C91DE326A590708D76102A /* BrowserAppDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = B997EC69E1C65B1E18234221 /* BrowserAppDetector.swift */; }; 4AC255BE2D0CCC67B8882C7A /* WelcomeCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21CB3008986BE7FD2A4D9132 /* WelcomeCoordinator.swift */; }; + 4B2C52C714D04D17ACB70B99 /* SpeculativeAcceptanceContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7EB66904C35A7D8BEF5D2A5 /* SpeculativeAcceptanceContext.swift */; }; 4B47C5DF1EF4276E0B143AF5 /* PermissionOverlayTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6423D6CC8CC371D2DA899DE /* PermissionOverlayTracker.swift */; }; 4B4DDB569CAD806F765224DE /* CustomRulesEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82F7F7355967725162DF2D1B /* CustomRulesEditor.swift */; }; 4B54ACE1255873955414CD06 /* PermissionGuidanceController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5F2C764D29C8D50D0C854FF8 /* PermissionGuidanceController.swift */; }; @@ -483,12 +486,14 @@ B67EEF6865EB7C2205605608 /* EngineAndModelPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FC9ECD5408B0F5708149B5C0 /* EngineAndModelPaneView.swift */; }; B6815BBBC91809A6EAA914CB /* UnitConversionEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 82E7794DF60664B1FA8F6E7B /* UnitConversionEvaluator.swift */; }; B691B8378FD73E186A72450C /* SuggestionRequestFactory.swift in Sources */ = {isa = PBXBuildFile; fileRef = DDE858CB1E687E3CEB8FDD5B /* SuggestionRequestFactory.swift */; }; + B6A84E11643F88D88B602F3D /* SuggestionAnchorCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BC3BE51EFE0A1B2B13BD02B /* SuggestionAnchorCacheTests.swift */; }; B6BA7DF77DCA55F9EF090AEE /* ScreenTextExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59E299BE2E9D42A33D5D2F5D /* ScreenTextExtractor.swift */; }; B709B362B786AA6ED548C673 /* TypoCaseTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08CE63B8725EBD71A4C024E1 /* TypoCaseTransfer.swift */; }; B782EC08B7516791BDB21172 /* FieldStyleCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7FBF2B766E728F25899B64E /* FieldStyleCache.swift */; }; B7A98BC225304E4DFED9E622 /* OnboardingTemplateRecommender.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA878B447441BB4F3E327CC8 /* OnboardingTemplateRecommender.swift */; }; B816C6191738AB616F2E8D2D /* SuggestionCoordinatorTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C174D8294858BF9DF3D361D /* SuggestionCoordinatorTestSupport.swift */; }; B849D68E0474CECAE809881C /* DebouncePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC17643448271DE5DE61A89 /* DebouncePolicy.swift */; }; + B909A118616C0C47AAB6039A /* SpeculativeAcceptanceContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7EB66904C35A7D8BEF5D2A5 /* SpeculativeAcceptanceContext.swift */; }; B93AB7E845086F6FBB068369 /* SuggestionRequestFactoryTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EE94342B888A5A2CCF66BC93 /* SuggestionRequestFactoryTests.swift */; }; B9623395B31459D9D45B1320 /* CurrentWordExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 247561C626843957CFB4B632 /* CurrentWordExtractor.swift */; }; B9F400BCC20757DA5DB0B5F9 /* FoundationModelSuggestionEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5664E34B23FBDF69292FEF43 /* FoundationModelSuggestionEngine.swift */; }; @@ -498,6 +503,7 @@ BBBD7B4628BA1160DD6B4BDB /* MacroModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = B41F06FEF208B30ECCF23A6F /* MacroModels.swift */; }; BBE22CE4EF43247F8775B25D /* FocusPollBackoff.swift in Sources */ = {isa = PBXBuildFile; fileRef = 09FADF683BE7B3558377FA76 /* FocusPollBackoff.swift */; }; BC0FDF9998CA892F4EE0E2E2 /* SuggestionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F961F5DF2A392F6F5F94F8A /* SuggestionCoordinator.swift */; }; + BC33B7F6EF791AD304646655 /* SuggestionAnchorCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = E99C99D2782EAF0492F964EF /* SuggestionAnchorCache.swift */; }; BD77B0CFB09BF0B4EDB1B0C6 /* TagChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB317C82CE2CBC69056BA4B8 /* TagChip.swift */; }; BD94A8663D79D9609461F894 /* SurfaceContextCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CF6B7FFBF77B4290F5F2FB8 /* SurfaceContextCache.swift */; }; BE3CB85508055D159C35020A /* LlamaSuggestionEngineCancellationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABCC3FD99B1824A81E665F3 /* LlamaSuggestionEngineCancellationTests.swift */; }; @@ -891,6 +897,7 @@ 9B3179B40A81DF121D1221C6 /* StaticTextRunWalkThrottleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticTextRunWalkThrottleTests.swift; sourceTree = ""; }; 9B55A4362AB7F0528C661C4C /* SuggestionTextNormalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTextNormalizerTests.swift; sourceTree = ""; }; 9B84BAE361626891F19DC9DB /* ScreenshotContextGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotContextGenerator.swift; sourceTree = ""; }; + 9BC3BE51EFE0A1B2B13BD02B /* SuggestionAnchorCacheTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAnchorCacheTests.swift; sourceTree = ""; }; 9C2F4A55D7EC8C29D47B45C4 /* SuggestionCoordinatorLifecycleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionCoordinatorLifecycleTests.swift; sourceTree = ""; }; 9C8F07AC52C7A482F5FE34C5 /* SuggestionSessionReconcilerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSessionReconcilerTests.swift; sourceTree = ""; }; 9CC2D6472ACD377FD73A5801 /* ControlTokenMarkers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlTokenMarkers.swift; sourceTree = ""; }; @@ -945,6 +952,7 @@ B6D42CD456B4B3C988B148A6 /* FocusTrackingModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusTrackingModel.swift; sourceTree = ""; }; B78AA11B52A6588119ABF76F /* TokenCountEstimatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenCountEstimatorTests.swift; sourceTree = ""; }; B7B185BA246A526CBA85E581 /* EmojiPickerPanelLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerPanelLayoutTests.swift; sourceTree = ""; }; + B7EB66904C35A7D8BEF5D2A5 /* SpeculativeAcceptanceContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeculativeAcceptanceContext.swift; sourceTree = ""; }; B7FBF2B766E728F25899B64E /* FieldStyleCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldStyleCache.swift; sourceTree = ""; }; B81DD30EB657368AACE9625A /* InputMonitor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputMonitor.swift; sourceTree = ""; }; B8412FE2BAC406421248A03B /* TypoGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypoGate.swift; sourceTree = ""; }; @@ -976,6 +984,7 @@ C7B2D34A6F3AC9DFD61350F7 /* CotabbyDebugOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CotabbyDebugOptions.swift; sourceTree = ""; }; C850141146422A132B2B3516 /* SymSpellCorrectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SymSpellCorrectorTests.swift; sourceTree = ""; }; C9C000E46A1E404932F89C81 /* he.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = he.txt; sourceTree = ""; }; + CA5B101C75C5D3972E33E8E0 /* SpeculativeAcceptanceContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeculativeAcceptanceContextTests.swift; sourceTree = ""; }; CA942A53B7C09D1F4EC57239 /* SuggestionInteractionState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionInteractionState.swift; sourceTree = ""; }; CB58035EFFD65B767949BAE6 /* AXTextGeometryResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AXTextGeometryResolver.swift; sourceTree = ""; }; CBD5FCB8CC56AA6138382B2C /* FieldEdgeIconIndicatorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FieldEdgeIconIndicatorView.swift; sourceTree = ""; }; @@ -1028,6 +1037,7 @@ E6423D6CC8CC371D2DA899DE /* PermissionOverlayTracker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionOverlayTracker.swift; sourceTree = ""; }; E68BE6A22BA0D42C8DD9868C /* SelfCaptureGate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfCaptureGate.swift; sourceTree = ""; }; E7F42112F14026E6253BB865 /* PermissionAndContextModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionAndContextModelTests.swift; sourceTree = ""; }; + E99C99D2782EAF0492F964EF /* SuggestionAnchorCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionAnchorCache.swift; sourceTree = ""; }; EAAE6B395FAB604DF059280A /* KeyCodeLabels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KeyCodeLabels.swift; sourceTree = ""; }; EB23CDF7CAA1DEAD606B46B3 /* DebouncePolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DebouncePolicyTests.swift; sourceTree = ""; }; EB630F9814388203DD1CA2EC /* ShortcutsPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShortcutsPaneView.swift; sourceTree = ""; }; @@ -1464,10 +1474,12 @@ 2BC293F6125E2B14DCF05AD9 /* SettingsAttentionEvaluatorTests.swift */, 5A2FFC2055C52FB837DEEB8F /* SettingsIndexTests.swift */, 0850B07CCDBA67C756C6EC59 /* ShortcutConflictTests.swift */, + CA5B101C75C5D3972E33E8E0 /* SpeculativeAcceptanceContextTests.swift */, D562A73C7C680F2AA65F9F7F /* SpellingDictionaryResourceTests.swift */, E0871985CB1F877EC422E18C /* SpellingLanguageResolverTests.swift */, 9B3179B40A81DF121D1221C6 /* StaticTextRunWalkThrottleTests.swift */, D1AA6A6F4C3A54B5DA2A0022 /* StreamedGhostTextPolicyTests.swift */, + 9BC3BE51EFE0A1B2B13BD02B /* SuggestionAnchorCacheTests.swift */, C05B0439348261163B37C508 /* SuggestionAvailabilityEvaluatorTests.swift */, EC04832FBD5311352F35241B /* SuggestionCaretLayoutRepairTests.swift */, C375227649689775275AA4B3 /* SuggestionCoordinatorAcceptanceTests.swift */, @@ -1684,8 +1696,10 @@ E68BE6A22BA0D42C8DD9868C /* SelfCaptureGate.swift */, D4B56C250DDEF3E81F9DCBD7 /* SentenceBoundaryClassifier.swift */, 2A02336442BB735EE2E8D064 /* SettingsAttentionEvaluator.swift */, + B7EB66904C35A7D8BEF5D2A5 /* SpeculativeAcceptanceContext.swift */, 0348A7053E5683C68879A71A /* SpellingLanguageResolver.swift */, 299BD7B741DA4AAE6A061BAD /* StreamedGhostTextPolicy.swift */, + E99C99D2782EAF0492F964EF /* SuggestionAnchorCache.swift */, 3609CC88A5280B3AA40414DF /* SuggestionAvailabilityEvaluator.swift */, B2F95847D76893C8A5B504B4 /* SuggestionOverlayStabilityGate.swift */, DDE858CB1E687E3CEB8FDD5B /* SuggestionRequestFactory.swift */, @@ -2080,11 +2094,13 @@ 14611368270D611A2D5DC67E /* SettingsRowLabel.swift in Sources */, 753DC144B9394A35A3F395DA /* SettingsSidebarView.swift in Sources */, 6F2FE689BCA50BEAE80AC6F4 /* ShortcutsPaneView.swift in Sources */, + B909A118616C0C47AAB6039A /* SpeculativeAcceptanceContext.swift in Sources */, AD361AA6F90A5E5F6F5005BF /* SpellingDictionaryCatalog.swift in Sources */, D6AD25168F108DA8D60E76EF /* SpellingDictionaryPicker.swift in Sources */, 257C2A5D299365C1D98527A8 /* SpellingLanguageResolver.swift in Sources */, 753C5A939E986B1A0FB25664 /* StaticTextRunWalkThrottle.swift in Sources */, A0A2BD916B2CB22BAF32A62E /* StreamedGhostTextPolicy.swift in Sources */, + 258EC4BDDBD31A4509B668AE /* SuggestionAnchorCache.swift in Sources */, 333C09921443BDDF21A9753D /* SuggestionAvailabilityEvaluator.swift in Sources */, EC4ED03BE4C7DD0E6319F310 /* SuggestionCoordinator+Acceptance.swift in Sources */, AC4A369EC73115E1F698934D /* SuggestionCoordinator+Input.swift in Sources */, @@ -2311,11 +2327,13 @@ 078FDE669437D756678E9AB7 /* SettingsRowLabel.swift in Sources */, 27D4F5CACADE171F142178B4 /* SettingsSidebarView.swift in Sources */, 12995E5DDB11E3395E6AF82F /* ShortcutsPaneView.swift in Sources */, + 4B2C52C714D04D17ACB70B99 /* SpeculativeAcceptanceContext.swift in Sources */, 2E972FB7E0CF14EE03AA55A3 /* SpellingDictionaryCatalog.swift in Sources */, 94F037A3F9D7CE52CC70CA0F /* SpellingDictionaryPicker.swift in Sources */, 1BDEC75125ADFCD67F3C406D /* SpellingLanguageResolver.swift in Sources */, B50EDCA5C4C5FE4FC548AA74 /* StaticTextRunWalkThrottle.swift in Sources */, C6925440737F37F537622F35 /* StreamedGhostTextPolicy.swift in Sources */, + BC33B7F6EF791AD304646655 /* SuggestionAnchorCache.swift in Sources */, 4F369F5284DDCEABF082E59B /* SuggestionAvailabilityEvaluator.swift in Sources */, A0657CE0488F69F0BD559CBC /* SuggestionCoordinator+Acceptance.swift in Sources */, D2F1DD215989BF32675308C2 /* SuggestionCoordinator+Input.swift in Sources */, @@ -2478,10 +2496,12 @@ C618C5595DA9C57C806A3E03 /* SettingsAttentionEvaluatorTests.swift in Sources */, F71FD79FAC8B59C1CBD9E2E0 /* SettingsIndexTests.swift in Sources */, 8441299082E6B68F7F88911B /* ShortcutConflictTests.swift in Sources */, + 14B2492F1208888C0C3F8804 /* SpeculativeAcceptanceContextTests.swift in Sources */, 303652F15C0FE55595669D81 /* SpellingDictionaryResourceTests.swift in Sources */, 66D0D9F605AF462F569A5CFD /* SpellingLanguageResolverTests.swift in Sources */, 96C3128BCB17A05A7C7DEFF7 /* StaticTextRunWalkThrottleTests.swift in Sources */, 9E4AED02831829A108A1AA85 /* StreamedGhostTextPolicyTests.swift in Sources */, + B6A84E11643F88D88B602F3D /* SuggestionAnchorCacheTests.swift in Sources */, 88BCD795A14E1C9308F7BB31 /* SuggestionAvailabilityEvaluatorTests.swift in Sources */, EB9B5E5F7326AB72E0E44C70 /* SuggestionCaretLayoutRepairTests.swift in Sources */, 5B404450B412A6102F514250 /* SuggestionCoordinatorAcceptanceTests.swift in Sources */, diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift index 419fc091..6c9a59fc 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Acceptance.swift @@ -157,6 +157,13 @@ extension SuggestionCoordinator { // *after* the `hideOverlay` above, which routes through `onStateChange(.hidden)` and // turns interception off; arming re-asserts it. See `armPostExhaustionAcceptance`. armPostExhaustionAcceptance() + // Start the continuation against the text the host is about to publish instead of + // idling through the publish poll first; the poll below validates the bet (see + // dispatchSpeculativePostAcceptanceGeneration). + dispatchSpeculativePostAcceptanceGeneration( + rawContext: rawContext, + insertionChunk: insertionChunk + ) // Wait for the host to actually publish the inserted text before regenerating. A bare // `schedulePrediction()` here reads pre-insertion AX in Chromium editors (the publish lags // the synthetic keystroke), so the model re-proposes the word just accepted and the next @@ -532,6 +539,15 @@ extension SuggestionCoordinator { clearDiagnostics: Bool = true ) { CotabbyLogger.suggestion.debug("Invalidating active suggestion: \(reason)") + // The dying session is exactly what a backspace-rollback wants restored a moment later; + // remember it (string-only) before the state is torn down. + if let session = interactionState.activeSession, !session.kind.isCorrection { + suggestionAnchorCache.record( + identityKey: session.baseContext.focusedInputIdentityKey, + precedingText: session.baseContext.precedingText, + fullText: session.fullText + ) + } cancelPredictionWork() clearSuggestion(clearDiagnostics: clearDiagnostics) hideOverlay(reason: reason) diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift index 329a4a79..b0fbc7cd 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Input.swift @@ -302,6 +302,23 @@ extension SuggestionCoordinator { let elementChanged = currentContext?.elementIdentifier != baseline.elementIdentifier let selectionChanged = currentContext?.selection.location != baseline.selectionLocation if textChanged || elementChanged || selectionChanged { + // The publish arrived. When it matches the snapshot a speculative post-acceptance + // generation was built against, that generation is already in flight (or applied) for + // exactly this content: scheduling another would only retire it and pay the full + // round-trip the speculation existed to skip. Stand down and let it land; `apply` + // validates via the same signature. Any divergence falls through to the normal + // reschedule, whose newer work id retires the speculation automatically. + if let expected = pendingSpeculativeSignature, + currentContext?.contentSignature == expected { + logStage( + "speculation-validated", + workID: currentWorkID, + generation: latestGenerationNumber, + message: "Host published exactly the speculated text; keeping the in-flight generation." + ) + return + } + pendingSpeculativeSignature = nil schedulePrediction( consumedDelayMilliseconds: Self.elapsedMilliseconds(since: baseline.keystrokeUptimeNanoseconds) ) diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift index 5b0ae7aa..bcb83d3b 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator+Prediction.swift @@ -15,6 +15,9 @@ extension SuggestionCoordinator { static let freshSnapshotReuseWindowMilliseconds = 30 func schedulePrediction(consumedDelayMilliseconds: Int = 0) { + // Any normal reschedule supersedes an outstanding speculative bet (its work id retires the + // in-flight task; this retires the signature exemption so a late result cannot sneak in). + pendingSpeculativeSignature = nil if let disabledReason = currentDisabledReason(focusSnapshot: focusModel.snapshot) { disablePredictions(reason: disabledReason) return @@ -98,6 +101,11 @@ extension SuggestionCoordinator { } let context = interactionState.materializeContext(from: rawContext) + // A cached suggestion consistent with the live text re-shows instantly: no debounce paid, + // no model run. Covers backspace rollback, type-through re-entry, and field return. + if restoreSuggestionFromAnchorCache(context: context, workID: workID) { + return + } // Screen Recording is optional. Re-check it live so a cached excerpt captured before the user // revoked the permission can never be injected during the 2s permission-poll window. let visualContextSummary = permissionManager.screenRecordingGranted @@ -129,6 +137,128 @@ extension SuggestionCoordinator { dispatchGeneration(request: request, workID: workID) } + /// Re-shows the freshest cached suggestion consistent with the live text, if any survives the + /// same display guards a fresh generation passes. Returns true when a suggestion was restored + /// (the caller skips generation entirely). The win is exactly the common editing moments: + /// deleting a typo, retyping suggested words after an invalidation, returning to a field. + private func restoreSuggestionFromAnchorCache(context: FocusedInputContext, workID: UInt64) -> Bool { + guard !userDefaults.bool(forKey: Self.anchorReuseDisabledDefaultsKey) else { return false } + guard context.selection.length == 0, !context.isSecure else { return false } + guard let remainder = suggestionAnchorCache.remainder( + identityKey: context.focusedInputIdentityKey, + precedingText: context.precedingText + ), !remainder.isEmpty else { return false } + + // Same display guards `apply` enforces on a fresh result. The remainder is a suffix of a + // suggestion that already passed the normalizer and seam guard for this exact text path, + // so only the guards that depend on CURRENT field state need re-checking. + if TrailingDuplicationFilter.duplicatesTrailingText(remainder, trailingText: context.trailingText) { + return false + } + if let pendingAcceptedTail = lastAcceptedTail, + SuggestionSessionReconciler.isStaleAcceptanceEcho( + resultText: remainder, + acceptedChunk: pendingAcceptedTail.text, + currentPrecedingText: context.precedingText, + acceptedPrecedingText: pendingAcceptedTail.precedingText + ) { + return false + } + + lastAcceptedTail = nil + latestGenerationNumber = context.generation + latestLatencyMilliseconds = 0 + let session = interactionState.startSession( + fullText: remainder, + liveContext: context, + latency: 0 + ) + applySessionDiagnostics(session, acceptanceAction: "Restored a cached suggestion.") + state = .ready(text: session.remainingText, latency: session.latency) + presentOverlay( + text: session.remainingText, + at: context.caretRect, + context: context, + isRightToLeft: TextDirectionDetector.isRightToLeft(context.precedingText) + ) + logStage( + "anchor-restore", + workID: workID, + generation: context.generation, + message: "Re-showed a cached suggestion without regenerating.", + normalizedOutput: remainder + ) + return true + } + + /// Starts the next generation immediately after a final-chunk accept, against the snapshot + /// the host is expected to publish, instead of idling through the publish poll first. The + /// poll keeps running as the validator: a matching publish lets this result through + /// (`pendingSpeculativeSignature` in `apply`), a mismatch schedules a normal regeneration + /// whose newer work id retires this one automatically. + func dispatchSpeculativePostAcceptanceGeneration( + rawContext: FocusedInputSnapshot, + insertionChunk: String + ) { + guard !userDefaults.bool(forKey: Self.speculativePrefetchDisabledDefaultsKey) else { return } + guard !insertionChunk.isEmpty else { return } + + let optimistic = SpeculativeAcceptanceContext.optimisticSnapshot( + after: rawContext, + inserting: insertionChunk + ) + + // Same pre-generation gates the ordinary cycle applies, minus their UI side effects: a + // speculative request must not spend a decode on text the normal path would refuse (too + // little text) or suppress (typo gate). The post-publish regeneration still runs the full + // gate with its correction semantics; declining here only skips the speculation. + guard SuggestionRequestFactory.shouldGenerateSuggestion(for: optimistic.precedingText) else { + return + } + if settingsSnapshot.suppressCompletionsOnTypo, + let trailingWord = CurrentWordExtractor.extractTrailingWord(from: optimistic.precedingText)?.result.word, + spellChecker.isTypo(trailingWord) { + return + } + + let context = interactionState.materializeContext(from: optimistic) + pendingSpeculativeSignature = context.contentSignature + + let visualContextSummary = permissionManager.screenRecordingGranted + ? visualContextCoordinator.excerpt(for: context) + : nil + // The pinned clipboard verdict, not a fresh filter pass: a speculative request that + // re-evaluated relevance against the optimistic prefix could flip the verdict and rewrite + // the prompt head mid-session, breaking prompt-byte continuity with the ordinary cycle + // (and the llama KV prefix reuse that depends on it). + let clipboardContext = pinnedClipboardContext(rawContext: optimistic) + let requestBuildResult = SuggestionRequestFactory.buildRequest( + context: context, + settings: settingsSnapshot, + configuration: configuration, + clipboardContext: clipboardContext, + visualContextSummary: visualContextSummary + ) + latestGenerationNumber = context.generation + latestPromptPreview = requestBuildResult.promptPreview + latestRawModelOutput = nil + let request = requestBuildResult.request + latestRequestID = request.requestID + + let workID = workController.replaceDebouncedWork(delayMilliseconds: 0) { [weak self] workID in + guard let self else { return } + self.dispatchGeneration(request: request, workID: workID) + } + state = .generating + logStage( + "speculative-generating", + workID: workID, + generation: context.generation, + message: "Started the post-acceptance generation against the expected post-insert text.", + prompt: requestBuildResult.promptPreview + ) + } + /// Runs the engine generation for `request` as the replaceable work for `workID`, applying the /// result (or failure) only while it is still the current work. Extracted from /// `generateFromCurrentFocus` so that function stays within the project's complexity budget. @@ -464,6 +594,34 @@ extension SuggestionCoordinator { ) } + /// Empty-result bookkeeping for `apply`, extracted to keep that function inside the + /// complexity budget as its guard chain grew. + private func discardEmptyResult(_ result: SuggestionResult, workID: UInt64) { + clearSuggestion() + hideOverlay(reason: "Overlay hidden because the model returned an empty continuation.") + state = .idle + // The router already counted engine-attributed suppressions (normalizer, confidence + // floor); only the unattributed "model produced nothing" case needs a ledger entry. + if result.suppressionReason == nil { + qualityMetricsStore.recordSuppressed(reason: "emptyUnattributed") + } + logStage( + "empty-result", + workID: workID, + generation: result.generation, + message: "Model returned an empty or whitespace-only continuation after normalization.", + rawOutput: result.rawText, + normalizedOutput: result.text + ) + } + + private static func seamSuppressionReason(for verdict: CompletionSeamGuard.Verdict) -> String { + if case .seamMisspelling = verdict { + return "seamMisspelling" + } + return "seamJunkPunctuationRun" + } + /// Promotes a generated result to `ready` only when it is still fresh for the current field. func apply(result: SuggestionResult, workID: UInt64) async { @@ -499,8 +657,18 @@ 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. - guard liveContext.generation == result.generation else { + // thinking, we drop the answer instead of showing a suggestion for old content. One + // exception: a speculative post-acceptance generation was built against text the host had + // not published yet, so its generation predates the live one by construction. When the + // live content now matches the signature the speculation was built against, the bet paid + // off and the result is exactly current. + let isPaidOffSpeculation = pendingSpeculativeSignature != nil + && pendingSpeculativeSignature == liveContext.contentSignature + if isPaidOffSpeculation { + pendingSpeculativeSignature = nil + } + + guard isPaidOffSpeculation || liveContext.generation == result.generation else { latestRawModelOutput = SuggestionDebugLogger.debugPreview(result.rawText) // Lifecycle discards are counted under their own reasons so `generated` always equals @@ -522,22 +690,7 @@ extension SuggestionCoordinator { latestRawModelOutput = SuggestionDebugLogger.debugPreview(result.rawText) guard !result.text.isEmpty else { - clearSuggestion() - hideOverlay(reason: "Overlay hidden because the model returned an empty continuation.") - state = .idle - // The router already counted engine-attributed suppressions (normalizer, confidence - // floor); only the unattributed "model produced nothing" case needs a ledger entry. - if result.suppressionReason == nil { - qualityMetricsStore.recordSuppressed(reason: "emptyUnattributed") - } - logStage( - "empty-result", - workID: workID, - generation: result.generation, - message: "Model returned an empty or whitespace-only continuation after normalization.", - rawOutput: result.rawText, - normalizedOutput: result.text - ) + discardEmptyResult(result, workID: workID) return } @@ -594,8 +747,7 @@ extension SuggestionCoordinator { clearSuggestion() hideOverlay(reason: "Overlay hidden because the completion failed the seam guard.") state = .idle - let seamReason = if case .seamMisspelling = seamVerdict { "seamMisspelling" } else { "seamJunkPunctuationRun" } - qualityMetricsStore.recordSuppressed(reason: seamReason) + qualityMetricsStore.recordSuppressed(reason: Self.seamSuppressionReason(for: seamVerdict)) logStage( "seam-suppressed", workID: workID, @@ -612,6 +764,11 @@ extension SuggestionCoordinator { // One shown event per suggestion: this is the only place a fresh generation becomes // visible (re-presentations after partial accepts reuse the same session). qualityMetricsStore.recordShown() + suggestionAnchorCache.record( + identityKey: liveContext.focusedInputIdentityKey, + precedingText: liveContext.precedingText, + fullText: result.text + ) let session = interactionState.startSession( fullText: result.text, liveContext: liveContext, @@ -929,6 +1086,7 @@ extension SuggestionCoordinator { /// Cancels debounce/generation tasks and advances the work id so late completions are ignored. func cancelPredictionWork() { + pendingSpeculativeSignature = nil hostPublishPollGeneration &+= 1 workController.cancelAll() } diff --git a/Cotabby/App/Coordinators/SuggestionCoordinator.swift b/Cotabby/App/Coordinators/SuggestionCoordinator.swift index 18733160..2282505c 100644 --- a/Cotabby/App/Coordinators/SuggestionCoordinator.swift +++ b/Cotabby/App/Coordinators/SuggestionCoordinator.swift @@ -133,6 +133,18 @@ final class SuggestionCoordinator: ObservableObject { /// accept on the last word. See `SuggestionSessionReconciler.isStaleAcceptanceEcho`. var lastAcceptedTail: AcceptedSuggestionTail? + /// Bounded string-only memory of recent suggestions for instant re-show on rollback and + /// re-entry (see `SuggestionAnchorCache`). `cotabbyAnchorReuseDisabled` is the kill switch. + var suggestionAnchorCache = SuggestionAnchorCache() + static let anchorReuseDisabledDefaultsKey = "cotabbyAnchorReuseDisabled" + static let speculativePrefetchDisabledDefaultsKey = "cotabbySpeculativePrefetchDisabled" + + /// Content signature a speculative post-acceptance generation was built against. While set, + /// `apply` may accept a result whose generation predates the live one as long as the live + /// content matches this signature (the speculation bet paid off), and the host-publish poll + /// stands down instead of scheduling a duplicate regeneration. + var pendingSpeculativeSignature: String? + /// Monotonic token for the post-exhaustion "keep owning Tab" window. Bumped on every arm so a /// stale backstop timer (or a window superseded by a newer accept) no-ops instead of releasing a /// window it no longer owns. See `armPostExhaustionAcceptance`. diff --git a/Cotabby/Support/SpeculativeAcceptanceContext.swift b/Cotabby/Support/SpeculativeAcceptanceContext.swift new file mode 100644 index 00000000..fc84a303 --- /dev/null +++ b/Cotabby/Support/SpeculativeAcceptanceContext.swift @@ -0,0 +1,50 @@ +import Foundation + +/// Builds the focus snapshot the host is EXPECTED to publish after Cotabby inserts a final +/// accepted chunk: same field, preceding text extended by exactly what was typed, caret advanced +/// by its UTF-16 length. +/// +/// Why this exists: after the final chunk of a suggestion is accepted, the next generation +/// otherwise waits for the host to publish the insert over Accessibility (10-400ms of polling) +/// before it can even start. Cotabby knows precisely what it just typed, so it can start the +/// next generation against this optimistic snapshot immediately and use the eventual publish as +/// validation: if the host's published content matches this snapshot's content signature, the +/// speculative result is exactly current; if anything differs (autocorrect, IME transformation, +/// a sliding context window), the signature mismatch drops the speculation and the normal +/// poll-driven regeneration takes over. Wrong speculation costs one discarded generation; right +/// speculation removes the publish wait plus a debounce from the visible gap. +nonisolated enum SpeculativeAcceptanceContext { + static func optimisticSnapshot( + after snapshot: FocusedInputSnapshot, + inserting insertionChunk: String + ) -> FocusedInputSnapshot { + let insertedUTF16Count = insertionChunk.utf16.count + return FocusedInputSnapshot( + applicationName: snapshot.applicationName, + bundleIdentifier: snapshot.bundleIdentifier, + processIdentifier: snapshot.processIdentifier, + elementIdentifier: snapshot.elementIdentifier, + role: snapshot.role, + subrole: snapshot.subrole, + caretRect: snapshot.caretRect, + inputFrameRect: snapshot.inputFrameRect, + caretSource: snapshot.caretSource, + caretQuality: snapshot.caretQuality, + observedCharWidth: snapshot.observedCharWidth, + observedContentEdges: snapshot.observedContentEdges, + precedingText: snapshot.precedingText + insertionChunk, + trailingText: snapshot.trailingText, + selection: NSRange( + location: snapshot.selection.location + insertedUTF16Count, + length: 0 + ), + isSecure: snapshot.isSecure, + isIntegratedTerminal: snapshot.isIntegratedTerminal, + focusChangeSequence: snapshot.focusChangeSequence, + focusedURLString: snapshot.focusedURLString, + resolvedFieldStyle: snapshot.resolvedFieldStyle, + windowTitle: snapshot.windowTitle, + fieldPlaceholder: snapshot.fieldPlaceholder + ) + } +} diff --git a/Cotabby/Support/SuggestionAnchorCache.swift b/Cotabby/Support/SuggestionAnchorCache.swift new file mode 100644 index 00000000..a87831b5 --- /dev/null +++ b/Cotabby/Support/SuggestionAnchorCache.swift @@ -0,0 +1,142 @@ +import Foundation + +/// One remembered suggestion, anchored to the text that preceded it. +struct SuggestionAnchor: Equatable { + /// `FocusedInputContext.focusedInputIdentityKey` of the field the suggestion belonged to. + let identityKey: UInt64 + /// The tail of `precedingText` at generation time (bounded; see `prefixTailLength`). + let prefixTail: String + /// The full normalized suggestion that was shown. + let fullText: String +} + +/// Bounded, string-only memory of recent suggestions so common editing moments can re-show a +/// known-good suggestion instantly instead of paying debounce plus a full model round-trip: +/// +/// - **Backspace rollback**: deleting a typo restores the caret to a position a cached suggestion +/// already covered. +/// - **Type-through re-entry**: typing exactly the suggested characters after the session was +/// invalidated for an unrelated reason (focus bounce, shortcut) lands back inside it. +/// - **Field return**: coming back to a field whose text has not moved. +/// +/// One match rule covers all three: the live preceding-text tail must equal a cached anchor's +/// tail plus the first `k` characters of its suggestion, for any `k` short of the whole +/// suggestion; the remainder is what is left to show. `k` strictly less than the full length +/// keeps a fully-consumed suggestion from re-offering its own tail right after acceptance. +/// +/// Pure logic with an injected clock; entries expire so a stale suggestion cannot resurface after +/// the document changed elsewhere (the caller additionally re-checks display guards on restore). +nonisolated struct SuggestionAnchorCache { + /// Characters of preceding-text tail stored per anchor. Long enough that an accidental + /// cross-field or cross-paragraph collision is implausible; short enough to keep matching and + /// memory trivial. + static let prefixTailLength = 256 + static let capacity = 16 + static let maxEntryAge: TimeInterval = 180 + + private struct Entry { + let anchor: SuggestionAnchor + let recordedAt: Date + } + + private var entries: [Entry] = [] + private let now: () -> Date + + init(now: @escaping () -> Date = Date.init) { + self.now = now + } + + /// Remembers one suggestion. The newest entry wins ties and duplicates are replaced, so a + /// regenerated identical suggestion refreshes its expiry instead of crowding the cache. + mutating func record(identityKey: UInt64, precedingText: String, fullText: String) { + guard !fullText.isEmpty else { return } + let anchor = SuggestionAnchor( + identityKey: identityKey, + prefixTail: Self.tail(of: precedingText), + fullText: fullText + ) + entries.removeAll { $0.anchor == anchor } + entries.append(Entry(anchor: anchor, recordedAt: now())) + if entries.count > Self.capacity { + entries.removeFirst(entries.count - Self.capacity) + } + } + + private struct Match { + let remainder: String + let consumed: Int + let recordedAt: Date + + func beats(_ other: Match?) -> Bool { + guard let other else { return true } + if consumed != other.consumed { return consumed > other.consumed } + return recordedAt > other.recordedAt + } + } + + /// The unshown remainder of the freshest cached suggestion consistent with the live preceding + /// text, or nil. Longest consumed prefix wins when several anchors match, so the restore + /// resumes from exactly where the user is rather than re-showing already-typed words. + mutating func remainder(identityKey: UInt64, precedingText: String) -> String? { + pruneExpired() + let liveTail = Self.tail(of: precedingText) + + var best: Match? + for entry in entries.reversed() where entry.anchor.identityKey == identityKey { + guard let consumed = Self.consumedPrefixLength( + liveTail: liveTail, + anchorTail: entry.anchor.prefixTail, + fullText: entry.anchor.fullText + ) else { continue } + let match = Match( + remainder: String(entry.anchor.fullText.dropFirst(consumed)), + consumed: consumed, + recordedAt: entry.recordedAt + ) + if match.beats(best) { + best = match + } + } + return best?.remainder + } + + mutating func removeAll() { + entries.removeAll() + } + + /// `k` such that liveTail == anchorTail + fullText.prefix(k) (tail-bounded comparison), with + /// `0 <= k < fullText.count`; nil when the live text is not on the anchor's path. + /// + /// The character buffers are built once per entry and every candidate window is then an + /// allocation-free slice comparison; the earlier form built a fresh + /// `tail(anchorTail + fullText.prefix(k))` string for every k of every scanned entry, which + /// is O(n) heap allocations per entry on a scan that visits up to the full cache. + private static func consumedPrefixLength( + liveTail: String, + anchorTail: String, + fullText: String + ) -> Int? { + let live = Array(liveTail) + let composed = Array(anchorTail) + Array(fullText) + let anchorCount = anchorTail.count + + for consumed in 0 ..< max(0, composed.count - anchorCount) { + let end = anchorCount + consumed + let start = max(0, end - prefixTailLength) + guard end - start == live.count else { continue } + if composed[start ..< end].elementsEqual(live) { + return consumed + } + } + return nil + } + + private mutating func pruneExpired() { + let cutoff = now().addingTimeInterval(-Self.maxEntryAge) + entries.removeAll { $0.recordedAt < cutoff } + } + + private static func tail(of text: String) -> String { + String(text.suffix(prefixTailLength)) + } +} diff --git a/CotabbyTests/SpeculativeAcceptanceContextTests.swift b/CotabbyTests/SpeculativeAcceptanceContextTests.swift new file mode 100644 index 00000000..8e3ed75b --- /dev/null +++ b/CotabbyTests/SpeculativeAcceptanceContextTests.swift @@ -0,0 +1,43 @@ +import XCTest +@testable import Cotabby + +/// The optimistic snapshot must reproduce, field for field, what the host is expected to publish +/// after the insert: same identity and geometry, preceding text extended by exactly the inserted +/// chunk, caret advanced by its UTF-16 length. Its content signature is the validation token the +/// speculation machinery compares against the real publish. +final class SpeculativeAcceptanceContextTests: XCTestCase { + func testAppendsInsertionAndAdvancesCaret() { + let base = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hello") + let optimistic = SpeculativeAcceptanceContext.optimisticSnapshot(after: base, inserting: " world") + + XCTAssertEqual(optimistic.precedingText, "Hello world") + XCTAssertEqual(optimistic.selection.location, base.selection.location + " world".utf16.count) + XCTAssertEqual(optimistic.selection.length, 0) + XCTAssertEqual(optimistic.trailingText, base.trailingText) + XCTAssertEqual(optimistic.elementIdentifier, base.elementIdentifier) + XCTAssertEqual(optimistic.focusChangeSequence, base.focusChangeSequence) + } + + func testUTF16AdvanceCountsSurrogatePairs() { + let base = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Nice ") + let optimistic = SpeculativeAcceptanceContext.optimisticSnapshot(after: base, inserting: "🎉🎉") + XCTAssertEqual(optimistic.selection.location, base.selection.location + 4) + } + + func testSignatureMatchesAnIdenticalRealPublish() { + let base = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hello") + let optimistic = SpeculativeAcceptanceContext.optimisticSnapshot(after: base, inserting: " world") + let published = CotabbyTestFixtures.focusedInputSnapshot( + precedingText: "Hello world", + selection: NSRange(location: optimistic.selection.location, length: 0) + ) + XCTAssertEqual(optimistic.contentSignature, published.contentSignature) + } + + func testSignatureDiffersWhenHostTransformedTheText() { + let base = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hello") + let optimistic = SpeculativeAcceptanceContext.optimisticSnapshot(after: base, inserting: " world") + let autocorrected = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hello World") + XCTAssertNotEqual(optimistic.contentSignature, autocorrected.contentSignature) + } +} diff --git a/CotabbyTests/SuggestionAnchorCacheTests.swift b/CotabbyTests/SuggestionAnchorCacheTests.swift new file mode 100644 index 00000000..af334a27 --- /dev/null +++ b/CotabbyTests/SuggestionAnchorCacheTests.swift @@ -0,0 +1,95 @@ +import XCTest +@testable import Cotabby + +/// Pins the single match rule that powers instant re-shows: live tail == anchor tail + the first +/// `k` suggestion characters, `k` strictly short of the whole suggestion, freshest and deepest +/// match first. +final class SuggestionAnchorCacheTests: XCTestCase { + private var clock: Date = .init(timeIntervalSince1970: 1_000_000) + private func makeCache() -> SuggestionAnchorCache { + SuggestionAnchorCache(now: { self.clock }) + } + + func testFreshAnchorMatchesAtZeroConsumed() { + var cache = makeCache() + cache.record(identityKey: 1, precedingText: "Hello", fullText: " world again") + XCTAssertEqual(cache.remainder(identityKey: 1, precedingText: "Hello"), " world again") + } + + func testTypeThroughConsumesPrefix() { + var cache = makeCache() + cache.record(identityKey: 1, precedingText: "Hello", fullText: " world again") + XCTAssertEqual(cache.remainder(identityKey: 1, precedingText: "Hello wo"), "rld again") + } + + func testBackspaceRollbackRestoresEarlierPosition() { + var cache = makeCache() + cache.record(identityKey: 1, precedingText: "Hello", fullText: " world again") + // The user typed " worl", then backspaced twice to "Hello wo". + XCTAssertEqual(cache.remainder(identityKey: 1, precedingText: "Hello wo"), "rld again") + XCTAssertEqual(cache.remainder(identityKey: 1, precedingText: "Hello"), " world again") + } + + func testFullyConsumedSuggestionNeverReoffersItsTail() { + var cache = makeCache() + cache.record(identityKey: 1, precedingText: "Hello", fullText: " world") + XCTAssertNil( + cache.remainder(identityKey: 1, precedingText: "Hello world"), + "k must stay strictly below the suggestion length" + ) + } + + func testDivergentTypingDoesNotMatch() { + var cache = makeCache() + cache.record(identityKey: 1, precedingText: "Hello", fullText: " world again") + XCTAssertNil(cache.remainder(identityKey: 1, precedingText: "Hello wa")) + } + + func testDifferentFieldDoesNotMatch() { + var cache = makeCache() + cache.record(identityKey: 1, precedingText: "Hello", fullText: " world") + XCTAssertNil(cache.remainder(identityKey: 2, precedingText: "Hello")) + } + + func testDeepestConsumedMatchWins() { + var cache = makeCache() + cache.record(identityKey: 1, precedingText: "Hello", fullText: " world again") + cache.record(identityKey: 1, precedingText: "Hello wo", fullText: "rld forever") + // Both anchors are consistent with "Hello wor": the first at k=4, the second at k=1. + // The deeper consumed prefix is the first anchor, resuming closest to the caret. + XCTAssertEqual(cache.remainder(identityKey: 1, precedingText: "Hello wor"), "ld again") + } + + func testEntriesExpire() { + var cache = makeCache() + cache.record(identityKey: 1, precedingText: "Hello", fullText: " world") + clock = clock.addingTimeInterval(SuggestionAnchorCache.maxEntryAge + 1) + XCTAssertNil(cache.remainder(identityKey: 1, precedingText: "Hello")) + } + + func testCapacityEvictsOldest() { + var cache = makeCache() + for index in 0 ..< (SuggestionAnchorCache.capacity + 4) { + cache.record(identityKey: 1, precedingText: "prefix \(index)", fullText: "suffix \(index)") + } + XCTAssertNil(cache.remainder(identityKey: 1, precedingText: "prefix 0")) + XCTAssertEqual( + cache.remainder(identityKey: 1, precedingText: "prefix \(SuggestionAnchorCache.capacity + 3)"), + "suffix \(SuggestionAnchorCache.capacity + 3)" + ) + } + + func testLongPrefixesMatchOnTheBoundedTail() { + var cache = makeCache() + let longPrefix = String(repeating: "a", count: 2000) + " ending here" + cache.record(identityKey: 1, precedingText: longPrefix, fullText: " and more") + XCTAssertEqual(cache.remainder(identityKey: 1, precedingText: longPrefix + " and"), " more") + } + + func testRemoveAllEmptiesTheCache() { + var cache = makeCache() + cache.record(identityKey: 1, precedingText: "Hello", fullText: " world") + cache.removeAll() + XCTAssertNil(cache.remainder(identityKey: 1, precedingText: "Hello")) + } +} diff --git a/CotabbyTests/SuggestionCoordinatorAcceptanceTests.swift b/CotabbyTests/SuggestionCoordinatorAcceptanceTests.swift index 3b701689..a66ad17a 100644 --- a/CotabbyTests/SuggestionCoordinatorAcceptanceTests.swift +++ b/CotabbyTests/SuggestionCoordinatorAcceptanceTests.swift @@ -131,9 +131,11 @@ final class SuggestionCoordinatorAcceptanceTests: XCTestCase { XCTAssertTrue(coordinator.acceptCurrentSuggestion()) XCTAssertEqual(inserter.insertedChunks, [" today"]) - // The final-chunk accept must not immediately re-enter debouncing. It waits for the host - // to publish the insert, so synchronously the coordinator is idle with the overlay hidden. - XCTAssertEqual(coordinator.state, .idle) + // The final-chunk accept starts the continuation immediately against the text the host + // is about to publish (speculative prefetch) instead of idling through the publish + // poll; the overlay still hides until that result lands and validates. + XCTAssertEqual(coordinator.state, .generating) + XCTAssertNotNil(coordinator.pendingSpeculativeSignature) XCTAssertFalse(coordinator.overlayState.isVisible) // It records what it committed so `apply` can drop a stale echo of the same tail. XCTAssertEqual( @@ -281,6 +283,98 @@ final class SuggestionCoordinatorAcceptanceTests: XCTestCase { } } + /// A cached suggestion consistent with the live text must re-show without any engine call. + /// The stub engine throws on every generation, so reaching `.ready` proves the restore path + /// satisfied the prediction cycle on its own. + @MainActor + func test_anchorCacheRestoresSuggestionWithoutGenerating() async { + let snapshot = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hello wo") + let interactionState = SuggestionInteractionState() + let coordinator = makeCoordinator( + snapshot: snapshot, + overlayState: .hidden(reason: "test"), + inputMonitor: StubSuggestionInputMonitor(), + inserter: StubSuggestionInserter(), + interactionState: interactionState + ) + let identityKey = FocusedInputContext(snapshot: snapshot, generation: 1).focusedInputIdentityKey + // The suggestion was generated when the field held "Hello"; the user has since typed + // " wo", which is exactly the suggestion's first three characters. + coordinator.suggestionAnchorCache.record( + identityKey: identityKey, + precedingText: "Hello", + fullText: " world again" + ) + + await coordinator.generateFromCurrentFocus(workID: coordinator.currentWorkID) + + guard case let .ready(text, latency) = coordinator.state else { + XCTFail("Expected a restored suggestion, got \(coordinator.state)") + return + } + XCTAssertEqual(text, "rld again") + XCTAssertEqual(latency, 0) + XCTAssertEqual(interactionState.activeSession?.fullText, "rld again") + } + + /// A speculative post-acceptance result carries a generation older than the live one by + /// construction; it must still apply when the live content matches the signature it was + /// built against, and must consume the exemption. + @MainActor + func test_applyAcceptsSpeculativeResultWhenContentSignatureMatches() async { + let snapshot = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hello world ") + let interactionState = SuggestionInteractionState() + let coordinator = makeCoordinator( + snapshot: snapshot, + overlayState: .hidden(reason: "test"), + inputMonitor: StubSuggestionInputMonitor(), + inserter: StubSuggestionInserter(), + interactionState: interactionState + ) + coordinator.pendingSpeculativeSignature = + FocusedInputContext(snapshot: snapshot, generation: 1).contentSignature + let speculativeResult = SuggestionResult( + generation: 999, + rawText: "from here on", + text: "from here on", + latency: 0.1 + ) + + await coordinator.apply(result: speculativeResult, workID: coordinator.currentWorkID) + + guard case let .ready(text, _) = coordinator.state else { + XCTFail("Expected the speculative result to apply, got \(coordinator.state)") + return + } + XCTAssertEqual(text, "from here on") + XCTAssertNil(coordinator.pendingSpeculativeSignature, "the exemption is single-use") + } + + /// Without the signature exemption, a stale-generation result must keep being dropped. + @MainActor + func test_applyStillDropsStaleResultsWithoutSpeculativeSignature() async { + let snapshot = CotabbyTestFixtures.focusedInputSnapshot(precedingText: "Hello world ") + let coordinator = makeCoordinator( + snapshot: snapshot, + overlayState: .hidden(reason: "test"), + inputMonitor: StubSuggestionInputMonitor(), + inserter: StubSuggestionInserter(), + interactionState: SuggestionInteractionState() + ) + let staleResult = SuggestionResult( + generation: 999, + rawText: "from here on", + text: "from here on", + latency: 0.1 + ) + + await coordinator.apply(result: staleResult, workID: coordinator.currentWorkID) + + if case .ready = coordinator.state { + XCTFail("A stale result with no speculative exemption must not apply") + } + } + @MainActor private func makeCoordinator( snapshot: FocusedInputSnapshot,