Merge pull request #14840 from keymanapp/feat/web/split-context-tokens

feat(web): add class method for splitting ContextToken instances 🚂
This commit is contained in:
Joshua Horton 2025-10-08 22:35:29 +07:00 committed by GitHub
commit da444878ff
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 460 additions and 4 deletions

View file

@ -7,10 +7,12 @@
* in the context and associated correction-search progress and results.
*/
import { buildMergedTransform } from "@keymanapp/models-templates";
import { applyTransform, buildMergedTransform } from "@keymanapp/models-templates";
import { LexicalModelTypes } from '@keymanapp/common-types';
import { deepCopy, KMWString } from "@keymanapp/web-utils";
import { SearchSpace } from "./distance-modeler.js";
import { TokenSplitMap } from "./context-tokenization.js";
import Distribution = LexicalModelTypes.Distribution;
import LexicalModel = LexicalModelTypes.LexicalModel;
@ -206,4 +208,126 @@ export class ContextToken {
const composite = transforms.reduce((accum, current) => buildMergedTransform(accum, current), {insert: '', deleteLeft: 0});
return composite.insert;
}
/**
* Splits this token into multiple tokens as defined by a `TokenSplitMap`.
* @param split
* @param lexicalModel
* @returns
*/
split(split: TokenSplitMap, lexicalModel: LexicalModel) {
const tokensFromSplit: ContextToken[] = [];
// Build an alternate version of the transforms: if we preprocess all deleteLefts,
// what text remains from each?
const alteredSources = preprocessInputSources(this.inputRange);
const blankContext = { left: '', startOfBuffer: true, endOfBuffer: true };
const splitSpecs = split.matches.slice();
let currentText = {...blankContext};
let lenBeforeLastApply = 0;
let committedLen = 0;
let constructingToken = new ContextToken(lexicalModel);
let backupToken: ContextToken;
let transformIndex = 0;
while(splitSpecs.length > 0) {
const splitMatch = splitSpecs[0];
if(splitMatch.text == currentText.left) {
tokensFromSplit.push(constructingToken);
constructingToken = new ContextToken(lexicalModel);
backupToken = null;
committedLen += lenBeforeLastApply;
currentText = {...blankContext};
splitSpecs.shift();
continue;
} else if(currentText.left.indexOf(splitMatch.text) > -1) {
// Oh dear - we've overshot the target! The split is awkward, in the
// middle of a keystroke.
// Restore!
const overextendedToken = constructingToken;
constructingToken = backupToken;
// We know how much of the next transform to pull in: it's specified on
// the split object. Excess on constructed token - the split 'text offset'
const totalLenBeforeLastApply = committedLen + lenBeforeLastApply;
// We read the start position for the NEXT token to know the split position.
const extraCharsAdded = splitSpecs[1].textOffset - totalLenBeforeLastApply;
const tokenSequence = overextendedToken.searchSpace.inputSequence;
const lastInputIndex = tokenSequence.length - 1;
const inputDistribution = tokenSequence[lastInputIndex];
const headDistribution = inputDistribution.map((m) => {
return {
sample: {
...m.sample,
insert: KMWString.substring(m.sample.insert, 0, extraCharsAdded),
deleteRight: 0
}, p: m.p
};
});
const tailDistribution = inputDistribution.map((m) => {
return {
sample: {
...m.sample,
insert: KMWString.substring(m.sample.insert, extraCharsAdded),
deleteLeft: 0
}, p: m.p
};
});
const priorSourceInput = overextendedToken.inputRange[lastInputIndex];
constructingToken.addInput(priorSourceInput, headDistribution);
tokensFromSplit.push(constructingToken);
constructingToken = new ContextToken(lexicalModel);
backupToken = new ContextToken(constructingToken);
constructingToken.addInput({
trueTransform: priorSourceInput.trueTransform,
inputStartIndex: priorSourceInput.inputStartIndex + extraCharsAdded
}, tailDistribution);
const lenToCommit = lenBeforeLastApply + extraCharsAdded;
splitSpecs.shift();
committedLen += lenToCommit;
currentText.left = KMWString.substring(currentText.left, lenToCommit);
lenBeforeLastApply = 0;
continue; // without incrementing transformIndex - we haven't processed a new one!
} else if(transformIndex == alteredSources.length) {
throw new Error("Invalid split specified!");
}
backupToken = new ContextToken(constructingToken);
lenBeforeLastApply = KMWString.length(currentText.left);
currentText = applyTransform(alteredSources[transformIndex].trueTransform, currentText);
constructingToken.addInput(this.inputRange[transformIndex], this.searchSpace.inputSequence[transformIndex]);
transformIndex++;
}
return tokensFromSplit;
}
}
export function preprocessInputSources(inputSources: ReadonlyArray<TokenInputSource>) {
const alteredSources = deepCopy(inputSources);
let trickledDeleteLeft = 0;
for(let i = alteredSources.length - 1; i >= 0; i--) {
const source = alteredSources[i];
if(trickledDeleteLeft) {
const insLen = KMWString.length(source.trueTransform.insert);
if(insLen <= trickledDeleteLeft) {
source.trueTransform.insert = '';
trickledDeleteLeft -= insLen;
} else {
source.trueTransform.insert = KMWString.substring(source.trueTransform.insert, 0, insLen - trickledDeleteLeft);
trickledDeleteLeft = 0;
}
}
trickledDeleteLeft += source.trueTransform.deleteLeft;
source.trueTransform.deleteLeft = 0;
}
alteredSources[0].trueTransform.deleteLeft = trickledDeleteLeft;
return alteredSources;
}

