Merge branch 'epic/autocorrect' into change/web/merge-master-into-autocorrect-acceptance-prep
Some checks failed
Keyman Build Summary / Summarize build status checks (push) Has been cancelled

This commit is contained in:
Joshua Horton 2026-07-30 11:25:16 -05:00
commit 6164f2760b
10 changed files with 675 additions and 28 deletions

View file

@ -673,9 +673,6 @@ export class ContextTokenization {
tailTokenization.splice(tokenIndex, 1, affectedToken); tailTokenization.splice(tokenIndex, 1, affectedToken);
} }
affectedToken.isPartial = true;
delete affectedToken.appliedTransitionId;
// If we are completely replacing a token via delete left, erase the deleteLeft; // If we are completely replacing a token via delete left, erase the deleteLeft;
// that part applied to a _previous_ token that no longer exists. // that part applied to a _previous_ token that no longer exists.
// We start at index 0 in the insert string for the "new" token. // We start at index 0 in the insert string for the "new" token.
@ -699,6 +696,11 @@ export class ContextTokenization {
affectedToken = new ContextToken(affectedToken); affectedToken = new ContextToken(affectedToken);
affectedToken.addInput(inputSource, distribution); affectedToken.addInput(inputSource, distribution);
// Do not adjust the original token, as it may be used by other transitions.
// Only adjust the new, extended token.
affectedToken.isPartial = true;
delete affectedToken.appliedTransitionId;
const tokenize = determineModelTokenizer(lexicalModel); const tokenize = determineModelTokenizer(lexicalModel);
affectedToken.isWhitespace = tokenize({left: affectedToken.exampleInput, startOfBuffer: false, endOfBuffer: false}).left[0]?.isWhitespace ?? false; affectedToken.isWhitespace = tokenize({left: affectedToken.exampleInput, startOfBuffer: false, endOfBuffer: false}).left[0]?.isWhitespace ?? false;
// Do not re-use the previous token; the mutation may have unexpected // Do not re-use the previous token; the mutation may have unexpected
@ -708,8 +710,19 @@ export class ContextTokenization {
affectedToken = null; affectedToken = null;
} }
// Backspace handling - emptying context via backspace or erasing _part_ of
// a whitespace token can erase the tokenization-final empty token usually
// used for word-initial suggestions.
//
// We re-add it here so that suggestions can be presented to the user as
// normal.
const tokenSequence = this.tokens.slice(0, sliceIndex).concat(tailTokenization);
if(tokenSequence.length == 0 || tokenSequence[tokenSequence.length - 1]?.isWhitespace) {
tokenSequence.push(new ContextToken(new LegacyQuotientRoot(lexicalModel)));
}
return new ContextTokenization( return new ContextTokenization(
this.tokens.slice(0, sliceIndex).concat(tailTokenization), tokenSequence,
null, null,
determineTaillessTrueKeystroke(transitionEdge) determineTaillessTrueKeystroke(transitionEdge)
); );

View file

