Repository navigation
Cut keystroke-to-ghost latency on the llama engine to under 60 ms #875
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
10be773
bd94e2c
04cb464
d38b89e
3d84ef6
f5d6b35
0943522
fe25581
2978ef0
9e8468c
b4c358a
f7d0bfe
223a66c
5f74c24
f040acf
96d406a
f6f74a2
39c9f71
4212ad2
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Large diffs are not rendered by default.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,12 +26,14 @@ extension SuggestionCoordinator { | |
| return | ||
| } | ||
|
|
||
| // The debounce window adapts to the last generation latency: snappier when the model is | ||
| // fast, calmer when it is slow (fewer doomed generations to cancel). The configured value | ||
| // is the fallback until a first latency exists. | ||
| // The debounce window adapts to how quickly the engine produces text: none when a warm | ||
| // request shows its first text fast, calmer when the model is slow (fewer doomed | ||
| // generations to cancel). The configured value is the fallback until a latency exists. | ||
| let debounceMilliseconds = DebouncePolicy.milliseconds( | ||
| lastGenerationLatencyMilliseconds: | ||
| lastLatencyByEngine[settingsSnapshot.selectedEngine], | ||
| lastFirstTextLatencyMilliseconds: | ||
| typicalFirstTextLatency(engine: settingsSnapshot.selectedEngine), | ||
| fallback: settingsSnapshot.debounceMilliseconds, | ||
| engine: settingsSnapshot.selectedEngine | ||
| ) | ||
|
|
@@ -252,11 +254,46 @@ extension SuggestionCoordinator { | |
| guard !userDefaults.bool(forKey: Self.speculativePrefetchDisabledDefaultsKey) else { return } | ||
| guard !insertionChunk.isEmpty else { return } | ||
|
|
||
| let optimistic = SpeculativeAcceptanceContext.optimisticSnapshot( | ||
| after: rawContext, | ||
| inserting: insertionChunk | ||
| dispatchSpeculativeGeneration( | ||
| optimistic: SpeculativeAcceptanceContext.optimisticSnapshot(after: rawContext, inserting: insertionChunk), | ||
| message: "Started the post-acceptance generation against the expected post-insert text." | ||
| ) | ||
| } | ||
|
|
||
| /// Starts generating for a plain typed character at keystroke time, against the text the host | ||
| /// is about to publish, instead of after the host-publish poll sees it (10-50 ms in native | ||
| /// hosts, longer in Chromium). The poll that follows validates exactly as it does for | ||
| /// post-acceptance speculation: a matching publish keeps this request, anything else (smart | ||
| /// quotes, dead keys, IME, auto-pairing, a key the host had not yet applied to the snapshot) | ||
| /// schedules the ordinary request and retires this one. Results and streamed partials are only | ||
| /// shown once the live text matches, so a wrong bet costs one discarded generation, never a | ||
| /// ghost at the wrong caret. | ||
| /// | ||
| /// Endpoint requests keep their debounced path: speculative traffic must not multiply requests | ||
| /// to a configured server. | ||
| /// | ||
| /// `knownPrecedingText` is the text before the caret as Cotabby already knows it when the | ||
| /// captured snapshot lags: typing through a visible suggestion advances the session without a | ||
| /// new AX capture, so the snapshot can still miss those letters. It is used only when it | ||
| /// extends the snapshot's own text. | ||
| func dispatchSpeculativeTypingGeneration( | ||
| rawContext: FocusedInputSnapshot, typed characters: String, knownPrecedingText: String? = nil | ||
| ) { | ||
| guard settingsSnapshot.selectedEngine != .openAICompatible, | ||
| !userDefaults.bool(forKey: Self.speculativePrefetchDisabledDefaultsKey), | ||
| characters.count == 1, let typed = characters.first, !typed.isNewline, | ||
| rawContext.selection.length == 0, !rawContext.isSecure else { return } | ||
| let base = knownPrecedingText.flatMap { $0.hasPrefix(rawContext.precedingText) ? $0 : nil } | ||
| ?? rawContext.precedingText | ||
| dispatchSpeculativeGeneration( | ||
| optimistic: SpeculativeAcceptanceContext.optimisticSnapshot(after: rawContext, precedingText: base + characters), | ||
| message: "Started generating against the typed character before the host published it." | ||
|
Comment on lines
+288
to
+290
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A typing request can finish before the host publishes the character. Keep the finished result until publication, or restart the request when it is dropped. Knowledge Base Used: Suggestion session coordination |
||
| ) | ||
| } | ||
|
|
||
| /// Shared body of the speculative paths: gate, build the request for `optimistic`, and run it | ||
| /// with no debounce while `pendingSpeculativeContext` lets the publish poll validate it. | ||
| private func dispatchSpeculativeGeneration(optimistic: FocusedInputSnapshot, message: String) { | ||
| // 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 | ||
|
|
@@ -306,7 +343,7 @@ extension SuggestionCoordinator { | |
| "speculative-generating", | ||
| workID: workID, | ||
| generation: context.generation, | ||
| message: "Started the post-acceptance generation against the expected post-insert text.", | ||
| message: message, | ||
| prompt: requestBuildResult.promptPreview | ||
| ) | ||
| } | ||
|
|
@@ -576,7 +613,25 @@ extension SuggestionCoordinator { | |
| } | ||
| return rebased | ||
| } | ||
| return liveContext.generation == partial.generation ? partial : nil | ||
| if liveContext.generation == partial.generation { return partial } | ||
| // A speculative request was built against text the host had not published yet, so any | ||
| // read of the pre-publish snapshot moves the live generation past the partial's. Like | ||
| // `apply`, accept it by content instead: once the live field reads exactly what was | ||
| // speculated, the partial is current; before that it stays hidden. | ||
| if let expected = pendingSpeculativeContext, | ||
| expected.sessionIdentity == liveContext.sessionIdentity, | ||
| expected.contentSignature == liveContext.contentSignature, | ||
| expected.generation == partial.generation { | ||
| return SuggestionResult( | ||
| generation: liveContext.generation, | ||
| rawText: partial.rawText, | ||
| text: partial.text, | ||
| latency: partial.latency, | ||
| suppressionReason: partial.suppressionReason, | ||
| spacingIsExact: partial.spacingIsExact | ||
| ) | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| /// Runs the typo gate for the current word. Returns `true` when it handled the cycle by suppressing, | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift
🔎 Supported by static analysis
🏁 Script executed:
Repository: FuJacob/cotabby
Length of output: 4477
🏁 Script executed:
Repository: FuJacob/cotabby
Length of output: 45669
🏁 Script executed:
Repository: FuJacob/cotabby
Length of output: 259
🏁 Script executed:
Repository: FuJacob/cotabby
Length of output: 26525
🏁 Script executed:
Repository: FuJacob/cotabby
Length of output: 42101
🏁 Script executed:
Repository: FuJacob/cotabby
Length of output: 6308
Retain completed speculative results until AX publication validates them.
dispatchSpeculativeGenerationstores the optimistic context before starting generation. If generation finishes while Accessibility still exposes the old text,waitForTypingPausecan materialize that old context and advance its generation.apply(result:)then fails both the speculative signature check and generation check, so it drops the result. When the host later publishes the expected text,pollForHostPublishreturns without scheduling another request. The ghost remains absent until another input.Keep the completed result pending until the matching publication arrives, or make the matching poll schedule a replacement when the speculative generation has already completed.
🤖 Prompt for AI Agents