change(web): prepare suggestion-application for whitespace fat-finger handling
Some checks failed
Keyman Build Summary / Summarize build status checks (push) Has been cancelled

Build-bot: skip build:web
Test-bot: skip
This commit is contained in:
Joshua Horton 2026-03-20 15:05:58 -05:00
parent d1a4b69413
commit 0e3368698d
7 changed files with 186 additions and 68 deletions

View file

@ -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<Transform>,
// overrides checks for token substitution that can fail for large applied suggestions.
isApplyingSuggestion?: boolean
transformDistribution: Distribution<Transform>
): ContextTransition {
const lexicalModel = this.model;

View file

@ -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<Transform>) => {
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
}
}
/**

View file

@ -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<string, TokenizationSubset>,
subsets: Map<string, TokenizationSubset>,
/**
* 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
};
}

View file

@ -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,

View file

@ -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);

View file

@ -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');

View file

@ -199,6 +199,7 @@ describe('determineContextTransition', () => {
deleteLeft: 0,
id: 2
},
id: 4,
transformId: 0,
displayAs: 'testing'
};