@ -19,6 +19,15 @@ import Reversion = LexicalModelTypes.Reversion;
import Suggestion = LexicalModelTypes.Suggestion; import Suggestion = LexicalModelTypes.Suggestion;
import Transform = LexicalModelTypes.Transform; import Transform = LexicalModelTypes.Transform;
export interface TransitionReversionView extends Pick<ContextTransition, 'reversion'> {
/**
* Gets the context state resulting from the context transition event,
* including any generated suggestions and data regarding potential
* application thereof.
*/
final: Pick<ContextState, 'suggestions'>
}
/** /**
* Represents the transition between two context states as triggered * Represents the transition between two context states as triggered
* by input keystrokes or applied suggestions. * by input keystrokes or applied suggestions.

View file

@ -12,7 +12,7 @@ import * as models from '@keymanapp/models-templates';
import { LexicalModelTypes } from '@keymanapp/common-types'; import { LexicalModelTypes } from '@keymanapp/common-types';
import { TransformUtils } from './transformUtils.js'; 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 { detectCurrentCasing, determineModelTokenizer, determineModelWordbreaker, determinePunctuationFromModel } from './model-helpers.js';
import { ContextTracker } from './correction/context-tracker.js'; import { ContextTracker } from './correction/context-tracker.js';
@ -216,18 +216,8 @@ export class ModelCompositor {
this.SUGGESTION_ID_SEED++; this.SUGGESTION_ID_SEED++;
}); });
if(revertableTransitionId) { const transitionToRevert = this.contextTracker?.peek(revertableTransitionId);
const reversion = this.contextTracker.peek(revertableTransitionId)?.reversion; prependReversion(suggestions, transitionToRevert);
if(reversion) {
if(suggestions[0]?.tag == 'keep') {
const keep = suggestions.shift();
suggestions.unshift(reversion);
suggestions.unshift(keep);
} else {
suggestions.unshift(reversion)
}
}
}
// Store the suggestions on the final token of the current context state (if it exists). // 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. // Or, once phrase-level suggestions are possible, on whichever token serves as each prediction's root.

View file

@ -9,11 +9,10 @@ import { ContextTokenization } from './correction/context-tokenization.js';
import { ContextTracker } from './correction/context-tracker.js'; import { ContextTracker } from './correction/context-tracker.js';
import { ContextToken } from './correction/context-token.js'; import { ContextToken } from './correction/context-token.js';
import { ContextState, determineContextSlideTransform } from './correction/context-state.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 { ExecutionTimer } from './correction/execution-timer.js';
import { ModelCompositor } from './model-compositor.js'; import { ModelCompositor } from './model-compositor.js';
import { getBestTokenMatches } from './correction/distance-modeler.js'; import { getBestTokenMatches } from './correction/distance-modeler.js';
import { TokenResultMapping } from './correction/token-result-mapping.js';
import CasingForm = LexicalModelTypes.CasingForm; import CasingForm = LexicalModelTypes.CasingForm;
import Context = LexicalModelTypes.Context; import Context = LexicalModelTypes.Context;
@ -459,7 +458,8 @@ export function determineSuggestionRange(
export function buildAndMapPredictions( export function buildAndMapPredictions(
transition: ContextTransition, transition: ContextTransition,
tokenization: ContextTokenization, tokenization: ContextTokenization,
match: Readonly<TokenResultMapping>, // Originally, Readonly<TokenResultMapping> - but we only need these two components here.
match: Readonly<{matchString: string, totalCost: number}>,
costFactor: number costFactor: number
): CorrectionPredictionTuple[] { ): CorrectionPredictionTuple[] {
const model = transition.final.model; const model = transition.final.model;
@ -493,6 +493,13 @@ export function buildAndMapPredictions(
// entry.baseTokenization = transition.final.tokenizationSourceMap.get(tokenization); // entry.baseTokenization = transition.final.tokenizationSourceMap.get(tokenization);
}); });
// Backspaces that shorten a multi-codepoint whitespace token are not handled well by default.
// As a new empty token is placed at the end for such cases, we can detect and handle such cases.
const inputTransform = transition.inputDistribution?.[0].sample ?? { insert: '', deleteLeft: 0 };
if(tokenization.tokens.length > 1 && tokenization.tail.searchModule.codepointLength == 0 && inputTransform.deleteLeft > 0) {
predictions.forEach((p) => p.prediction.sample.transform.deleteLeft += inputTransform.deleteLeft);
}
return predictions; return predictions;
} }
@ -578,11 +585,6 @@ export async function correctAndEnumerate(
// Corrections obtained: now to predict from them! // Corrections obtained: now to predict from them!
const tokenization = tokenizations.find(t => t.spaceId == match.spaceId); const tokenization = tokenizations.find(t => t.spaceId == match.spaceId);
// If our 'match' results in fully deleting the new token, reject it and try again.
if(match.matchSequence.length == 0 && match.inputSequence.length != 0) {
continue;
}
// If our 'match' fully replaces the token, reject it and try again. // If our 'match' fully replaces the token, reject it and try again.
if(match.matchSequence.length != 0 && match.matchSequence.length == match.knownCost) { if(match.matchSequence.length != 0 && match.matchSequence.length == match.knownCost) {
continue; continue;
@ -1077,6 +1079,9 @@ export function finalizeSuggestions(
if(presDL > 0) { if(presDL > 0) {
mergedTransform.deleteLeft -= presDL; mergedTransform.deleteLeft -= presDL;
} }
if(prediction.sample.transform.id !== undefined) {
mergedTransform.id = prediction.sample.transform.id;
}
// Temporarily and locally drops 'readonly' semantics so that we can reassign the transform. // 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 // See https://www.typescriptlang.org/docs/handbook/release-notes/typescript-2-8.html#improved-control-over-mapped-type-modifiers
@ -1191,4 +1196,35 @@ export function toAnnotatedSuggestion(
} }
return result; 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;
} }

View file

@ -3,7 +3,7 @@ export * from './correction/context-state.js';
export * from './correction/context-token.js'; export * from './correction/context-token.js';
export * from './correction/context-tokenization.js'; export * from './correction/context-tokenization.js';
export { ContextTracker } from './correction/context-tracker.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-searchable.js';
export * from './correction/correction-result-mapping.js'; export * from './correction/correction-result-mapping.js';
export * from './correction/distance-modeler.js'; export * from './correction/distance-modeler.js';

View file

@ -1,7 +1,7 @@
import { Transcription } from "keyman/engine/keyboard"; import { Transcription } from "keyman/engine/keyboard";
import { RewindableCache } from "keyman/common/web-utils"; import { RewindableCache } from "keyman/common/web-utils";
const TRANSCRIPTION_BUFFER_SIZE = 10; const TRANSCRIPTION_BUFFER_SIZE = 20;
export class TranscriptionCache extends RewindableCache<Transcription> { export class TranscriptionCache extends RewindableCache<Transcription> {
constructor() { constructor() {

View file

@ -397,6 +397,43 @@ describe('ContextTokenization', function() {
); );
}); });
it('handles simple case - deletion of final context content via backspace', () => {
const baseTokens = ['a'];
const baseTokenization = new ContextTokenization(baseTokens.map(t => toToken(t)));
const targetTokens = [''].map((t) => ({text: t, isWhitespace: t == ' '}));
const inputTransform = { insert: '', deleteLeft: 1, deleteRight: 0, id: 42 };
const inputTransformMap: Map<number, Transform> = new Map();
inputTransformMap.set(0, { insert: '', deleteLeft: 1, id: 42 });
const edgeWindow = buildEdgeWindow(baseTokenization.tokens, inputTransform, false, testEdgeWindowSpec);
const tokenization = baseTokenization.evaluateTransition({
alignment: {
merges: [],
splits: [],
unmappedEdits: [],
edgeWindow: {
...edgeWindow,
// The range within the window constructed by the prior call for its parameterization.
// Any adjustments on the boundary token itself are included here.
retokenization: [...targetTokens.slice(edgeWindow.sliceIndex).map(t => t.text)]
},
removedTokenCount: 1
},
inputs: [{ sample: inputTransformMap, p: 1 }],
inputSubsetId: generateSubsetId()
},
inputTransform.id,
1
);
assert.isOk(tokenization);
assert.equal(tokenization.tokens.length, targetTokens.length);
assert.deepEqual(tokenization.tokens.map((t) => ({text: t.exampleInput, isWhitespace: t.isWhitespace})),
targetTokens
);
});
it('handles simple case - new character added to last token', () => { it('handles simple case - new character added to last token', () => {
const baseTokens = ['an', ' ', 'apple', ' ', 'a', ' ', 'da']; const baseTokens = ['an', ' ', 'apple', ' ', 'a', ' ', 'da'];
const baseTokenization = new ContextTokenization(baseTokens.map(t => toToken(t))); const baseTokenization = new ContextTokenization(baseTokens.map(t => toToken(t)));
@ -823,6 +860,198 @@ describe('ContextTokenization', function() {
assert.equal(preTail.exampleInput, '\''); assert.equal(preTail.exampleInput, '\'');
assert.equal(tail.exampleInput, '.'); assert.equal(tail.exampleInput, '.');
}); });
describe('properly handles previously-applied transition IDs', () => {
it('does not preserve applied transition IDs on edited tokens', () => {
const baseTokens = ['an', ' ', 'apple', ' ', 'a', ' ', 'day', ' ', 'can'];
const baseTokenization = new ContextTokenization(baseTokens.map(t => toToken(t)));
const REVERTABLE_TRANSITION_ID = 31415;
baseTokenization.tail.appliedTransitionId = REVERTABLE_TRANSITION_ID;
const NEW_TRANSITION_ID = REVERTABLE_TRANSITION_ID + 1;
const dist = [
{
sample: { insert: 't', deleteLeft: 0, deleteRight: 0, id: NEW_TRANSITION_ID },
p: 1
}
];
const resultTokenization = baseTokenization.evaluateTransition(
{
alignment: {
merges: [],
splits: [],
unmappedEdits: [],
edgeWindow: {
...buildEdgeWindow(baseTokenization.tokens, dist[0].sample, false),
retokenization: baseTokenization.tokens.map((t) => t.exampleInput),
retokenizationText: baseTokenization.tokens.map((t) => t.exampleInput).reduce((accum, curr) => accum + curr, '')
},
removedTokenCount: 0
},
inputs: (() => {
const map: Map<number, Transform> = new Map();
map.set(0, dist[0].sample);
return [
{sample: map, p: 1}
];
})(),
inputSubsetId: 0
},
NEW_TRANSITION_ID,
1
)
resultTokenization.tokens.forEach((t) => {
assert.isUndefined(t.appliedTransitionId);
});
});
it('preserves applied transition IDs on applicable tokens', () => {
const baseTokens = ['an', ' ', 'apple', ' ', 'a', ' ', 'day', ' ', 'can'];
const baseTokenization = new ContextTokenization(baseTokens.map(t => toToken(t)));
const REVERTABLE_TRANSITION_ID = 31415;
baseTokenization.tail.appliedTransitionId = REVERTABLE_TRANSITION_ID;
const NEW_TRANSITION_ID = REVERTABLE_TRANSITION_ID + 1;
const dist = [
{
sample: { insert: ' ', deleteLeft: 0, deleteRight: 0, id: NEW_TRANSITION_ID },
p: 1
}
];
const resultTokenization = baseTokenization.evaluateTransition(
{
alignment: {
merges: [],
splits: [],
unmappedEdits: [],
edgeWindow: {
...buildEdgeWindow(baseTokenization.tokens, dist[0].sample, false),
retokenization: baseTokenization.tokens.map((t) => t.exampleInput),
retokenizationText: baseTokenization.tokens.map((t) => t.exampleInput).reduce((accum, curr) => accum + curr, '')
},
removedTokenCount: 0
},
inputs: (() => {
const map: Map<number, Transform> = new Map();
map.set(1, dist[0].sample);
return [
{sample: map, p: 1}
];
})(),
inputSubsetId: 0
},
NEW_TRANSITION_ID,
1
)
const resultTokenLength = resultTokenization.tokens.length;
resultTokenization.tokens.forEach((t, i) => {
// The space will add TWO tokens.
if(i == resultTokenLength - 3) {
assert.equal(t.appliedTransitionId, REVERTABLE_TRANSITION_ID);
} else {
assert.isUndefined(t.appliedTransitionId);
}
});
});
// Performs the two above in sequence in a manner that could cause cross-effects
// if implemented incorrectly.
it('does not conflate effects between different tokenization transitions', () => {
const baseTokens = ['an', ' ', 'apple', ' ', 'a', ' ', 'day', ' ', 'can'];
const baseTokenization = new ContextTokenization(baseTokens.map(t => toToken(t)));
const REVERTABLE_TRANSITION_ID = 31415;
baseTokenization.tail.appliedTransitionId = REVERTABLE_TRANSITION_ID;
const NEW_TRANSITION_ID = REVERTABLE_TRANSITION_ID + 1;
const dist = [
{
sample: { insert: ' ', deleteLeft: 0, deleteRight: 0, id: NEW_TRANSITION_ID },
p: .8
}, {
sample: { insert: ' ', deleteLeft: 0, deleteRight: 0, id: NEW_TRANSITION_ID },
p: .2
}
];
const baseTransitionEdge: TransitionEdge = {
alignment: {
merges: [],
splits: [],
unmappedEdits: [],
edgeWindow: {
...buildEdgeWindow(baseTokenization.tokens, dist[0].sample, false),
retokenization: baseTokenization.tokens.map((t) => t.exampleInput),
retokenizationText: baseTokenization.tokens.map((t) => t.exampleInput).reduce((accum, curr) => accum + curr, '')
},
removedTokenCount: 0
},
inputs: (() => {
const map: Map<number, Transform> = new Map();
map.set(1, dist[0].sample);
return [
{sample: map, p: dist[0].p}
];
})(),
inputSubsetId: 0
};
// We don't care about the results here. What we care about is that
// this call doesn't remove the appliedTransitionId from the source
// token, preventing it from being marked on later tokenization
// transitions.
baseTokenization.evaluateTransition(
{
alignment: {
...baseTransitionEdge.alignment,
edgeWindow: {
...baseTransitionEdge.alignment.edgeWindow,
...buildEdgeWindow(baseTokenization.tokens, dist[1].sample, false)
}
},
inputs: (() => {
const map: Map<number, Transform> = new Map();
map.set(0, dist[1].sample);
return [
{sample: map, p: dist[1].p}
];
})(),
inputSubsetId: 0
},
NEW_TRANSITION_ID,
dist[1].p
)
const resultTokenization = baseTokenization.evaluateTransition(
baseTransitionEdge,
NEW_TRANSITION_ID,
dist[0].p
);
const resultTokenLength = resultTokenization.tokens.length;
resultTokenization.tokens.forEach((t, i) => {
// The space will add TWO tokens.
if(i == resultTokenLength - 3) {
assert.equal(t.appliedTransitionId, REVERTABLE_TRANSITION_ID);
} else {
assert.isUndefined(t.appliedTransitionId);
}
});
});
});
}); });
describe('buildEdgeWindow', () => { describe('buildEdgeWindow', () => {

View file

@ -21,6 +21,7 @@ import {
ContextToken, ContextToken,
ContextTokenization, ContextTokenization,
generateSubsetId, generateSubsetId,
legacySubsetKeyer,
models, models,
precomputationSubsetKeyer, precomputationSubsetKeyer,
TokenizationTransitionEdits, TokenizationTransitionEdits,
@ -31,7 +32,7 @@ import Distribution = LexicalModelTypes.Distribution;
import Transform = LexicalModelTypes.Transform; import Transform = LexicalModelTypes.Transform;
import TrieModel = models.TrieModel; import TrieModel = models.TrieModel;
var plainModel = new TrieModel(jsonFixture('models/tries/english-1000'), const plainModel = new TrieModel(jsonFixture('models/tries/english-1000'),
{wordBreaker: defaultBreaker}); {wordBreaker: defaultBreaker});
function toToken(text: string) { function toToken(text: string) {
@ -41,6 +42,54 @@ function toToken(text: string) {
return token; return token;
} }
describe('legacySubsetKeyer', () => {
it('does not map backspace inputs to same result as standard key inputs', () => {
const appleToken = ContextToken.fromRawText(plainModel, 'apple', true);
const bksp = { insert: '', deleteLeft: 1, deleteRight: 0, id: 3 };
const bkspKey = legacySubsetKeyer({
alignment: {
merges: [],
splits: [],
unmappedEdits: [],
edgeWindow: {
...buildEdgeWindow([appleToken], bksp, false),
retokenization: ['appl'],
retokenizationText: 'appl'
},
removedTokenCount: 0
},
tokenizedTransform: (() => {
const map: Map<number, Transform> = new Map();
map.set(0, bksp);
return map;
})()
});
const input = { insert: 's', deleteLeft: 0, deleteRight: 0, id: bksp.id };
const inputKey = legacySubsetKeyer({
alignment: {
merges: [],
splits: [],
unmappedEdits: [],
edgeWindow: {
...buildEdgeWindow([appleToken], input, false),
retokenization: ['apples'],
retokenizationText: 'apples'
},
removedTokenCount: 0
},
tokenizedTransform: (() => {
const map: Map<number, Transform> = new Map();
map.set(0, input);
return map;
})()
});
assert.notEqual(bkspKey, inputKey);
});
});
describe('precomputationSubsetKeyer', function() { describe('precomputationSubsetKeyer', function() {
it("safely generates keys for empty transition + empty contexts", () => { it("safely generates keys for empty transition + empty contexts", () => {
const rawTextTokens = ['']; const rawTextTokens = [''];

View file

@ -0,0 +1,220 @@
/*
* Keyman is copyright (C) SIL Global. MIT License.
*
* Created by jahorton on 2026-07-23
*
* This file contains tests designed to validate the behavior of the
* buildAndMapPredictions helper function class and its integration with the
* lower-level predictive-text helpers.
*/
import { assert } from 'chai';
import { default as defaultBreaker } from '@keymanapp/models-wordbreakers';
import { LexicalModelTypes } from '@keymanapp/common-types';
import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs';
import { TrieModel } from '@keymanapp/models-templates';
import { buildAndMapPredictions, buildEdgeWindow, ContextState, ContextToken, ContextTokenization, ContextTransition, generateSubsetId, LegacyQuotientRoot, LegacyQuotientSpur, models, predictFromCorrections } from "@keymanapp/lm-worker/test-index";
import Context = LexicalModelTypes.Context;
import Distribution = LexicalModelTypes.Distribution;
import ProbabilityMass = LexicalModelTypes.ProbabilityMass;
import Transform = LexicalModelTypes.Transform;
const plainModel = new TrieModel(jsonFixture('models/tries/english-1000'),
{wordBreaker: defaultBreaker});
describe('buildAndMapPredictions', () => {
it('adds the preservation transform to all generated predictions', () => {
const context: Context = {
left: 'th',
right: '',
startOfBuffer: true,
endOfBuffer: true
};
const correctionDistribution: Distribution<Transform> = [{
sample: {
insert: 'e',
deleteLeft: 0
},
p: 0.6
}
];
const basePredictions = predictFromCorrections(plainModel, correctionDistribution, context);
basePredictions.forEach((entry) => assert.isNotOk(entry.preservationTransform));
// must construct the taillessTrueKeystroke appropriately.
const tailless = { insert: 'TEST', deleteLeft: 0 };
const tokenization = new ContextTokenization([ContextToken.fromRawText(plainModel, 'th', true)], null, tailless);
const transition = new ContextTransition(new ContextState(context, plainModel, tokenization), 0);
const targetTokenization = new ContextTokenization([new ContextToken(new LegacyQuotientSpur(tokenization.tail.searchModule, correctionDistribution, correctionDistribution[0]))]);
transition.finalize(new ContextState(models.applyTransform(correctionDistribution[0].sample, context), plainModel, targetTokenization), correctionDistribution);
const mappedPredictions = buildAndMapPredictions(
transition,
transition.base.displayTokenization,
{matchString: 'the', totalCost: 0},
1
);
assert.deepEqual(mappedPredictions.map((tuple) => tuple.prediction), basePredictions.map((tuple) => tuple.prediction));
mappedPredictions.forEach((tuple) => assert.isOk(tuple.preservationTransform));
mappedPredictions.forEach((tuple) => tuple.preservationTransform == tailless);
});
it('properly handles empty prediction roots from deleted same-token codepoints', () => {
const context: Context = {
left: 'the a',
right: '',
startOfBuffer: true,
endOfBuffer: true
};
const correctionDistribution: Distribution<Transform> = [{
sample: {
insert: '',
deleteLeft: 1
},
p: 1
}
];
const basePredictions = predictFromCorrections(plainModel, correctionDistribution, context);
// must construct the taillessTrueKeystroke appropriately.
const tokenization = new ContextTokenization([
ContextToken.fromRawText(plainModel, 'the', false),
ContextToken.fromRawText(plainModel, ' ', false),
ContextToken.fromRawText(plainModel, 'a', true)
]);
const transition = new ContextTransition(new ContextState(context, plainModel, tokenization), 0);
const targetTokenization = new ContextTokenization([
tokenization.tokens[0],
tokenization.tokens[1],
new ContextToken(new LegacyQuotientRoot(plainModel))
]);
transition.finalize(new ContextState(models.applyTransform(correctionDistribution[0].sample, context), plainModel, targetTokenization), correctionDistribution);
const mappedPredictions = buildAndMapPredictions(
transition,
transition.base.displayTokenization,
{matchString: '', totalCost: 0},
1
);
assert.deepEqual(mappedPredictions.map((tuple) => tuple.prediction), basePredictions.map((tuple) => tuple.prediction));
});
it('properly handles empty prediction roots caused by backspacing one of multiple spaces', () => {
const context: Context = {
left: 'the ',
right: '',
startOfBuffer: true,
endOfBuffer: true
};
const correctionDistribution: Distribution<Transform> = [{
sample: {
insert: '',
deleteLeft: 1
},
p: 1
}
];
const basePredictions = predictFromCorrections(plainModel, correctionDistribution, context);
// must construct the taillessTrueKeystroke appropriately.
const tokenization = new ContextTokenization([
ContextToken.fromRawText(plainModel, 'the', false),
ContextToken.fromRawText(plainModel, ' ', false),
ContextToken.fromRawText(plainModel, '', true)
]);
const transition = new ContextTransition(new ContextState(context, plainModel, tokenization), 0);
const targetTokenization = new ContextTokenization([
tokenization.tokens[0],
new ContextToken(new LegacyQuotientSpur(tokenization.tokens[1].searchModule, correctionDistribution, correctionDistribution[0])),
new ContextToken(new LegacyQuotientRoot(plainModel))
]);
transition.finalize(new ContextState(models.applyTransform(correctionDistribution[0].sample, context), plainModel, targetTokenization), correctionDistribution);
const mappedPredictions = buildAndMapPredictions(
transition,
transition.base.displayTokenization,
{matchString: '', totalCost: 0},
1
);
assert.deepEqual(mappedPredictions.map((tuple) => tuple.prediction), basePredictions.map((tuple) => tuple.prediction));
});
it('properly handles contexts made empty by input backspace', () => {
const context: Context = {
left: 't',
right: '',
startOfBuffer: true,
endOfBuffer: true
};
const correctionDistribution = [{
sample: {
insert: '',
deleteLeft: 1,
deleteRight: 0
},
p: 1
}
];
// must construct the taillessTrueKeystroke appropriately.
const tokenization = new ContextTokenization([
ContextToken.fromRawText(plainModel, 't', true)
]);
const transition = new ContextTransition(new ContextState(context, plainModel, tokenization), 0);
const targetTokenization = new ContextTokenization([
new ContextToken(new LegacyQuotientRoot(plainModel))
], {
alignment: {
merges: [],
splits: [],
unmappedEdits: [],
edgeWindow: {
...buildEdgeWindow(tokenization.tokens, correctionDistribution[0].sample, false),
retokenization: [''],
retokenizationText: ''
},
removedTokenCount: 0
},
inputs: (() => {
const val: ProbabilityMass<Map<number, Transform>>[] = [{
sample: new Map(),
p: correctionDistribution[0].p
}];
val[0].sample.set(0, correctionDistribution[0].sample);
return val;
})(),
inputSubsetId: generateSubsetId()
}, null);
transition.finalize(new ContextState(models.applyTransform(correctionDistribution[0].sample, context), plainModel, targetTokenization), correctionDistribution);
const mappedPredictions = buildAndMapPredictions(
transition,
transition.final.displayTokenization,
{matchString: '', totalCost: 0},
1
);
mappedPredictions.forEach((tuple) => assert.equal(tuple.prediction.sample.transform.deleteLeft, 1));
});
});

View file

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