mirror of
https://github.com/keymanapp/keyman.git
synced 2026-08-29 03:37:42 +00:00
change(web): prepare suggestion-application for whitespace fat-finger handling
Some checks failed
Keyman Build Summary / Summarize build status checks (push) Has been cancelled
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:
parent
d1a4b69413
commit
0e3368698d
7 changed files with 186 additions and 68 deletions
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -199,6 +199,7 @@ describe('determineContextTransition', () => {
|
|||
deleteLeft: 0,
|
||||
id: 2
|
||||
},
|
||||
id: 4,
|
||||
transformId: 0,
|
||||
displayAs: 'testing'
|
||||
};
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue