diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-transition.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-transition.ts index c65f348996..ede7125a9c 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-transition.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-transition.ts @@ -19,6 +19,15 @@ import Reversion = LexicalModelTypes.Reversion; import Suggestion = LexicalModelTypes.Suggestion; import Transform = LexicalModelTypes.Transform; +export interface TransitionReversionView extends Pick { + /** + * Gets the context state resulting from the context transition event, + * including any generated suggestions and data regarding potential + * application thereof. + */ + final: Pick +} + /** * Represents the transition between two context states as triggered * by input keystrokes or applied suggestions. diff --git a/web/src/engine/predictive-text/worker-thread/src/main/model-compositor.ts b/web/src/engine/predictive-text/worker-thread/src/main/model-compositor.ts index 8e12b96ed4..1a514cad64 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/model-compositor.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/model-compositor.ts @@ -2,7 +2,7 @@ import * as models from '@keymanapp/models-templates'; import { LexicalModelTypes } from '@keymanapp/common-types'; import { TransformUtils } from './transformUtils.js'; -import { applySuggestionCasing, correctAndEnumerate, createDefaultKeep, dedupeSuggestions, finalizeSuggestions, predictionAutoSelect, processSimilarity, toAnnotatedSuggestion, tupleDisplayOrderSort } from './predict-helpers.js'; +import { applySuggestionCasing, correctAndEnumerate, createDefaultKeep, dedupeSuggestions, finalizeSuggestions, predictionAutoSelect, prependReversion, processSimilarity, toAnnotatedSuggestion, tupleDisplayOrderSort } from './predict-helpers.js'; import { detectCurrentCasing, determineModelTokenizer, determineModelWordbreaker, determinePunctuationFromModel } from './model-helpers.js'; import { ContextTracker } from './correction/context-tracker.js'; @@ -206,18 +206,8 @@ export class ModelCompositor { this.SUGGESTION_ID_SEED++; }); - if(revertableTransitionId) { - const reversion = this.contextTracker.peek(revertableTransitionId)?.reversion; - if(reversion) { - if(suggestions[0]?.tag == 'keep') { - const keep = suggestions.shift(); - suggestions.unshift(reversion); - suggestions.unshift(keep); - } else { - suggestions.unshift(reversion) - } - } - } + const transitionToRevert = this.contextTracker?.peek(revertableTransitionId); + prependReversion(suggestions, transitionToRevert); // Store the suggestions on the final token of the current context state (if it exists). // Or, once phrase-level suggestions are possible, on whichever token serves as each prediction's root. @@ -239,8 +229,8 @@ export class ModelCompositor { // Step 1: re-use the original input Transform as the reversion's Transform. // The Web engine will restore the original state of the context before accepting // and before reverting; all we need to do is put the original keystroke back in place. - let reversionTransform: Transform = originalInput - ? { ...originalInput } + let reversionTransform: Transform = originalInput + ? { ...originalInput } : { insert: '', deleteLeft: 0, id: suggestion.transform.id }; // Step 2: building the proper 'displayAs' string for the Reversion diff --git a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts index 5aa73200ee..9047e80a83 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts @@ -9,7 +9,7 @@ import { ContextTokenization } from './correction/context-tokenization.js'; import { ContextTracker } from './correction/context-tracker.js'; import { ContextToken } from './correction/context-token.js'; import { ContextState, determineContextSlideTransform } from './correction/context-state.js'; -import { ContextTransition } from './correction/context-transition.js'; +import { ContextTransition, TransitionReversionView } from './correction/context-transition.js'; import { ExecutionTimer } from './correction/execution-timer.js'; import { ModelCompositor } from './model-compositor.js'; import { getBestTokenMatches } from './correction/distance-modeler.js'; @@ -1196,4 +1196,35 @@ export function toAnnotatedSuggestion( } return result; +} + +/** + * For applicable scenarios, this mutates the passed-in suggestion array by + * prepending a predictive-text reversion that restores the context to a prior + * state. Otherwise, it leaves the suggestion array unaltered. + * @param suggestions + * @param transitionToRevert + * @returns + */ +export function prependReversion(suggestions: Suggestion[], transitionToRevert: TransitionReversionView) { + if(transitionToRevert) { + const reversion = transitionToRevert.reversion; + if(reversion) { + if(suggestions[0]?.tag == 'keep') { + const appliedId = -reversion.id; + const appliedSuggestion = transitionToRevert.final.suggestions.find((s) => s.id == appliedId); + // If the selected suggestion was itself a `keep`, we don't need a + // reversion. They'd do the same thing. + if(appliedSuggestion.tag != 'keep') { + const keep = suggestions.shift(); + suggestions.unshift(reversion); + suggestions.unshift(keep); + } + } else { + suggestions.unshift(reversion); + } + } + } + + return suggestions; } \ No newline at end of file diff --git a/web/src/engine/predictive-text/worker-thread/src/main/test-index.ts b/web/src/engine/predictive-text/worker-thread/src/main/test-index.ts index e0c5d513f9..bcaf4692bf 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/test-index.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/test-index.ts @@ -3,7 +3,7 @@ export * from './correction/context-state.js'; export * from './correction/context-token.js'; export * from './correction/context-tokenization.js'; export { ContextTracker } from './correction/context-tracker.js'; -export { ContextTransition } from './correction/context-transition.js'; +export * from './correction/context-transition.js'; export * from './correction/correction-searchable.js'; export * from './correction/correction-result-mapping.js'; export * from './correction/distance-modeler.js'; diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/prepend-reversion.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/prepend-reversion.tests.ts new file mode 100644 index 0000000000..5215705ed6 --- /dev/null +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/prepend-reversion.tests.ts @@ -0,0 +1,101 @@ + +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by jahorton on 2026-07-27 + * + * This file contains tests designed to ensure predictive text does not + * provide matching 'keep' and 'revert' suggestions in any context. + */ + + +import { assert } from 'chai'; + +import { LexicalModelTypes } from '@keymanapp/common-types'; +import { prependReversion, type TransitionReversionView } from "@keymanapp/lm-worker/test-index"; + +import Suggestion = LexicalModelTypes.Suggestion; + +describe('prependReversion', () => { + it(`prepends reversions when reverting a non-'keep' suggestion`, () => { + // context: Original was appl+u, corrected to apply. Reached via bksp. + const suggestions: Suggestion[] = [{ + tag: 'keep', + transform: { insert: 'apply', deleteLeft: 6, id: 3 }, + displayAs: '"apply"', + id: 5, + matchesModel: false + } as Suggestion]; + + const revertable: TransitionReversionView = { + reversion: { + tag: 'revert', + transform: { insert: 'u', deleteLeft: 0, id: 3 }, + id: -3, + displayAs: '"applu"' + }, + final: { + suggestions: [{ + tag: 'keep', + transform: { insert: 'applu', deleteLeft: 4, id: 3 }, + displayAs: '"applu"', + id: 2, + matchesModel: false + } as Suggestion, { + transform: { insert: 'apply', deleteLeft: 4, id: 3 }, + displayAs: 'apply', + id: 3 + }, { + transform: { insert: 'applied', deleteLeft: 4, id: 3 }, + displayAs: 'applied', + id: 4 + }] + } + }; + + prependReversion(suggestions, revertable); + + assert.includeMembers(suggestions, [revertable.reversion]); + }); + + it(`does not prepend reversions when reverting a 'keep' suggestion`, () => { + // context: Original was appl+u, corrected to apply + const suggestions: Suggestion[] = [{ + tag: 'keep', + transform: { insert: 'applu', deleteLeft: 5, id: 3 }, + displayAs: '"applu"', + id: 5, + matchesModel: false + } as Suggestion]; + + const revertable: TransitionReversionView = { + reversion: { + tag: 'revert', + transform: { insert: 'u', deleteLeft: 0, id: 3 }, + id: -2, + displayAs: '"applu"' + }, + final: { + suggestions: [{ + tag: 'keep', + transform: { insert: 'applu', deleteLeft: 4, id: 3 }, + displayAs: '"applu"', + id: 2, + matchesModel: false + } as Suggestion, { + transform: { insert: 'apply', deleteLeft: 4, id: 3 }, + displayAs: 'apply', + id: 3 + }, { + transform: { insert: 'applied', deleteLeft: 4, id: 3 }, + displayAs: 'applied', + id: 4 + }] + } + }; + + prependReversion(suggestions, revertable); + + assert.notIncludeMembers(suggestions, [revertable.reversion]); + }); +}); \ No newline at end of file