diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-state.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-state.ts index 283ad66df7..2476fcc350 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-state.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-state.ts @@ -44,7 +44,7 @@ export class ContextState { readonly model: LexicalModel; /** - * Denotes the most likely tokenization for the represented Context. + * Denotes the possible tokenization(s) for the represented Context. */ tokenization: ContextTokenization; @@ -92,6 +92,14 @@ export class ContextState { return this.suggestions.find(s => s.id == this.appliedSuggestionId)?.transformId; } + /** + * Returns the ContextTokenization matching the current version of context, as + * is visible to the user. + */ + get displayTokenization(): ContextTokenization { + return this.tokenization; + } + /** * Indicates whether or not the applied suggestion (if it exists) was applied * directly by the user. @@ -185,17 +193,11 @@ export class ContextState { * context after adjusting for sliding context-window behaviors.) * @param transformDistribution A distribution of incoming potential edits to the context - * typically from a keystroke's fat-finger distribution. - * - * May also contain a single entry for applying Suggestions or when correction behavior - * is disabled. - * @param isApplyingSuggestion When true, alters behavior to better model application of suggestions. * @returns */ analyzeTransition( context: Context, - transformDistribution: Distribution, - // overrides checks for token substitution that can fail for large applied suggestions. - isApplyingSuggestion?: boolean + transformDistribution: Distribution ): ContextTransition { const lexicalModel = this.model; 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 852ded44ff..822b6a2ae1 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 @@ -8,25 +8,17 @@ */ import { LexicalModelTypes } from '@keymanapp/common-types'; +import { applyTransform } from '@keymanapp/models-templates'; import { ContextState } from './context-state.js'; +import { ContextTokenization } from './context-tokenization.js'; +import { precomputeTransitions, transitionTokenizations } from './transition-helpers.js'; import Distribution = LexicalModelTypes.Distribution; import Reversion = LexicalModelTypes.Reversion; import Suggestion = LexicalModelTypes.Suggestion; import Transform = LexicalModelTypes.Transform; -// Mark affected tokens with the applied-suggestion transition ID -// for easy future reference. -const tagTokens = (state: ContextState, suggestion: Suggestion) => { - const edits = state.tokenization.transitionEdits; - const appliedTokenCount = edits.editedTokenCount; - const tokens = state.tokenization.tokens; - for(let i = tokens.length - appliedTokenCount; i < tokens.length; i++) { - tokens[i].appliedTransitionId = suggestion.transformId; - } -} - /** * Represents the transition between two context states as triggered * by input keystrokes or applied suggestions. @@ -131,63 +123,110 @@ export class ContextTransition { * @param suggestion * @returns */ - applySuggestion(suggestion: Suggestion): { + applySuggestion(suggestion: Suggestion /*, wasManuallyApplied: boolean */): { base: ContextTransition, appended?: ContextTransition } { - const preAppliedState = this.final; - if(!preAppliedState.suggestions?.find((s) => s.id == suggestion?.id)) { + if(!this.final.suggestions?.find((s) => s.id == suggestion?.id)) { throw new Error("Could not find matching suggestion to apply"); } - // This closure captures `suggestion`, and definition here / within the class also - // gives us private access to `._final`. - const buildAppliedTransition = ( - transition: ContextTransition, - baseState: ContextState, - transform: Transform - ) => { - const state = baseState.analyzeTransition( - baseState.context, - [{sample: transform, p: 1}], - true - ).final; + const lexicalModel = this.base.model; - tagTokens(state, suggestion); - transition._final = state; + // Goal: allow multiple base tokenizations. - // Applying a suggestion should not forget the original suggestion set. - state.appliedSuggestionId = suggestion.id; - state.suggestions = preAppliedState.suggestions; + // // Only keep the other context versions - along with a copy of the original + // // one - if the suggestion was auto-applied; that may not have been + // // intentional. If explicit (via manual), the other context versions are + // // considered to have been wrong and should be discarded. (They can be + // // restored via reversion, though.) + // + // const preservedVariations = ( + // wasManuallyApplied ? [] : [this.tokenization].map((t) => { + // return t.applyContextSlide(lexicalModel, slideUpdateTransform); + // }); + + const performTransitionStep = (baseState: ContextState, rootTokenization: ContextTokenization, transformToApply: Transform, inputDistribution: Distribution) => { + const appliedDistribution = [{sample: transformToApply, p: 1}]; + const { subsets: applicationSubsets, keyMatchingUserContext } = precomputeTransitions( + [rootTokenization], appliedDistribution + ); + + // Filter out insert and delete edges here! ONLY the primary substitution + // edge should be permitted! + applicationSubsets.forEach((value, key) => { + // When applying suggestions, only consider the actual tokenization that would result. + if(key != keyMatchingUserContext) { + applicationSubsets.delete(key); + } + + // TODO: verify that 'insert' and 'delete' edit-spurs are ignored (once + // they're supported) + }) + + const resultingTokenization = transitionTokenizations( + applicationSubsets, + appliedDistribution + ).get(keyMatchingUserContext); + + // Tag the result as revertable - but only on the last token. + // + // We won't try to partially revert a multi-word suggestion; reversions + // are only supported at the end of the last word of the main suggestion + // body and after any appended whitespace. + resultingTokenization.tail.appliedTransitionId = suggestion.transformId; + + const resultingState = new ContextState(applyTransform(transformToApply, baseState.context), lexicalModel); + resultingState.tokenization = resultingTokenization; // [resultingTokenization].concat(preservedVariations); + resultingState.appliedInput = transformToApply; + resultingState.appliedSuggestionId = suggestion.id; + resultingState.suggestions = this.final.suggestions; + + const resultingTransition = new ContextTransition(baseState, transformToApply.id); + resultingTransition.finalize(resultingState, inputDistribution); + resultingTransition.revertableTransitionId = suggestion.transformId; + resultingTransition._transitionId = transformToApply.id; + + return { + transition: resultingTransition, + tokenization: resultingTokenization + }; } - // Start from a deep copy, then replace as needed to overwrite with the context - // state resulting from the suggestion while preserving suggestion + primary - // keystroke data. - - const resultTransition = new ContextTransition(this); - buildAppliedTransition(resultTransition, this.base, suggestion.transform); - - // An applied suggestion should replace the original Transition's effects, though keeping - // the original input around. - resultTransition._transitionId = suggestion.transformId; - resultTransition.final.appliedInput = preAppliedState.appliedInput; + // Suggestions always apply to the version of context that the user last saw before + // the input triggering the suggestion.. + // + // Clone the base state in order to prevent cross-contamination from other operations (?) + const results = performTransitionStep( + new ContextState(this.base), + this.base.displayTokenization, + suggestion.transform, + this.inputDistribution + ); if(!suggestion.appendedTransform) { - return { base: resultTransition }; + return { + base: results.transition, + appended: null + }; } - const finalTransition = new ContextTransition(resultTransition.final, suggestion.appendedTransform.id); - buildAppliedTransition(finalTransition, resultTransition.final, suggestion.appendedTransform); + // Appended transforms apply to the context resulting from that. + const appendingTransition = performTransitionStep(results.transition.final, results.tokenization, suggestion.appendedTransform, []).transition; + appendingTransition.final.appliedInput = { insert: '', deleteLeft: 0 }; - // The appended transform is applied with no intermediate input. - finalTransition.final.appliedInput = { insert: '', deleteLeft: 0 }; - finalTransition.inputDistribution = []; + // Ensure the appended tokens all have the transition ID tagged to enable reversion. + // We allow reversion on any post-suggestion appended components. + const baseTokenizationLength = results.transition.final.displayTokenization.tokens.length; + const appliedTokenization = appendingTransition.final.displayTokenization; + for(let i = baseTokenizationLength; i < appliedTokenization.tokens.length; i++) { + appliedTokenization.tokens[i].appliedTransitionId = suggestion.transformId; + } return { - base: resultTransition, - appended: finalTransition - }; + base: results.transition, + appended: appendingTransition + } } /** diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/transition-helpers.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/transition-helpers.ts index 642eb1b684..11a96ebc01 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/transition-helpers.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/transition-helpers.ts @@ -40,7 +40,7 @@ export function precomputeTransitions( * in context (and the tokenizations that model each) that will result from * the currently-considered context variations and input. */ - subsets: ReadonlyMap, + subsets: Map, /** * The key matching the resulting context variation that will match the actual * context edited by the user. @@ -74,7 +74,7 @@ export function precomputeTransitions( } return { - subsets: subsetBuilder.subsets, + subsets: new Map(subsetBuilder.subsets), keyMatchingUserContext }; } 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 e3ca0762c2..e08716e6b3 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 @@ -1036,7 +1036,6 @@ export function finalizeSuggestions( if(presDL > 0) { mergedTransform.deleteLeft -= presDL; } - mergedTransform.id = prediction.sample.transformId; // Temporarily and locally drops 'readonly' semantics so that we can reassign the transform. // See https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#improved-control-over-mapped-type-modifiers @@ -1046,6 +1045,11 @@ export function finalizeSuggestions( mutableSuggestion.transform = mergedTransform; } + // Is sometimes not set during unit tests. + if(prediction.sample.transformId) { + prediction.sample.transform.id = prediction.sample.transformId; + } + if(!verbose) { return { ...prediction.sample, diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-transition.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-transition.tests.ts index 6185069c7b..263a24aa47 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-transition.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-transition.tests.ts @@ -238,7 +238,7 @@ describe('ContextTransition', () => { // 3 long, only last token was edited. appliedTransition.base.final.tokenization.tokens.forEach((token, index) => { - if(index >= 3) { + if(index >= 4) { assert.equal(token.appliedTransitionId, suggestions[0].transformId); } else { assert.isUndefined(token.appliedTransitionId); @@ -246,7 +246,7 @@ describe('ContextTransition', () => { }); appliedTransition.appended.final.tokenization.tokens.forEach((token, index) => { - if(index >= 3) { + if(index >= 4) { assert.equal(token.appliedTransitionId, suggestions[0].transformId); } else { assert.isUndefined(token.appliedTransitionId); diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/base-context-state.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/base-context-state.tests.ts index 269f7d4a95..6b36685093 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/base-context-state.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/base-context-state.tests.ts @@ -9,6 +9,7 @@ import { ContextTracker, matchBaseContextState, models } from "@keymanapp/lm-wor import CasingFunction = LexicalModelTypes.CasingFunction; import Context = LexicalModelTypes.Context; +import Suggestion = LexicalModelTypes.Suggestion; import TrieModel = models.TrieModel; const plainApplyCasing: CasingFunction = function(caseToApply, text) { @@ -124,7 +125,7 @@ describe('matchBaseContextState', () => { assert.equal(matchedState, transition.base); }); - it('handles sliding context with large jump from applying a suggestion', () => { + it('handles sliding context with large jump from applying a large insertion', () => { const context: Context = { left: 'ot of test here might cause the sliding context window to shift ', // 64 chars startOfBuffer: false, @@ -137,7 +138,8 @@ describe('matchBaseContextState', () => { left: 'ot of test here might cause the sliding context window to shift ', startOfBuffer: false, // We're sliding now. endOfBuffer: true - }, [{sample: { insert: 'dramatically', deleteLeft: 0 }, p: 1}], true); + }, [{sample: { insert: 'dramatically', deleteLeft: 0 }, p: 1}]); + contextTracker.latest = transition; const warningSpy = sinon.spy(console, 'warn'); @@ -155,7 +157,42 @@ describe('matchBaseContextState', () => { } }); -it('handles backward-sliding context after big delete', () => { + it('handles sliding context with large jump from applying a suggestion', () => { + const context: Context = { + left: 'ot of test here might cause the sliding context window to shift ', // 64 chars + startOfBuffer: false, + endOfBuffer: true + }; + const contextTracker = new ContextTracker(plainCasedModel, context, 1, plainCasedModel.configuration); + + const suggestion: Suggestion= { + transform: { insert: 'dramatically', deleteLeft: 0, id: 3 }, + displayAs: 'dramatically', + transformId: 3, + id: 5 + } + + const latest = contextTracker.latest; + latest.final.suggestions = [suggestion]; + const transition = latest.applySuggestion(suggestion).base; + contextTracker.latest = transition; + + const warningSpy = sinon.spy(console, 'warn'); + try { + const matchedState = matchBaseContextState(contextTracker, { + left: 'ere might cause the sliding context window to shift dramatically', + startOfBuffer: false, + endOfBuffer: true + }, 1); + + assert.isFalse(warningSpy.called); + assert.equal(matchedState, transition.final); + } finally { + warningSpy.restore(); + } + }); + + it('handles backward-sliding context after big deletion via input', () => { const context: Context = { left: 'ere might cause the sliding context window to shift dramatically', // 64 chars startOfBuffer: false, @@ -168,7 +205,42 @@ it('handles backward-sliding context after big delete', () => { left: 'ere might cause the sliding context window to shift dramatically', startOfBuffer: false, // We're sliding now. endOfBuffer: true - }, [{sample: { insert: '', deleteLeft: 'dramatically'.length }, p: 1}], true); + }, [{sample: { insert: '', deleteLeft: 'dramatically'.length }, p: 1}]); + contextTracker.latest = transition; + + const warningSpy = sinon.spy(console, 'warn'); + try { + const matchedState = matchBaseContextState(contextTracker, { + left: 'ot of test here might cause the sliding context window to shift ', + startOfBuffer: false, + endOfBuffer: true + }, 1); + + assert.isFalse(warningSpy.called); + assert.equal(matchedState, transition.final); + } finally { + warningSpy.restore(); + } + }); + + it('handles backward-sliding context after big deletion from suggestion', () => { + const context: Context = { + left: 'ere might cause the sliding context window to shift dramatically', // 64 chars + startOfBuffer: false, + endOfBuffer: true + }; + const contextTracker = new ContextTracker(plainCasedModel, context, 1, plainCasedModel.configuration); + + const suggestion: Suggestion= { + transform: { insert: '', deleteLeft: 'dramatically'.length, id: 3 }, + displayAs: '""', + transformId: 3, + id: 5 + } + + const latest = contextTracker.latest; + latest.final.suggestions = [suggestion]; + const transition = latest.applySuggestion(suggestion).base; contextTracker.latest = transition; const warningSpy = sinon.spy(console, 'warn'); diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/determine-suggestion-context-transition.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/determine-suggestion-context-transition.tests.ts index 1008699b3a..218f18313a 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/determine-suggestion-context-transition.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/determine-suggestion-context-transition.tests.ts @@ -199,6 +199,7 @@ describe('determineContextTransition', () => { deleteLeft: 0, id: 2 }, + id: 4, transformId: 0, displayAs: 'testing' };