From ace4fefc0f6e317dc5511ce178d8ae121027a526 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Fri, 26 Sep 2025 12:21:58 -0500 Subject: [PATCH 1/2] feat(web): add class method for splitting ContextToken instances Relates-to: #14679 Build-bot: skip build:web Test-bot: skip --- .../src/main/correction/context-token.ts | 127 ++++++++++- .../main/correction/context-tokenization.ts | 2 +- .../worker-thread/src/main/test-index.ts | 2 +- .../context/context-token.tests.ts | 215 +++++++++++++++++- 4 files changed, 342 insertions(+), 4 deletions(-) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-token.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-token.ts index 0a67daf433..f7109793e6 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-token.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-token.ts @@ -7,14 +7,16 @@ * 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 Distribution = LexicalModelTypes.Distribution; import LexicalModel = LexicalModelTypes.LexicalModel; import Transform = LexicalModelTypes.Transform; +import { TokenSplitMap } from "./context-tokenization.js"; /** * Notes critical properties of the inputs comprising each ContextToken. @@ -206,4 +208,127 @@ 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]; + const wholeTokenIndex = currentText.left.indexOf(splitMatch.text); + + if(splitMatch.text == currentText.left) { + tokensFromSplit.push(constructingToken); + constructingToken = new ContextToken(lexicalModel); + backupToken = null; + committedLen += lenBeforeLastApply; + currentText = {...blankContext}; + splitSpecs.shift(); + continue; + } else if(wholeTokenIndex > -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.slice(m.sample.insert, 0, extraCharsAdded), + deleteRight: 0 + }, p: m.p + }; + }); + const tailDistribution = inputDistribution.map((m) => { + return { + sample: { + ...m.sample, + insert: KMWString.slice(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.slice(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) { + 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.slice(source.trueTransform.insert, 0, insLen - trickledDeleteLeft); + trickledDeleteLeft = 0; + } + } + trickledDeleteLeft += source.trueTransform.deleteLeft; + source.trueTransform.deleteLeft = 0; + } + + alteredSources[0].trueTransform.deleteLeft = trickledDeleteLeft; + return alteredSources; } \ No newline at end of file diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts index f842f19845..a3859f2c7b 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-tokenization.ts @@ -38,7 +38,7 @@ interface TokenMergeMap { match: EditTokenMap }; -interface TokenSplitMap { +export interface TokenSplitMap { input: EditTokenMap, matches: (EditTokenMap & { textOffset: number })[] }; diff --git a/web/src/engine/predictive-text/worker-thread/src/main/test-index.ts b/web/src/engine/predictive-text/worker-thread/src/main/test-index.ts index 2c64db440b..4b40891a17 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/test-index.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/test-index.ts @@ -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'; diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-token.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-token.tests.ts index 5e174d0146..6e97a95b7a 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-token.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-token.tests.ts @@ -12,10 +12,13 @@ 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; var plainModel = new TrieModel(jsonFixture('models/tries/english-1000'), @@ -73,4 +76,214 @@ 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[] = [ + [ + { 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[] = [ + [ + { 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[] = [ + [ + { 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 + } + }), + ]); + }); + }); +}); + +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]); + }); }); \ No newline at end of file From 050edaca21a51bf1ff509462f896022a4e8884aa Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Mon, 29 Sep 2025 12:38:03 -0500 Subject: [PATCH 2/2] fix(web): improve token-split handling of non-BMP text --- .../src/main/correction/context-token.ts | 13 +- .../context/context-token.tests.ts | 119 ++++++++++++++++++ 2 files changed, 125 insertions(+), 7 deletions(-) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-token.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-token.ts index f7109793e6..181231c1b4 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/context-token.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/context-token.ts @@ -12,11 +12,11 @@ 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; import Transform = LexicalModelTypes.Transform; -import { TokenSplitMap } from "./context-tokenization.js"; /** * Notes critical properties of the inputs comprising each ContextToken. @@ -232,7 +232,6 @@ export class ContextToken { let transformIndex = 0; while(splitSpecs.length > 0) { const splitMatch = splitSpecs[0]; - const wholeTokenIndex = currentText.left.indexOf(splitMatch.text); if(splitMatch.text == currentText.left) { tokensFromSplit.push(constructingToken); @@ -242,7 +241,7 @@ export class ContextToken { currentText = {...blankContext}; splitSpecs.shift(); continue; - } else if(wholeTokenIndex > -1) { + } 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. @@ -262,7 +261,7 @@ export class ContextToken { return { sample: { ...m.sample, - insert: KMWString.slice(m.sample.insert, 0, extraCharsAdded), + insert: KMWString.substring(m.sample.insert, 0, extraCharsAdded), deleteRight: 0 }, p: m.p }; @@ -271,7 +270,7 @@ export class ContextToken { return { sample: { ...m.sample, - insert: KMWString.slice(m.sample.insert, extraCharsAdded), + insert: KMWString.substring(m.sample.insert, extraCharsAdded), deleteLeft: 0 }, p: m.p }; @@ -292,7 +291,7 @@ export class ContextToken { splitSpecs.shift(); committedLen += lenToCommit; - currentText.left = KMWString.slice(currentText.left, 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) { @@ -321,7 +320,7 @@ export function preprocessInputSources(inputSources: ReadonlyArray { + 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("", () => { it("(model: LexicalModel)", async () => { let token = new ContextToken(plainModel); @@ -266,6 +291,100 @@ describe('ContextToken', function() { }), ]); }); + + it("handles messy mid-transform splits correctly - non-BMP text", () => { + // Setup phase + const keystrokeDistributions: Distribution[] = [ + [ + { 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 + } + }), + ]); + }); }); });