View file

@ -38,7 +38,7 @@ interface TokenMergeMap {
match: EditTokenMap
};
interface TokenSplitMap {
export interface TokenSplitMap {
input: EditTokenMap,
matches: (EditTokenMap & { textOffset: number })[]
};

View file

@ -1,6 +1,6 @@
export { ClassicalDistanceCalculation, EditOperation, EditTuple, forNewIndices } from './correction/classical-calculation.js';
export * from './correction/context-state.js';
export { ContextToken } from './correction/context-token.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';

View file

@ -12,16 +12,44 @@ import { assert } from 'chai';
// Aliased due to JS keyword.
import { default as defaultBreaker } from '@keymanapp/models-wordbreakers';
import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs';
import { LexicalModelTypes } from '@keymanapp/common-types';
import { ContextToken, correction, models } from '@keymanapp/lm-worker/test-index';
import { ContextToken, correction, models, preprocessInputSources } from '@keymanapp/lm-worker/test-index';
import Distribution = LexicalModelTypes.Distribution;
import ExecutionTimer = correction.ExecutionTimer;
import Transform = LexicalModelTypes.Transform;
import TrieModel = models.TrieModel;
import { KMWString } from '@keymanapp/web-utils';
var plainModel = new TrieModel(jsonFixture('models/tries/english-1000'),
{wordBreaker: defaultBreaker});
// https://www.compart.com/en/unicode/block/U+1D400
const mathBoldUpperA = 0x1D400; // Mathematical Bold Capital A
const mathBoldLowerA = 0x1D41A; // Small A
function toMathematicalSMP(text: string) {
const chars = [...text];
const asSMP = chars.map((c) => {
if(c >= 'a' && c <= 'z') {
return String.fromCodePoint(mathBoldLowerA + (c.charCodeAt(0) - 'a'.charCodeAt(0)));
} else if(c >= 'A' && c <= 'Z') {
return String.fromCodePoint(mathBoldUpperA + (c.charCodeAt(0) - 'A'.charCodeAt(0)));
} else {
return c;
}
});
return asSMP.join('');
}
describe('ContextToken', function() {
before(() => {
KMWString.enableSupplementaryPlane(true);
});
describe("<constructor>", () => {
it("(model: LexicalModel)", async () => {
let token = new ContextToken(plainModel);
@ -73,4 +101,308 @@ describe('ContextToken', function() {
assert.deepEqual({...clonedToken, searchSpace: null}, {...baseToken, searchSpace: null});
});
});
describe("splitToken()", () => {
it("handles clean two-way split correctly", () => {
// Setup phase
const keystrokeDistributions: Distribution<Transform>[] = [
[
{ sample: { insert: 'c', deleteLeft: 0 }, p: 0.75 },
{ sample: { insert: 't', deleteLeft: 0 }, p: 0.25 }
],
[
{ sample: { insert: 'a', deleteLeft: 0 }, p: 0.75 },
{ sample: { insert: 'o', deleteLeft: 0 }, p: 0.25 }
],
[
{ sample: { insert: 'n', deleteLeft: 0 }, p: 0.75 },
{ sample: { insert: 'r', deleteLeft: 0 }, p: 0.25 }
],
[
{ sample: { insert: '\'', deleteLeft: 0 }, p: 0.75 },
{ sample: { insert: 't', deleteLeft: 0 }, p: 0.25 }
]
]
const tokenToSplit = new ContextToken(plainModel);
for(let i = 0; i < keystrokeDistributions.length; i++) {
tokenToSplit.addInput({trueTransform: keystrokeDistributions[i][0].sample, inputStartIndex: 0}, keystrokeDistributions[i]);
};
assert.equal(tokenToSplit.sourceText, 'can\'');
assert.deepEqual(tokenToSplit.searchSpace.inputSequence, keystrokeDistributions);
// And now for the "fun" part.
const resultsOfSplit = tokenToSplit.split({
// Input portion here can be ignored.
input: {
text: 'can\'',
index: 0
}, matches: [
// For this part, the text entries are what really matters.
{ text: 'can', index: 0, textOffset: 0 },
{ text: '\'', index: 1, textOffset: 3 }
]
}, plainModel);
assert.equal(resultsOfSplit.length, 2);
assert.sameOrderedMembers(resultsOfSplit.map(t => t.exampleInput), ['can', '\'']);
assert.sameDeepOrderedMembers(resultsOfSplit.map(t => t.searchSpace.inputSequence), [
keystrokeDistributions.slice(0, 3),
[keystrokeDistributions[3]]
]);
});
it("handles mid-transform splits correctly", () => {
// Setup phase
const keystrokeDistributions: Distribution<Transform>[] = [
[
{ sample: { insert: 'biglargetransform', deleteLeft: 0, deleteRight: 0 }, p: 1 },
]
];
const splitTextArray = ['big', 'large', 'transform'];
const tokenToSplit = new ContextToken(plainModel);
for(let i = 0; i < keystrokeDistributions.length; i++) {
tokenToSplit.addInput({trueTransform: keystrokeDistributions[i][0].sample, inputStartIndex: 0}, keystrokeDistributions[i]);
};
assert.equal(tokenToSplit.sourceText, 'biglargetransform');
assert.deepEqual(tokenToSplit.searchSpace.inputSequence, keystrokeDistributions);
// And now for the "fun" part.
const resultsOfSplit = tokenToSplit.split({
// Input portion here can be ignored.
input: {
text: 'biglargetransform',
index: 0
}, matches: [
// For this part, the text entries are what really matters.
{ text: 'big', index: 0, textOffset: 0 },
{ text: 'large', index: 1, textOffset: 3 },
{ text: 'transform', index: 2, textOffset: 8 }
]
}, plainModel);
assert.equal(resultsOfSplit.length, 3);
assert.sameOrderedMembers(resultsOfSplit.map(t => t.exampleInput), splitTextArray);
assert.sameDeepOrderedMembers(resultsOfSplit.map(t => t.inputRange[0]), [0, 3, 8].map(i => ({
trueTransform: {
insert: 'biglargetransform',
deleteLeft: 0,
deleteRight: 0
}, inputStartIndex: i
})));
assert.sameDeepOrderedMembers(resultsOfSplit.map(t => t.searchSpace.inputSequence[0]), splitTextArray.map(t => [{
sample: { insert: t, deleteLeft: 0, deleteRight: 0 }, p: 1
}]));
});
it("handles messy mid-transform splits correctly", () => {
// Setup phase
const keystrokeDistributions: Distribution<Transform>[] = [
[
{ sample: { insert: 'long', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 1 }
], [
{ sample: { insert: 'argelovely', deleteLeft: 3, deleteRight: 0, id: 12 }, p: 1 }
], [
{ sample: { insert: 'ngtransforms', deleteLeft: 4, deleteRight: 0, id: 13 }, p: 1 }
]
];
const splitTextArray = ['large', 'long', 'transforms'];
const tokenToSplit = new ContextToken(plainModel);
for(let i = 0; i < keystrokeDistributions.length; i++) {
tokenToSplit.addInput({trueTransform: keystrokeDistributions[i][0].sample, inputStartIndex: 0}, keystrokeDistributions[i]);
};
assert.equal(tokenToSplit.exampleInput, 'largelongtransforms');
assert.deepEqual(tokenToSplit.searchSpace.inputSequence, keystrokeDistributions);
// And now for the "fun" part.
const resultsOfSplit = tokenToSplit.split({
// Input portion here can be ignored.
input: {
text: 'largelongtransforms',
index: 0
}, matches: [
// For this part, the text entries are what really matters.
{ text: 'large', index: 0, textOffset: 0 },
{ text: 'long', index: 1, textOffset: 5 },
{ text: 'transforms', index: 2, textOffset: 9 }
]
}, plainModel);
assert.equal(resultsOfSplit.length, 3);
assert.sameOrderedMembers(resultsOfSplit.map(t => t.exampleInput), splitTextArray);
assert.deepEqual(resultsOfSplit[0].inputRange, [
{ trueTransform: keystrokeDistributions[0][0].sample, inputStartIndex: 0 },
{ trueTransform: keystrokeDistributions[1][0].sample, inputStartIndex: 0 },
]);
assert.deepEqual(resultsOfSplit[1].inputRange, [
{ trueTransform: keystrokeDistributions[1][0].sample, inputStartIndex: 'arge'.length },
{ trueTransform: keystrokeDistributions[2][0].sample, inputStartIndex: 0 },
]);
assert.deepEqual(resultsOfSplit[2].inputRange, [
{ trueTransform: keystrokeDistributions[2][0].sample, inputStartIndex: 'ng'.length }
]);
assert.deepEqual(resultsOfSplit[0].searchSpace.inputSequence, [
keystrokeDistributions[0],
keystrokeDistributions[1].map((entry) => {
return {
sample: {
...entry.sample,
insert: entry.sample.insert.slice(0, 4) // gets the 'arge' portion & the deleteLefts.
}, p: entry.p
}
}),
]);
assert.deepEqual(resultsOfSplit[1].searchSpace.inputSequence, [
keystrokeDistributions[1].map((entry) => {
return {
sample: {
...entry.sample,
insert: entry.sample.insert.slice('arge'.length),
deleteLeft: 0
}, p: entry.p
}
}),
keystrokeDistributions[2].map((entry) => {
return {
sample: {
...entry.sample,
insert: entry.sample.insert.slice(0, 'ng'.length), // gets the 'ng' portion.
}, p: entry.p
}
}),
]);
assert.deepEqual(resultsOfSplit[2].searchSpace.inputSequence, [
keystrokeDistributions[2].map((entry) => {
return {
sample: {
...entry.sample,
insert: entry.sample.insert.slice('ng'.length), // drops the 'ng' portion.
deleteLeft: 0
}, p: entry.p
}
}),
]);
});
it("handles messy mid-transform splits correctly - non-BMP text", () => {
// Setup phase
const keystrokeDistributions: Distribution<Transform>[] = [
[
{ sample: { insert: toMathematicalSMP('long'), deleteLeft: 0, deleteRight: 0, id: 11 }, p: 1 }
], [
{ sample: { insert: toMathematicalSMP('argelovely'), deleteLeft: 3, deleteRight: 0, id: 12 }, p: 1 }
], [
{ sample: { insert: toMathematicalSMP('ngtransforms'), deleteLeft: 4, deleteRight: 0, id: 13 }, p: 1 }
]
];
const splitTextArray = ['large', 'long', 'transforms'].map(t => toMathematicalSMP(t));
const tokenToSplit = new ContextToken(plainModel);
for(let i = 0; i < keystrokeDistributions.length; i++) {
tokenToSplit.addInput({trueTransform: keystrokeDistributions[i][0].sample, inputStartIndex: 0}, keystrokeDistributions[i]);
};
assert.equal(tokenToSplit.exampleInput, toMathematicalSMP('largelongtransforms'));
assert.deepEqual(tokenToSplit.searchSpace.inputSequence, keystrokeDistributions);
// And now for the "fun" part.
const resultsOfSplit = tokenToSplit.split({
// Input portion here can be ignored.
input: {
text: toMathematicalSMP('largelongtransforms'),
index: 0
}, matches: [
// For this part, the text entries are what really matters.
{ text: toMathematicalSMP('large'), index: 0, textOffset: 0 },
{ text: toMathematicalSMP('long'), index: 1, textOffset: 5 },
{ text: toMathematicalSMP('transforms'), index: 2, textOffset: 9 }
]
}, plainModel);
assert.equal(resultsOfSplit.length, 3);
assert.sameOrderedMembers(resultsOfSplit.map(t => t.exampleInput), splitTextArray);
assert.deepEqual(resultsOfSplit[0].inputRange, [
{ trueTransform: keystrokeDistributions[0][0].sample, inputStartIndex: 0 },
{ trueTransform: keystrokeDistributions[1][0].sample, inputStartIndex: 0 },
]);
assert.deepEqual(resultsOfSplit[1].inputRange, [
{ trueTransform: keystrokeDistributions[1][0].sample, inputStartIndex: 'arge'.length },
{ trueTransform: keystrokeDistributions[2][0].sample, inputStartIndex: 0 },
]);
assert.deepEqual(resultsOfSplit[2].inputRange, [
{ trueTransform: keystrokeDistributions[2][0].sample, inputStartIndex: 'ng'.length }
]);
assert.deepEqual(resultsOfSplit[0].searchSpace.inputSequence, [
keystrokeDistributions[0],
keystrokeDistributions[1].map((entry) => {
return {
sample: {
...entry.sample,
insert: KMWString.substring(entry.sample.insert, 0, 4) // gets the 'arge' portion & the deleteLefts.
}, p: entry.p
}
}),
]);
assert.deepEqual(resultsOfSplit[1].searchSpace.inputSequence, [
keystrokeDistributions[1].map((entry) => {
return {
sample: {
...entry.sample,
insert: KMWString.substring(entry.sample.insert, 'arge'.length),
deleteLeft: 0
}, p: entry.p
}
}),
keystrokeDistributions[2].map((entry) => {
return {
sample: {
...entry.sample,
insert: KMWString.substring(entry.sample.insert, 0, 'ng'.length), // gets the 'ng' portion.
}, p: entry.p
}
}),
]);
assert.deepEqual(resultsOfSplit[2].searchSpace.inputSequence, [
keystrokeDistributions[2].map((entry) => {
return {
sample: {
...entry.sample,
insert: KMWString.substring(entry.sample.insert, 'ng'.length), // drops the 'ng' portion.
deleteLeft: 0
}, p: entry.p
}
}),
]);
});
});
});
describe('preprocessInputSources', () => {
it('properly preprocesses deleteLefts in the transforms', () => {
const transforms: Transform[] = [
{ insert: 'long', deleteLeft: 0, deleteRight: 0 },
{ insert: 'argelovely', deleteLeft: 3, deleteRight: 0 },
{ insert: 'ngtransforms', deleteLeft: 4, deleteRight: 0 }
];
const results = preprocessInputSources(transforms.map((t) => ({
trueTransform: t,
inputStartIndex: 0
})));
assert.equal(results.length, transforms.length);
assert.sameOrderedMembers(results.map((entry) => entry.trueTransform.insert), ['l', 'argelo', 'ngtransforms']);
assert.sameOrderedMembers(results.map((entry) => entry.trueTransform.deleteLeft), [0, 0, 0]);
});
});