From 64a0355838aca1e37c07747d2bc91adcdc182701 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Tue, 3 Feb 2026 17:12:56 -0600 Subject: [PATCH 1/5] feat(web): add spur variants specialized for each edit type Build-bot: skip build:web Test-bot: skip --- .../main/correction/deletion-quotient-spur.ts | 35 +++ .../correction/insertion-quotient-spur.ts | 31 +++ .../main/correction/search-quotient-spur.ts | 14 +- .../correction/substitution-quotient-spur.ts | 38 +++ .../worker-thread/src/main/test-index.ts | 3 + .../search-quotient-spur.tests.ts | 246 +++++++++++++++++- .../auto/resources/searchQuotientUtils.ts | 34 ++- 7 files changed, 388 insertions(+), 13 deletions(-) create mode 100644 web/src/engine/predictive-text/worker-thread/src/main/correction/deletion-quotient-spur.ts create mode 100644 web/src/engine/predictive-text/worker-thread/src/main/correction/insertion-quotient-spur.ts create mode 100644 web/src/engine/predictive-text/worker-thread/src/main/correction/substitution-quotient-spur.ts diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/deletion-quotient-spur.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/deletion-quotient-spur.ts new file mode 100644 index 00000000000..250f7e35030 --- /dev/null +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/deletion-quotient-spur.ts @@ -0,0 +1,35 @@ +import { LexicalModelTypes } from "@keymanapp/common-types"; + +import { SearchNode } from "./distance-modeler.js"; +import { PathInputProperties, SearchQuotientNode } from "./search-quotient-node.js"; +import { SearchQuotientSpur } from "./search-quotient-spur.js"; + +import Distribution = LexicalModelTypes.Distribution; +import ProbabilityMass = LexicalModelTypes.ProbabilityMass; +import Transform = LexicalModelTypes.Transform; + +export class DeletionQuotientSpur extends SearchQuotientSpur { + public readonly insertLength: number = 0; + public readonly leftDeleteLength: number = 0; + + constructor( + parentNode: SearchQuotientNode, + inputs: Distribution>, + inputSource: PathInputProperties | ProbabilityMass + ) { + super(parentNode, inputs, inputSource, parentNode.codepointLength); + } + + construct(parentNode: SearchQuotientNode, inputs: ProbabilityMass>[], inputSource: PathInputProperties): this { + return new DeletionQuotientSpur(parentNode, inputs, inputSource) as this; + } + + protected buildEdgesForNodes(baseNodes: ReadonlyArray): SearchNode[] { + return baseNodes.flatMap((n) => n.buildDeletionEdges(this.inputs, this.spaceId)); + } + + get edgeKey(): string { + const baseKey = super.edgeKey; + return `${baseKey}DEL`; + } +} \ No newline at end of file diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/insertion-quotient-spur.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/insertion-quotient-spur.ts new file mode 100644 index 00000000000..b66d727a542 --- /dev/null +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/insertion-quotient-spur.ts @@ -0,0 +1,31 @@ +import { SearchNode } from "./distance-modeler.js"; +import { SearchQuotientNode } from "./search-quotient-node.js"; +import { SearchQuotientSpur } from "./search-quotient-spur.js"; + +export class InsertionQuotientSpur extends SearchQuotientSpur { + public readonly insertLength = 1; + public readonly leftDeleteLength = 0; + + constructor( + parentNode: SearchQuotientNode + ) { + super(parentNode, null, null, parentNode.codepointLength + 1); + } + + construct(parentNode: SearchQuotientNode): this { + return new InsertionQuotientSpur(parentNode) as this; + } + + protected buildEdgesForNodes(baseNodes: ReadonlyArray): SearchNode[] { + // Note that .buildInsertionEdges will not extend any nodes reached by empty-input + // or by deletions. + return baseNodes.flatMap((n) => n.buildInsertionEdges()); + } + + get edgeKey(): string { + return `SR[${this.parentNode.sourceRangeKey}]L${this.codepointLength}INS`; + // How will this differentiate from other cases? + // ... sourceRangeKey + codepointLength + insert count? + return ''; + } +} \ No newline at end of file diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-spur.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-spur.ts index cf09b297615..16ac96918dd 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-spur.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-spur.ts @@ -41,7 +41,7 @@ export abstract class SearchQuotientSpur extends SearchQuotientNode { readonly inputs?: Distribution; readonly inputSource?: PathInputProperties; - private parentNode: SearchQuotientNode; + protected readonly parentNode: SearchQuotientNode; readonly spaceId: number; readonly inputCount: number; @@ -511,9 +511,7 @@ export abstract class SearchQuotientSpur extends SearchQuotientNode { * ancestry properly handle quotient-path variance in unit tests. */ get edgeKey(): string { - const inputSrc = this.inputSource; - const segment = inputSrc.segment; - return `E${inputSrc.subsetId}:${segment.start}${segment.end !== undefined ? `-${segment.end}` : ''}`; + return `E${this.inputSource?.subsetId ?? ''}:${this.splitClusteringKey}`; } isSameNode(space: SearchQuotientNode): boolean { @@ -540,11 +538,7 @@ export abstract class SearchQuotientSpur extends SearchQuotientNode { // Used to identify cluster-compatible components of SearchPaths during SearchCluster split operations. get splitClusteringKey(): string { - const pathSrc = this.inputSource; - if(!pathSrc) { - return ''; - } - - return `${pathSrc.segment.start}${pathSrc.segment.end == undefined ? '' : `-${pathSrc.segment.end}`}`; + const spurSeg = this.inputSource?.segment; + return `${spurSeg?.start ?? 0}${spurSeg?.end == undefined ? '' : `-${spurSeg.end}`}`; } } diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/substitution-quotient-spur.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/substitution-quotient-spur.ts new file mode 100644 index 00000000000..55a78748795 --- /dev/null +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/substitution-quotient-spur.ts @@ -0,0 +1,38 @@ +import { LexicalModelTypes } from "@keymanapp/common-types"; +import { KMWString } from "@keymanapp/web-utils"; + +import { SearchNode } from "./distance-modeler.js"; +import { PathInputProperties, SearchQuotientNode } from "./search-quotient-node.js"; +import { SearchQuotientSpur } from "./search-quotient-spur.js"; + +import Distribution = LexicalModelTypes.Distribution; +import ProbabilityMass = LexicalModelTypes.ProbabilityMass; +import Transform = LexicalModelTypes.Transform; + +export class SubstitutionQuotientSpur extends SearchQuotientSpur { + public insertLength: number; + public leftDeleteLength: number; + + constructor( + parentNode: SearchQuotientNode, + inputs: Distribution>, + inputSource: PathInputProperties | ProbabilityMass + ) { + // Compute this SearchPath's codepoint length & edge length. + const inputSample = inputs?.[0].sample ?? { insert: '', deleteLeft: 0 }; + const insertLength = KMWString.length(inputSample.insert); + super(parentNode, inputs, inputSource, parentNode.codepointLength + insertLength - inputSample.deleteLeft); + + // Compute this SearchPath's codepoint length & edge length. + this.insertLength = insertLength; + this.leftDeleteLength = inputSample.deleteLeft; + } + + construct(parentNode: SearchQuotientNode, inputs: ProbabilityMass>[], inputSource: PathInputProperties): this { + return new SubstitutionQuotientSpur(parentNode, inputs, inputSource) as this; + } + + protected buildEdgesForNodes(baseNodes: ReadonlyArray): SearchNode[] { + return baseNodes.flatMap((n) => n.buildSubstitutionEdges(this.inputs, this.spaceId)); + } +} \ No newline at end of file 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 6a66776f55a..41a4c8d665c 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 @@ -5,6 +5,9 @@ export * from './correction/context-tokenization.js'; export { ContextTracker } from './correction/context-tracker.js'; export { ContextTransition } from './correction/context-transition.js'; export * from './correction/distance-modeler.js'; +export * from './correction/deletion-quotient-spur.js'; +export * from './correction/insertion-quotient-spur.js'; +export * from './correction/substitution-quotient-spur.js'; export * from './correction/search-quotient-cluster.js'; export * from './correction/search-quotient-spur.js'; export * from './correction/search-quotient-node.js'; diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts index f3a39a93c5a..7a58e9b99e7 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts @@ -13,14 +13,18 @@ import { LexicalModelTypes } from '@keymanapp/common-types'; import { KMWString } from '@keymanapp/web-utils'; import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs'; import { + DeletionQuotientSpur, generateSubsetId, + InsertionQuotientSpur, LegacyQuotientRoot, LegacyQuotientSpur, models, PathInputProperties, + SearchQuotientCluster, SearchQuotientNode, SearchQuotientRoot, - SearchQuotientSpur + SearchQuotientSpur, + SubstitutionQuotientSpur } from '@keymanapp/lm-worker/test-index'; import { buildAlphabeticClusterFixtures } from './search-quotient-cluster.tests.js'; @@ -96,6 +100,219 @@ export function buildSimplePathSplitFixture() { }; } +/** + * Builds a text fixture matching the [final quotient-graph example]( + * ../../../../../../../engine/predictive-text/worker-thread/docs/correction-search-graph.md) + * documenting the internal SearchQuotientNode design. + * @returns + */ +export function buildQuotientDocFixture() { + const searchRoot = new SearchQuotientRoot(testModel); + let idSeed = 0; + + const key1Id = idSeed++; + const abDistrib: Distribution = [ + { sample: { insert: 'a', deleteLeft: 0, id: key1Id }, p: .45 }, + { sample: { insert: 'b', deleteLeft: 0, id: key1Id }, p: .35 } + ]; + + const cdDistrib: Distribution = [ + { sample: { insert: 'cd', deleteLeft: 0, id: key1Id }, p: .2 } + ]; + + const sc1 = new InsertionQuotientSpur(searchRoot); + const sc2 = new InsertionQuotientSpur(sc1); + + // K1C0 + const k1c0 = new DeletionQuotientSpur(searchRoot, abDistrib.concat(cdDistrib), { + segment: { + transitionId: key1Id, + start: 0 + }, + // Deletions always get their own unique subset ID. + subsetId: generateSubsetId(), + bestProbFromSet: abDistrib[0].p + }); + + // K1C1 + const k1c1_del = new DeletionQuotientSpur(sc1, abDistrib.concat(cdDistrib), { + segment: { + transitionId: key1Id, + start: 0 + }, + // Deletions always get their own unique subset ID. + subsetId: generateSubsetId(), + bestProbFromSet: abDistrib[0].p + }); + const k1c1_ab = new SubstitutionQuotientSpur(searchRoot, abDistrib, { + segment: { + transitionId: key1Id, + start: 0 + }, + // Deletions always get their own unique subset ID. + subsetId: generateSubsetId(), + bestProbFromSet: abDistrib[0].p + }); + const k1c1_ins = new InsertionQuotientSpur(k1c0); + const k1c1 = new SearchQuotientCluster([k1c1_del, k1c1_ab, k1c1_ins]); + + const k1c2_del = new DeletionQuotientSpur(sc2, abDistrib.concat(cdDistrib), { + segment: { + transitionId: key1Id, + start: 0 + }, + // Deletions always get their own unique subset ID. + subsetId: generateSubsetId(), + bestProbFromSet: abDistrib[0].p + }); + const k1c2_ab = new SubstitutionQuotientSpur(sc1, abDistrib, { + segment: { + transitionId: key1Id, + start: 0 + }, + // Deletions always get their own unique subset ID. + subsetId: generateSubsetId(), + bestProbFromSet: abDistrib[0].p + }); + const k1c2_cd = new SubstitutionQuotientSpur(searchRoot, cdDistrib, { + segment: { + transitionId: key1Id, + start: 0 + }, + // Deletions always get their own unique subset ID. + subsetId: generateSubsetId(), + bestProbFromSet: abDistrib[0].p + }); + const k1c2_ins = new InsertionQuotientSpur(k1c1); + const k1c2 = new SearchQuotientCluster([k1c2_del, k1c2_ab, k1c2_cd, k1c2_ins]); + + const k1c3_ab = new SubstitutionQuotientSpur(sc2, abDistrib, { + segment: { + transitionId: key1Id, + start: 0 + }, + // Deletions always get their own unique subset ID. + subsetId: generateSubsetId(), + bestProbFromSet: abDistrib[0].p + }); + const k1c3_cd = new SubstitutionQuotientSpur(sc1, cdDistrib, { + segment: { + transitionId: key1Id, + start: 0 + }, + // Deletions always get their own unique subset ID. + subsetId: generateSubsetId(), + bestProbFromSet: abDistrib[0].p + }); + const k1c3_ins = new InsertionQuotientSpur(k1c2); + const k1c3 = new SearchQuotientCluster([k1c3_ab, k1c3_cd, k1c3_ins]); + + // Onto keystroke 2. + + const key2Id = idSeed++; + const efDistrib: Distribution = [ + { sample: { insert: 'e', deleteLeft: 0, id: key2Id }, p: .4 }, + { sample: { insert: 'f', deleteLeft: 0, id: key2Id }, p: .3 } + ]; + + const ghDistrib: Distribution = [ + { sample: { insert: 'gh', deleteLeft: 0, id: key2Id }, p: .3 } + ]; + + const k2c0 = new DeletionQuotientSpur(k1c0, efDistrib.concat(ghDistrib), { + segment: { + transitionId: key2Id, + start: 0 + }, + // Deletions always get their own unique subset ID. + subsetId: generateSubsetId(), + bestProbFromSet: efDistrib[0].p + }); + + const k2c1_del = new DeletionQuotientSpur(k1c1, efDistrib.concat(ghDistrib), { + segment: { + transitionId: key2Id, + start: 0 + }, + // Deletions always get their own unique subset ID. + subsetId: generateSubsetId(), + bestProbFromSet: efDistrib[0].p + }); + const k2c1_ef = new SubstitutionQuotientSpur(k1c0, efDistrib, { + segment: { + transitionId: key2Id, + start: 0 + }, + // Deletions always get their own unique subset ID. + subsetId: generateSubsetId(), + bestProbFromSet: efDistrib[0].p + }); + const k2c1_ins = new InsertionQuotientSpur(k2c0); + const k2c1 = new SearchQuotientCluster([k2c1_del, k2c1_ef, k2c1_ins]); + + const k2c2_del = new DeletionQuotientSpur(k1c2, efDistrib.concat(ghDistrib), { + segment: { + transitionId: key2Id, + start: 0 + }, + // Deletions always get their own unique subset ID. + subsetId: generateSubsetId(), + bestProbFromSet: efDistrib[0].p + }); + const k2c2_ef = new SubstitutionQuotientSpur(k1c1, efDistrib, { + segment: { + transitionId: key2Id, + start: 0 + }, + // Deletions always get their own unique subset ID. + subsetId: generateSubsetId(), + bestProbFromSet: efDistrib[0].p + }); + const k2c2_gh = new SubstitutionQuotientSpur(k1c0, ghDistrib, { + segment: { + transitionId: key2Id, + start: 0 + }, + // Deletions always get their own unique subset ID. + subsetId: generateSubsetId(), + bestProbFromSet: efDistrib[0].p + }); + const k2c2_ins = new InsertionQuotientSpur(k2c1); + const k2c2 = new SearchQuotientCluster([k2c2_del, k2c2_ef, k2c2_gh, k2c2_ins]); + + const k2c3_del = new DeletionQuotientSpur(k1c3, efDistrib.concat(ghDistrib), { + segment: { + transitionId: key2Id, + start: 0 + }, + // Deletions always get their own unique subset ID. + subsetId: generateSubsetId(), + bestProbFromSet: efDistrib[0].p + }); + const k2c3_ef = new SubstitutionQuotientSpur(k1c2, efDistrib, { + segment: { + transitionId: key2Id, + start: 0 + }, + // Deletions always get their own unique subset ID. + subsetId: generateSubsetId(), + bestProbFromSet: efDistrib[0].p + }); + const k2c3_gh = new SubstitutionQuotientSpur(k1c1, ghDistrib, { + segment: { + transitionId: key2Id, + start: 0 + }, + // Deletions always get their own unique subset ID. + subsetId: generateSubsetId(), + bestProbFromSet: efDistrib[0].p + }); + const k2c3_ins = new InsertionQuotientSpur(k2c2); + const k2c3 = new SearchQuotientCluster([k2c3_del, k2c3_ef, k2c3_gh, k2c3_ins]); + + return {searchRoot, sc1, sc2, k1c0, k1c1, k1c2, k1c3, k2c1, k2c2, k2c3}; +} + describe('SearchQuotientSpur', () => { describe('constructor', () => { it('initializes from a lexical model', () => { @@ -289,6 +506,33 @@ describe('SearchQuotientSpur', () => { assert.isTrue(quotientPathHasInputs(pathToSplit, distributions)); assert.equal(constituentPaths(pathToSplit).length, 1); }); + + it('setup: buildQuotientDocFixture() constructs nodes properly', () => { + const nodes = buildQuotientDocFixture(); + + [nodes.searchRoot, nodes.sc1, nodes.sc2].forEach((n) => { + assert.equal(n.inputCount, 0); + }); + [nodes.k1c0, nodes.k1c1, nodes.k1c2, nodes.k1c3].forEach((n) => { + assert.equal(n.inputCount, 1); + }); + [nodes.k2c1, nodes.k2c2, nodes.k2c3].forEach((n) => { + assert.equal(n.inputCount, 2); + }); + + [nodes.searchRoot, nodes.k1c0].forEach((n) => { + assert.equal(n.codepointLength, 0); + }); + [nodes.sc1, nodes.k1c1, nodes.k2c1].forEach((n) => { + assert.equal(n.codepointLength, 1); + }); + [nodes.sc2, nodes.k1c2, nodes.k2c2].forEach((n) => { + assert.equal(n.codepointLength, 2); + }); + [nodes.k1c3, nodes.k2c3].forEach((n) => { + assert.equal(n.codepointLength, 3); + }); + }); }); describe('constituentPaths', () => { diff --git a/web/src/test/auto/resources/searchQuotientUtils.ts b/web/src/test/auto/resources/searchQuotientUtils.ts index cbb3959240c..4816caaf32e 100644 --- a/web/src/test/auto/resources/searchQuotientUtils.ts +++ b/web/src/test/auto/resources/searchQuotientUtils.ts @@ -1,6 +1,13 @@ import { LexicalModelTypes } from "@keymanapp/common-types"; -import { SearchQuotientCluster, SearchQuotientNode, SearchQuotientRoot, SearchQuotientSpur } from "@keymanapp/lm-worker/test-index"; +import { + DeletionQuotientSpur, + InsertionQuotientSpur, + SearchQuotientCluster, + SearchQuotientNode, + SearchQuotientRoot, + SearchQuotientSpur +} from "@keymanapp/lm-worker/test-index"; import Distribution = LexicalModelTypes.Distribution; import Transform = LexicalModelTypes.Transform; @@ -88,8 +95,31 @@ export function constituentPaths(node: SearchQuotientNode): SearchQuotientSpur[] return node.parents.flatMap((p) => constituentPaths(p)); } else if(node instanceof SearchQuotientSpur) { const parentPaths = constituentPaths(node.parents[0]); + let pathsToExtend = parentPaths; + + if(node instanceof InsertionQuotientSpur) { + pathsToExtend = pathsToExtend.filter(s => { + const tail = s[s.length - 1]; + + // Deletion nodes and modules should always be ordered after those for + // insertion in order to avoid duplicating search paths. (Insertions may + // stick to the right of a root, while deletions always process inputs; they + // may thus precede deletions.) + // + // Also, internally, insertion edges are not built after deletion (or empty) edges. + if(tail instanceof DeletionQuotientSpur) { + return false; + } else if(tail.insertLength == 0 && tail.leftDeleteLength == 0) { + // Insertions should also not appear after empty nodes; there's no net + // difference between inserting before and inserting after. + return false; + } + + return true; + }); + } if(parentPaths.length > 0) { - return parentPaths.map(p => { + return pathsToExtend.map(p => { p.push(node); return p; }); From e67980e0f870e8aff0164f7c30364756ed04ec9d Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Wed, 4 Feb 2026 11:33:33 -0600 Subject: [PATCH 2/5] feat(web): add unit tests for basic uses of specialized spur types --- .../search-quotient-spur.tests.ts | 132 ++++++++++++++---- 1 file changed, 108 insertions(+), 24 deletions(-) diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts index 7a58e9b99e7..ae66ad5a99e 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts @@ -310,12 +310,12 @@ export function buildQuotientDocFixture() { const k2c3_ins = new InsertionQuotientSpur(k2c2); const k2c3 = new SearchQuotientCluster([k2c3_del, k2c3_ef, k2c3_gh, k2c3_ins]); - return {searchRoot, sc1, sc2, k1c0, k1c1, k1c2, k1c3, k2c1, k2c2, k2c3}; + return {searchRoot, sc1, sc2, k1c0, k1c1, k1c2, k1c3, k2c0, k2c1, k2c2, k2c3}; } describe('SearchQuotientSpur', () => { describe('constructor', () => { - it('initializes from a lexical model', () => { + it('initializes from a lexical model (legacy)', () => { const path = new LegacyQuotientRoot(testModel); assert.equal(path.inputCount, 0); assert.equal(path.codepointLength, 0); @@ -324,6 +324,15 @@ describe('SearchQuotientSpur', () => { assert.deepEqual(path.parents, []); }); + it('initializes from a lexical model', () => { + const path = new SearchQuotientRoot(testModel); + assert.equal(path.inputCount, 0); + assert.equal(path.codepointLength, 0); + assert.isNumber(path.spaceId); + assert.deepEqual(path.bestExample, {text: '', p: 1}); + assert.deepEqual(path.parents, []); + }); + it('may be extended from root path', () => { const rootPath = new LegacyQuotientRoot(testModel); @@ -516,11 +525,11 @@ describe('SearchQuotientSpur', () => { [nodes.k1c0, nodes.k1c1, nodes.k1c2, nodes.k1c3].forEach((n) => { assert.equal(n.inputCount, 1); }); - [nodes.k2c1, nodes.k2c2, nodes.k2c3].forEach((n) => { + [nodes.k2c0, nodes.k2c1, nodes.k2c2, nodes.k2c3].forEach((n) => { assert.equal(n.inputCount, 2); }); - [nodes.searchRoot, nodes.k1c0].forEach((n) => { + [nodes.searchRoot, nodes.k1c0, nodes.k2c0].forEach((n) => { assert.equal(n.codepointLength, 0); }); [nodes.sc1, nodes.k1c1, nodes.k2c1].forEach((n) => { @@ -536,33 +545,53 @@ describe('SearchQuotientSpur', () => { }); describe('constituentPaths', () => { - it('includes a single entry array when all parents are SearchQuotientSpurs', () => { - const { paths } = buildSimplePathSplitFixture(); - const finalPath = paths[4]; + describe('on the simple-path split fixture', () => { + it('includes a single entry array when all parents are SearchQuotientSpurs', () => { + const { paths } = buildSimplePathSplitFixture(); + const finalPath = paths[4]; + + assert.equal(constituentPaths(finalPath).length, 1); - assert.equal(constituentPaths(finalPath).length, 1); + const pathSequence = constituentPaths(finalPath)[0]; + assert.equal(pathSequence.length, 4); // 4 inputs; does not include root node - const pathSequence = constituentPaths(finalPath)[0]; - assert.equal(pathSequence.length, 4); // 4 inputs; does not include root node + assert.sameOrderedMembers(pathSequence, paths.slice(1)); + }); - assert.sameOrderedMembers(pathSequence, paths.slice(1)); + it('properly enumerates child paths when encountering SearchCluster ancestors', () => { + const fixture = buildAlphabeticClusterFixtures(); + const finalPath = fixture.paths[4].path_k4c6; + + // The longest SearchPath at the end of that fixture's set is based on a + // lead-in cluster; all variants of that should be included. + assert.equal(constituentPaths(finalPath).length, constituentPaths(fixture.clusters.cluster_k3c4).length); + + // That cluster holds the different potential penultimate paths; + // finalPath's inputs are added directly after any variation that may be + // output from the cluster. + assert.sameDeepMembers(constituentPaths(finalPath), constituentPaths(fixture.clusters.cluster_k3c4).map((p) => { + p.push(finalPath); + return p; + })); + }); }); - it('properly enumerates child paths when encountering SearchCluster ancestors', () => { - const fixture = buildAlphabeticClusterFixtures(); - const finalPath = fixture.paths[4].path_k4c6; + describe('for the final quotient-graph doc example', () => { + it('handles insertion-only quotient-graph paths', () => { + const {sc2} = buildQuotientDocFixture(); - // The longest SearchPath at the end of that fixture's set is based on a - // lead-in cluster; all variants of that should be included. - assert.equal(constituentPaths(finalPath).length, constituentPaths(fixture.clusters.cluster_k3c4).length); + const sc2Constituents = constituentPaths(sc2); + assert.equal(sc2Constituents.length, 1); + sc2Constituents.forEach(s => s.forEach(p => assert.isTrue(p instanceof InsertionQuotientSpur))); + }); - // That cluster holds the different potential penultimate paths; - // finalPath's inputs are added directly after any variation that may be - // output from the cluster. - assert.sameDeepMembers(constituentPaths(finalPath), constituentPaths(fixture.clusters.cluster_k3c4).map((p) => { - p.push(finalPath); - return p; - })); + it('handles deletion-only quotient-graph paths', () => { + const { k2c0 } = buildQuotientDocFixture(); + + const k2c0Constituents = constituentPaths(k2c0); + assert.equal(k2c0Constituents.length, 1); + k2c0Constituents.forEach(s => s.forEach(p => assert.isTrue(p instanceof DeletionQuotientSpur))); + }); }); }); @@ -625,6 +654,61 @@ describe('SearchQuotientSpur', () => { assert.notEqual(spur2.edgeKey, spur3.edgeKey); assert.notEqual(spur3.edgeKey, spur1.edgeKey); }); + + it('is different for different insert locations', () => { + const {sc2} = buildQuotientDocFixture(); + + const sc2ConstituentPath = constituentPaths(sc2)[0]; + assert.notEqual(sc2ConstituentPath[0].edgeKey, sc2ConstituentPath[1].edgeKey); + }); + + it('is different for different delete locations', () => { + const {k2c0} = buildQuotientDocFixture(); + + const k2c0ConstituentPath = constituentPaths(k2c0)[0]; + assert.notEqual(k2c0ConstituentPath[0].edgeKey, k2c0ConstituentPath[1].edgeKey); + }); + + it('is different for delete spurs and substitution spurs for the same inputs', () => { + const root = new SearchQuotientRoot(testModel); + const dist: Distribution = [ + { sample: { insert: 'a', deleteLeft: 0, id: 13 }, p: .6 }, + { sample: { insert: 'b', deleteLeft: 0, id: 13 }, p: .4 } + ]; + const inputSrc: PathInputProperties = { + segment: { + transitionId: 13, + start: 0 + }, + subsetId: generateSubsetId(), + bestProbFromSet: dist[0].p + } + + const deletionSpur = new DeletionQuotientSpur(root, dist, inputSrc); + const substitutionSpur = new SubstitutionQuotientSpur(root, dist, inputSrc); + + assert.notEqual(deletionSpur.edgeKey, substitutionSpur.edgeKey); + }); + + it('is different for delete spurs and substitutions of empty inputs', () => { + const root = new SearchQuotientRoot(testModel); + const dist: Distribution = [ + { sample: { insert: '', deleteLeft: 0, id: 13 }, p: 1 } + ]; + const inputSrc: PathInputProperties = { + segment: { + transitionId: 13, + start: 0 + }, + subsetId: generateSubsetId(), + bestProbFromSet: dist[0].p + } + + const deletionSpur = new DeletionQuotientSpur(root, dist, inputSrc); + const substitutionSpur = new SubstitutionQuotientSpur(root, dist, inputSrc); + + assert.notEqual(deletionSpur.edgeKey, substitutionSpur.edgeKey); + }); }); describe('split()', () => { From d4c549d520af837d39949fc2798887ef38963a3c Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Wed, 4 Feb 2026 15:55:48 -0600 Subject: [PATCH 3/5] feat(web): add quotient-path fixture split unit tests --- .../search-quotient-spur.tests.ts | 140 +++++++++++++++++- 1 file changed, 139 insertions(+), 1 deletion(-) diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts index ae66ad5a99e..bf96e260440 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts @@ -37,7 +37,6 @@ import { constituentPaths, quotientPathHasInputs } from '#test-resources/searchQ const testModel = new TrieModel(jsonFixture('models/tries/english-1000')); - // https://www.compart.com/en/unicode/block/U+1D400 const mathBoldUpperA = 0x1D400; // Mathematical Bold Capital A const mathBoldLowerA = 0x1D41A; // Small A @@ -67,6 +66,20 @@ function toMathematicalSMP(text: string) { return asSMP.join(''); } +export function toSpurTypeSequence(spurs: SearchQuotientNode[]): string[] { + return spurs.map(s => { + if(s instanceof InsertionQuotientSpur) { + return 'insert'; + } else if(s instanceof DeletionQuotientSpur) { + return 'delete'; + } else if(s instanceof SubstitutionQuotientSpur) { + return 'substitute'; + } else { + return 'legacy'; + } + }) +} + export function buildSimplePathSplitFixture() { const rootPath = new LegacyQuotientRoot(testModel); @@ -1751,6 +1764,131 @@ describe('SearchQuotientSpur', () => { assert.deepEqual((head as LegacyQuotientSpur).inputSource, headTarget.inputSource); assert.deepEqual((tail as LegacyQuotientSpur).inputSource, tailTarget.inputSource); }); + + describe('correctly handles the quotient-path doc example', () => { + it('splits properly at index 0', () => { + const nodes = buildQuotientDocFixture(); + const k2c3 = nodes.k2c3; + + const splits = k2c3.split(0); + assert.equal(splits.length, 1); + assert.equal(splits[0][0], nodes.searchRoot); + assert.isTrue(splits[0][1].isSameNode(k2c3)); + }); + + it('splits properly at index 1', () => { + const nodes = buildQuotientDocFixture(); + const k2c3 = nodes.k2c3; + + const splits = k2c3.split(1); + /* + * Unironically, actually 5. + * + * 3 are "clean", with 2 "dirty" - the "dirty" two both split in the + * middle of input for the first keystroke. + */ + assert.equal(splits.length, 5); + assert.sameDeepMembers(splits.map(s => s.map(n => n.inputCount)), [ + // clean + [0, 2], // 1 inserted char, then both inputs + [1, 1], // 1 std keystroke, then the other input + [2, 0], // BOTH keystrokes processed (with one deleted), then insertions afterward + // dirty + [1, 2], // 1/2 keystroke inserted, other 1/2 is in tail + [2, 1] // first keystroke deleted, second keystroke 1/2 inserted with remainder in tail + ]); + + splits.forEach(s => { + assert.equal(s[0].codepointLength, 1); + assert.equal(s[1].codepointLength, 2); + }); + + const cleanSplit0KeyHead = splits.find(s => s[0].inputCount == 0 && s[1].inputCount == 2); + const cleanSplit1KeyHead = splits.find(s => s[0].inputCount == 1 && s[1].inputCount == 1); + const cleanSplit2KeyHead = splits.find(s => s[0].inputCount == 2 && s[1].inputCount == 0); + + assert.equal(constituentPaths(cleanSplit0KeyHead[0]).length, 1); + assert.sameDeepMembers(constituentPaths(cleanSplit0KeyHead[0]).map(toSpurTypeSequence), [ + ['insert'] + ]); + assert.equal(constituentPaths(cleanSplit1KeyHead[0]).length, 2); + assert.sameDeepMembers(constituentPaths(cleanSplit1KeyHead[0]).map(toSpurTypeSequence), [ + ['insert', 'delete'], + ['substitute'] + ]); + assert.equal(constituentPaths(cleanSplit2KeyHead[0]).length, 3); + assert.sameDeepMembers(constituentPaths(cleanSplit2KeyHead[0]).map(toSpurTypeSequence), [ + ['insert', 'delete', 'delete'], + ['substitute', 'delete'], + ['delete', 'substitute'] + ]); + }); + + it('splits properly at index 2', () => { + const nodes = buildQuotientDocFixture(); + const k2c3 = nodes.k2c3; + + const splits = k2c3.split(2); + /* + * Unironically, actually 5. + * + * 3 are "clean", with 2 "dirty" - the "dirty" two both split in the + * middle of input for the first keystroke. + */ + assert.equal(splits.length, 5); + assert.sameDeepMembers(splits.map(s => s.map(n => n.inputCount)), [ + // clean + [0, 2], // 1 inserted char, then both inputs + [1, 1], // 1 std keystroke, then the other input + [2, 0], // BOTH keystrokes processed (with one deleted), then insertions afterward + // dirty + [1, 2], //insert, then 1/2 keystroke inserted, other 1/2 is in tail + [2, 1] // first keystroke deleted or short, second keystroke 1/2 inserted with remainder in tail + ]); + + splits.forEach(s => { + assert.equal(s[0].codepointLength, 2); + assert.equal(s[1].codepointLength, 1); + }); + + const cleanSplit0KeyHead = splits.find(s => s[0].inputCount == 0 && s[1].inputCount == 2); + const cleanSplit1KeyHead = splits.find(s => s[0].inputCount == 1 && s[1].inputCount == 1); + const cleanSplit2KeyHead = splits.find(s => s[0].inputCount == 2 && s[1].inputCount == 0); + + assert.equal(constituentPaths(cleanSplit0KeyHead[0]).length, 1); + assert.sameDeepMembers(constituentPaths(cleanSplit0KeyHead[0]).map(toSpurTypeSequence), [ + ['insert', 'insert'] + ]); + assert.equal(constituentPaths(cleanSplit1KeyHead[0]).length, 4); + assert.sameDeepMembers(constituentPaths(cleanSplit1KeyHead[0]).map(toSpurTypeSequence), [ + ['insert', 'insert', 'delete'], // is too high an edit-cost, though... + ['substitute', 'insert'], + ['insert', 'substitute'], + ['substitute'] + ]); + assert.equal(constituentPaths(cleanSplit2KeyHead[0]).length, 8); + assert.sameDeepMembers(constituentPaths(cleanSplit2KeyHead[0]).map(toSpurTypeSequence), [ + ['insert', 'insert', 'delete', 'delete'], // is too high an edit-cost, though... + ['substitute', 'substitute'], + ['substitute', 'delete'], + ['delete', 'substitute'], + ['insert', 'substitute', 'delete'], + ['substitute', 'insert', 'delete'], + ['insert', 'delete', 'substitute'], + ['delete', 'substitute', 'insert'] + ]); + }); + + it('splits properly at index 3', () => { + const nodes = buildQuotientDocFixture(); + const k2c3 = nodes.k2c3; + + const splits = k2c3.split(3); + assert.equal(splits.length, 1); + assert.equal(splits[0][0], k2c3); + assert.isTrue(splits[0][1].isSameNode(nodes.searchRoot)); + }); + }); }); // Placed after `split()` because many cases mock a reversal of split-test results. From 991fe7641a910e569f405822e0d3469368bda03e Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Thu, 5 Feb 2026 13:29:05 -0600 Subject: [PATCH 4/5] docs(web): add specialized spur headers --- .../src/main/correction/deletion-quotient-spur.ts | 9 +++++++++ .../src/main/correction/insertion-quotient-spur.ts | 10 ++++++++++ .../src/main/correction/substitution-quotient-spur.ts | 9 +++++++++ 3 files changed, 28 insertions(+) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/deletion-quotient-spur.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/deletion-quotient-spur.ts index 250f7e35030..99feaad88c9 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/deletion-quotient-spur.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/deletion-quotient-spur.ts @@ -1,3 +1,12 @@ +/** + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by jahorton on 2026-02-03 + * + * This file adds a SearchQuotientSpur variant modeling deletion of the corresponding + * keystroke. + */ + import { LexicalModelTypes } from "@keymanapp/common-types"; import { SearchNode } from "./distance-modeler.js"; diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/insertion-quotient-spur.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/insertion-quotient-spur.ts index b66d727a542..b4093be7294 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/insertion-quotient-spur.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/insertion-quotient-spur.ts @@ -1,3 +1,13 @@ +/** + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by jahorton on 2026-02-03 + * + * This file adds a SearchQuotientSpur variant modeling insertion of + * lexical-entry prefix characters - an operation with no corresponding + * keystroke. + */ + import { SearchNode } from "./distance-modeler.js"; import { SearchQuotientNode } from "./search-quotient-node.js"; import { SearchQuotientSpur } from "./search-quotient-spur.js"; diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/substitution-quotient-spur.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/substitution-quotient-spur.ts index 55a78748795..50a5fd2c15b 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/substitution-quotient-spur.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/substitution-quotient-spur.ts @@ -1,3 +1,12 @@ +/** + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by jahorton on 2026-02-03 + * + * This file adds a SearchQuotientSpur variant modeling match & substitute edit + * operations in regard to the corresponding keystroke. + */ + import { LexicalModelTypes } from "@keymanapp/common-types"; import { KMWString } from "@keymanapp/web-utils"; From 54d3559908f0c188d443906abf3a2a52c6b05c83 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Thu, 5 Feb 2026 13:52:48 -0600 Subject: [PATCH 5/5] change(web): make toSpurTypeSequence into a formal test util --- .../search-quotient-spur.tests.ts | 16 +--------------- .../test/auto/resources/searchQuotientUtils.ts | 17 ++++++++++++++++- 2 files changed, 17 insertions(+), 16 deletions(-) diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts index bf96e260440..be21d60ef86 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts @@ -33,7 +33,7 @@ import Distribution = LexicalModelTypes.Distribution; import Transform = LexicalModelTypes.Transform; import TrieModel = models.TrieModel; -import { constituentPaths, quotientPathHasInputs } from '#test-resources/searchQuotientUtils.js'; +import { constituentPaths, quotientPathHasInputs, toSpurTypeSequence } from '#test-resources/searchQuotientUtils.js'; const testModel = new TrieModel(jsonFixture('models/tries/english-1000')); @@ -66,20 +66,6 @@ function toMathematicalSMP(text: string) { return asSMP.join(''); } -export function toSpurTypeSequence(spurs: SearchQuotientNode[]): string[] { - return spurs.map(s => { - if(s instanceof InsertionQuotientSpur) { - return 'insert'; - } else if(s instanceof DeletionQuotientSpur) { - return 'delete'; - } else if(s instanceof SubstitutionQuotientSpur) { - return 'substitute'; - } else { - return 'legacy'; - } - }) -} - export function buildSimplePathSplitFixture() { const rootPath = new LegacyQuotientRoot(testModel); diff --git a/web/src/test/auto/resources/searchQuotientUtils.ts b/web/src/test/auto/resources/searchQuotientUtils.ts index 4816caaf32e..764fab5f226 100644 --- a/web/src/test/auto/resources/searchQuotientUtils.ts +++ b/web/src/test/auto/resources/searchQuotientUtils.ts @@ -6,7 +6,8 @@ import { SearchQuotientCluster, SearchQuotientNode, SearchQuotientRoot, - SearchQuotientSpur + SearchQuotientSpur, + SubstitutionQuotientSpur } from "@keymanapp/lm-worker/test-index"; import Distribution = LexicalModelTypes.Distribution; @@ -129,4 +130,18 @@ export function constituentPaths(node: SearchQuotientNode): SearchQuotientSpur[] } else { throw new Error("constituentPaths is unable to handle a new, unexpected SearchQuotientNode type"); } +} + +export function toSpurTypeSequence(spurs: SearchQuotientNode[]): string[] { + return spurs.map(s => { + if(s instanceof InsertionQuotientSpur) { + return 'insert'; + } else if(s instanceof DeletionQuotientSpur) { + return 'delete'; + } else if(s instanceof SubstitutionQuotientSpur) { + return 'substitute'; + } else { + return 'legacy'; + } + }) } \ No newline at end of file