From 9bd3f68bc427fc2f848af46031aeb2001e9e1704 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Wed, 4 Mar 2026 15:57:40 -0600 Subject: [PATCH 1/6] refactor(web): improve clarity and organization of search-graph root + legacy-spur unit tests As the next work in line will introduce new, specialized spur types designed to replace the current 'legacy spurs', it is wise to clarify existing search-graph unit tests and which sections of the code they actually target. We'll eventually drop behaviors specific to 'legacy' spurs, but those that apply to the new incoming specialized spur types should be preserved. At the same time, it may be wise to improve the unit testing of each specific type by placing each within its own specialized unit-test suite, then adding new tests that test and clarify the role of each type. Build-bot: skip build:web Test-bot: skip --- .../main/correction/search-quotient-root.ts | 8 + .../legacy-quotient-spur.tests.ts | 385 ++++++++++++++++++ .../search-quotient-root.tests.ts | 86 ++++ .../search-quotient-spur.tests.ts | 233 ----------- 4 files changed, 479 insertions(+), 233 deletions(-) create mode 100644 web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/legacy-quotient-spur.tests.ts create mode 100644 web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-root.tests.ts diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-root.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-root.ts index 90e8e83822..845b47c843 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-root.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-root.ts @@ -29,6 +29,10 @@ export class SearchQuotientRoot implements SearchQuotientNode { * @param model */ constructor(model: LexicalModel) { + if(!model.traverseFromRoot) { + throw new Error("The active lexical model does not support traversal-based searching."); + } + this.rootNode = new SearchNode(model.traverseFromRoot(), generateSpaceSeed(), t => model.toKey(t)); this.model = model; this.rootResult = new SearchResult(this.rootNode); @@ -122,6 +126,10 @@ export class SearchQuotientRoot implements SearchQuotientNode { } merge(space: SearchQuotientNode): SearchQuotientNode { + if(this.model != space.model) { + throw new Error("Cannot merge search graphs based on different LexicalModels"); + } + // Head node for the incoming path is empty, so skip it. if(space.parents.length == 0 || space instanceof SearchQuotientRoot) { return this; diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/legacy-quotient-spur.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/legacy-quotient-spur.tests.ts new file mode 100644 index 0000000000..fd5ad049a3 --- /dev/null +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/legacy-quotient-spur.tests.ts @@ -0,0 +1,385 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by jahorton on 2025-10-29 + * + * This file defines tests for the SearchQuotientSpur classes of the + * predictive-text correction-search engine. + */ + +import { assert } from 'chai'; + +import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs'; +import { + generateSubsetId, + LegacyQuotientRoot, + LegacyQuotientSpur, + models, + PathResult, +} from '@keymanapp/lm-worker/test-index'; + +import { buildCantLinearFixture } from '../../helpers/buildCantLinearFixture.js'; + +import TrieModel = models.TrieModel; + +const testModel = new TrieModel(jsonFixture('models/tries/english-1000')); + +describe('LegacyQuotientSpur', () => { + describe('constructor', () => { + it('initializes from a lexical model', () => { + const path = new LegacyQuotientRoot(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); + + const leadEdgeDistribution = [ + {sample: {insert: 't', deleteLeft: 0, id: 13 }, p: 0.5}, + {sample: {insert: 'a', deleteLeft: 0, id: 13 }, p: 0.3}, + {sample: {insert: 'o', deleteLeft: 0, id: 13 }, p: 0.2} + ]; + + const extendedPath = new LegacyQuotientSpur(rootPath, leadEdgeDistribution, leadEdgeDistribution[0]); + + assert.equal(extendedPath.inputCount, 1); + assert.equal(extendedPath.codepointLength, 1); + assert.isNumber(extendedPath.spaceId); + assert.notEqual(extendedPath.spaceId, rootPath.spaceId); + assert.deepEqual(extendedPath.bestExample, {text: 't', p: 0.5}); + assert.deepEqual(extendedPath.parents, [rootPath]); + assert.deepEqual(extendedPath.inputs, leadEdgeDistribution); + assert.deepEqual(extendedPath.inputSegments, [ + { + transitionId: leadEdgeDistribution[0].sample.id, + start: 0 + } + ]); + + // Assert the root is unchanged. + assert.equal(rootPath.inputCount, 0); + // Should (still) have codepointLength == 0 once it's defined. + assert.deepEqual(rootPath.bestExample, {text: '', p: 1}); + assert.deepEqual(rootPath.parents, []); + }); + + it('may be built from arbitrary prior SearchQuotientSpur', () => { + const rootPath = new LegacyQuotientRoot(testModel); + + const leadEdgeDistribution = [ + {sample: {insert: 't', deleteLeft: 0, id: 13 }, p: 0.5}, + {sample: {insert: 'a', deleteLeft: 0, id: 13 }, p: 0.3}, + {sample: {insert: 'o', deleteLeft: 0, id: 13 }, p: 0.2} + ]; + const inputClone = leadEdgeDistribution.map(e => ({...e})); + + const length1Path = new LegacyQuotientSpur( + rootPath, + leadEdgeDistribution, + leadEdgeDistribution[0] + ); + + const tailEdgeDistribution = [ + {sample: {insert: 'r', deleteLeft: 0, id: 17 }, p: 0.6}, + {sample: {insert: 'e', deleteLeft: 0, id: 17 }, p: 0.25}, + {sample: {insert: 'h', deleteLeft: 0, id: 17 }, p: 0.15} + ]; + + const length2Path = new LegacyQuotientSpur( + length1Path, + tailEdgeDistribution, + tailEdgeDistribution[0] + ); + + // Verify that the prior distribution remains fully unaltered. + assert.deepEqual(leadEdgeDistribution, inputClone); + + assert.equal(length2Path.inputCount, 2); + assert.equal(length2Path.codepointLength, 2); + assert.isNumber(length2Path.spaceId); + assert.notEqual(length2Path.spaceId, length1Path.spaceId); + assert.deepEqual(length2Path.bestExample, {text: 'tr', p: leadEdgeDistribution[0].p * tailEdgeDistribution[0].p}); + assert.deepEqual(length2Path.parents, [length1Path]); + assert.deepEqual(length2Path.inputs, tailEdgeDistribution); + assert.deepEqual(length2Path.inputSegments, [ + { + transitionId: leadEdgeDistribution[0].sample.id, + start: 0 + }, { + transitionId: tailEdgeDistribution[0].sample.id, + start: 0 + } + ]); + + assert.equal(length1Path.inputCount, 1); + assert.equal(length1Path.codepointLength, 1); + assert.isNumber(length1Path.spaceId); + assert.notEqual(length1Path.spaceId, rootPath.spaceId); + assert.deepEqual(length1Path.bestExample, {text: 't', p: 0.5}); + assert.deepEqual(length1Path.parents, [rootPath]); + assert.deepEqual(length1Path.inputs, leadEdgeDistribution); + }); + + it('throws if input and input-source transition IDs mismatch', () => { + const rootPath = new LegacyQuotientRoot(testModel); + + const leadEdgeDistribution = [ + {sample: {insert: 't', deleteLeft: 0, id: 13 }, p: 0.5}, + {sample: {insert: 'a', deleteLeft: 0, id: 13 }, p: 0.3}, + {sample: {insert: 'o', deleteLeft: 0, id: 13 }, p: 0.2} + ]; + + assert.throws(() => new LegacyQuotientSpur(rootPath, leadEdgeDistribution, { + ...leadEdgeDistribution[0], + sample: {...leadEdgeDistribution[0].sample, id: 15} + })); + }); + + it('may extend with a Transform inserting multiple codepoints', () => { + const rootPath = new LegacyQuotientRoot(testModel); + + const leadEdgeDistribution = [ + {sample: {insert: 't', deleteLeft: 0, id: 13 }, p: 0.5}, + {sample: {insert: 'a', deleteLeft: 0, id: 13 }, p: 0.3}, + {sample: {insert: 'o', deleteLeft: 0, id: 13 }, p: 0.2} + ]; + const inputClone = leadEdgeDistribution.map(e => ({...e})); + + const length1Path = new LegacyQuotientSpur( + rootPath, + leadEdgeDistribution, + leadEdgeDistribution[0] + ); + + const tailEdgeDistribution = [ + {sample: {insert: 'ri', deleteLeft: 0, id: 17 }, p: 0.6}, + {sample: {insert: 'er', deleteLeft: 0, id: 17 }, p: 0.25}, + {sample: {insert: 'hi', deleteLeft: 0, id: 17 }, p: 0.15} + ]; + + const length2Path = new LegacyQuotientSpur( + length1Path, + tailEdgeDistribution, + tailEdgeDistribution[0] + ); + + // Verify that the prior distribution remains fully unaltered. + assert.deepEqual(leadEdgeDistribution, inputClone); + + assert.equal(length2Path.inputCount, 2); + assert.equal(length2Path.codepointLength, 3); + assert.isNumber(length2Path.spaceId); + assert.notEqual(length2Path.spaceId, length1Path.spaceId); + assert.deepEqual(length2Path.bestExample, {text: 'tri', p: leadEdgeDistribution[0].p * tailEdgeDistribution[0].p}); + assert.deepEqual(length2Path.parents, [length1Path]); + assert.deepEqual(length2Path.inputs, tailEdgeDistribution); + assert.deepEqual(length2Path.inputSegments, [ + { + transitionId: leadEdgeDistribution[0].sample.id, + start: 0 + }, { + transitionId: tailEdgeDistribution[0].sample.id, + start: 0 + } + ]); + + assert.equal(length1Path.inputCount, 1); + assert.equal(length1Path.codepointLength, 1); + assert.isNumber(length1Path.spaceId); + assert.notEqual(length1Path.spaceId, rootPath.spaceId); + assert.deepEqual(length1Path.bestExample, {text: 't', p: 0.5}); + assert.deepEqual(length1Path.parents, [rootPath]); + assert.deepEqual(length1Path.inputs, leadEdgeDistribution); + }); + }); + + describe('.edgeKey', () => { + it('changes when input source subset IDs differ', () => { + const root = new LegacyQuotientRoot(testModel); + + const {distributions} = buildCantLinearFixture(); + const inputSrc = { + segment: { + transitionId: distributions[0][0].sample.id, + start: 0 + }, + subsetId: generateSubsetId(), + bestProbFromSet: distributions[0][0].p + }; + + const spur1 = new LegacyQuotientSpur(root, distributions[0], { + ...inputSrc, + subsetId: generateSubsetId() + }); + const spur2 = new LegacyQuotientSpur(root, distributions[0], { + ...inputSrc, + subsetId: generateSubsetId() + }); + + assert.notEqual(spur1.edgeKey, spur2.edgeKey); + }); + + it('changes when different parts of the same input source are used', () => { + const root = new LegacyQuotientRoot(testModel); + + const {distributions} = buildCantLinearFixture(); + const inputSrc = { + segment: { + transitionId: distributions[0][0].sample.id, + start: 0 + }, + subsetId: generateSubsetId(), + bestProbFromSet: distributions[0][0].p + }; + + const spur1 = new LegacyQuotientSpur(root, distributions[0], inputSrc); + const spur2 = new LegacyQuotientSpur(root, distributions[0], { + ...inputSrc, + segment: { + ...inputSrc.segment, + end: 1 + } + }); + const spur3 = new LegacyQuotientSpur(root, distributions[0], { + ...inputSrc, + segment: { + ...inputSrc.segment, + start: inputSrc.segment.start + 1 + } + }); + + assert.notEqual(spur1.edgeKey, spur2.edgeKey); + assert.notEqual(spur2.edgeKey, spur3.edgeKey); + assert.notEqual(spur3.edgeKey, spur1.edgeKey); + }); + }); + + describe('handleNextNode()', () => { + it('outputs results that directly match inputs', () => { + const canPath = buildCantLinearFixture().paths[3]; + + const matchTargets = [ + 'can', + 'car', + 'cen', // 'cent' and 'center' are supported in this test model. + ]; + let matchCount = 0; + + let result: PathResult = canPath.handleNextNode(); + while(result.type != 'none') { + if(result.type == 'complete') { + const resultKey = result.finalNode.resultKey; + if(matchTargets.find((entry) => entry == resultKey)) { + matchCount++; + } + } + + if(matchCount == matchTargets.length) { + break; + } + + result = canPath.handleNextNode(); + } + + assert.notEqual(result.type, 'none'); + }); + + it('outputs results that substitute inputs', () => { + const canPath = buildCantLinearFixture().paths[3]; + + const matchTargets = [ + // Replacement of first char + 'man', + 'far', + // Replacement of second char + 'con', // 'consider' and variants thereof are also supported. + 'cor', // 'corner' + // Replacement of third char + 'cal', // 'call' + ]; + let matchCount = 0; + + let result: PathResult = canPath.handleNextNode(); + while(result.type != 'none') { + if(result.type == 'complete') { + const resultKey = result.finalNode.resultKey; + if(matchTargets.find((entry) => entry == resultKey)) { + matchCount++; + } + } + + if(matchCount == matchTargets.length) { + break; + } + + result = canPath.handleNextNode(); + } + + assert.notEqual(result.type, 'none'); + }); + + it('outputs results that insert characters as needed', () => { + const canPath = buildCantLinearFixture().paths[3]; + + const matchTargets = [ + 'char', // 'c' + (insert) 'h' + 'ar' + 'than', // 't' + (insert) 'h' + 'an' + ]; + let matchCount = 0; + + let result: PathResult = canPath.handleNextNode(); + while(result.type != 'none') { + if(result.type == 'complete') { + const resultKey = result.finalNode.resultKey; + if(matchTargets.find((entry) => entry == resultKey)) { + matchCount++; + } + } + + if(matchCount == matchTargets.length) { + break; + } + + result = canPath.handleNextNode(); + } + + assert.notEqual(result.type, 'none'); + }); + + it('outputs results that delete incoming keystrokes as needed', () => { + const canPath = buildCantLinearFixture().paths[3]; + + const matchTargets = [ + // Delete only first + 'an', // (delete) 'c'/'r'/'t' + 'an', for 'and' and 'any' + 'en', // (delete) 'c'/'r'/'t' + 'en', for 'end', + // Even delete second + 'n', // model possesses words starting with just 'n' + 't' // ... and 't'. + ]; + let matchCount = 0; + + let result: PathResult = canPath.handleNextNode(); + while(result.type != 'none') { + if(result.type == 'complete') { + const resultKey = result.finalNode.resultKey; + if(matchTargets.find((entry) => entry == resultKey)) { + matchCount++; + } + } + + if(matchCount == matchTargets.length) { + break; + } + + result = canPath.handleNextNode(); + } + + assert.notEqual(result.type, 'none'); + }); + }); +}); \ No newline at end of file diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-root.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-root.tests.ts new file mode 100644 index 0000000000..a140376c24 --- /dev/null +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-root.tests.ts @@ -0,0 +1,86 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by jahorton on 2025-03-04 + * + * This file defines tests for the SearchQuotientRoot class of the + * predictive-text correction-search engine. + */ + +import { assert } from 'chai'; + +import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs'; +import { + models, + SearchQuotientRoot, +} from '@keymanapp/lm-worker/test-index'; + +import { constituentPaths } from '../../helpers/constituentPaths.js'; + +import DummyModel = models.DummyModel; +import TrieModel = models.TrieModel; + +const testModel = new TrieModel(jsonFixture('models/tries/english-1000')); +const altModel = new TrieModel(jsonFixture('models/tries/accented')); + +describe('SearchQuotientSpur', () => { + describe('constructor', () => { + 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('throws an error for lexical models that do not support traversal-based searching', () => { + assert.throws(() => new SearchQuotientRoot(new DummyModel({}))); + }); + }); + + it('constituentPaths returns an empty array', () => { + const path = new SearchQuotientRoot(testModel); + assert.deepEqual(constituentPaths(path), []); + }); + + describe('.isSameNode returns true for separate instance on same model', () => { + const root1 = new SearchQuotientRoot(testModel); + const root2 = new SearchQuotientRoot(testModel); + + assert.isTrue(root1.isSameNode(root2)); + }); + + it('split() results in two separate root instances', () => { + const root = new SearchQuotientRoot(testModel); + + const splitResults = root.split(0); + + // Only one way to split it... + assert.equal(splitResults.length, 1); + + const [head, tail] = splitResults[0]; + assert.notStrictEqual(head, tail); + assert.isTrue(head.isSameNode(tail)); + }); + + describe('merge()', () => { + it('treats two copies of same-model root as same node', () => { + const head = new SearchQuotientRoot(testModel); + const tail = new SearchQuotientRoot(testModel); + + const merged = head.merge(tail); + + assert.isTrue(head.isSameNode(tail)); + assert.isTrue(merged.isSameNode(head)); + assert.isTrue(merged.isSameNode(tail)); + }); + + it('rejects roots from different models', () => { + const head = new SearchQuotientRoot(testModel); + const tail = new SearchQuotientRoot(altModel); + + assert.throws(() => head.merge(tail)); + }); + }); +}); \ No newline at end of file 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 474ea09c76..70b428c5b5 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 @@ -63,239 +63,6 @@ function toMathematicalSMP(text: string) { } describe('SearchQuotientSpur', () => { - describe('constructor', () => { - it('initializes from a lexical model', () => { - const path = new LegacyQuotientRoot(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); - - const leadEdgeDistribution = [ - {sample: {insert: 't', deleteLeft: 0, id: 13 }, p: 0.5}, - {sample: {insert: 'a', deleteLeft: 0, id: 13 }, p: 0.3}, - {sample: {insert: 'o', deleteLeft: 0, id: 13 }, p: 0.2} - ]; - - const extendedPath = new LegacyQuotientSpur(rootPath, leadEdgeDistribution, leadEdgeDistribution[0]); - - assert.equal(extendedPath.inputCount, 1); - assert.equal(extendedPath.codepointLength, 1); - assert.isNumber(extendedPath.spaceId); - assert.notEqual(extendedPath.spaceId, rootPath.spaceId); - assert.deepEqual(extendedPath.bestExample, {text: 't', p: 0.5}); - assert.deepEqual(extendedPath.parents, [rootPath]); - assert.deepEqual(extendedPath.inputs, leadEdgeDistribution); - assert.deepEqual(extendedPath.inputSegments, [ - { - transitionId: leadEdgeDistribution[0].sample.id, - start: 0 - } - ]); - - // Assert the root is unchanged. - assert.equal(rootPath.inputCount, 0); - // Should (still) have codepointLength == 0 once it's defined. - assert.deepEqual(rootPath.bestExample, {text: '', p: 1}); - assert.deepEqual(rootPath.parents, []); - }); - - it('may be built from arbitrary prior SearchQuotientSpur', () => { - const rootPath = new LegacyQuotientRoot(testModel); - - const leadEdgeDistribution = [ - {sample: {insert: 't', deleteLeft: 0, id: 13 }, p: 0.5}, - {sample: {insert: 'a', deleteLeft: 0, id: 13 }, p: 0.3}, - {sample: {insert: 'o', deleteLeft: 0, id: 13 }, p: 0.2} - ]; - const inputClone = leadEdgeDistribution.map(e => ({...e})); - - const length1Path = new LegacyQuotientSpur( - rootPath, - leadEdgeDistribution, - leadEdgeDistribution[0] - ); - - const tailEdgeDistribution = [ - {sample: {insert: 'r', deleteLeft: 0, id: 17 }, p: 0.6}, - {sample: {insert: 'e', deleteLeft: 0, id: 17 }, p: 0.25}, - {sample: {insert: 'h', deleteLeft: 0, id: 17 }, p: 0.15} - ]; - - const length2Path = new LegacyQuotientSpur( - length1Path, - tailEdgeDistribution, - tailEdgeDistribution[0] - ); - - // Verify that the prior distribution remains fully unaltered. - assert.deepEqual(leadEdgeDistribution, inputClone); - - assert.equal(length2Path.inputCount, 2); - assert.equal(length2Path.codepointLength, 2); - assert.isNumber(length2Path.spaceId); - assert.notEqual(length2Path.spaceId, length1Path.spaceId); - assert.deepEqual(length2Path.bestExample, {text: 'tr', p: leadEdgeDistribution[0].p * tailEdgeDistribution[0].p}); - assert.deepEqual(length2Path.parents, [length1Path]); - assert.deepEqual(length2Path.inputs, tailEdgeDistribution); - assert.deepEqual(length2Path.inputSegments, [ - { - transitionId: leadEdgeDistribution[0].sample.id, - start: 0 - }, { - transitionId: tailEdgeDistribution[0].sample.id, - start: 0 - } - ]); - - assert.equal(length1Path.inputCount, 1); - assert.equal(length1Path.codepointLength, 1); - assert.isNumber(length1Path.spaceId); - assert.notEqual(length1Path.spaceId, rootPath.spaceId); - assert.deepEqual(length1Path.bestExample, {text: 't', p: 0.5}); - assert.deepEqual(length1Path.parents, [rootPath]); - assert.deepEqual(length1Path.inputs, leadEdgeDistribution); - }); - - it('throws if input and input-source transition IDs mismatch', () => { - const rootPath = new LegacyQuotientRoot(testModel); - - const leadEdgeDistribution = [ - {sample: {insert: 't', deleteLeft: 0, id: 13 }, p: 0.5}, - {sample: {insert: 'a', deleteLeft: 0, id: 13 }, p: 0.3}, - {sample: {insert: 'o', deleteLeft: 0, id: 13 }, p: 0.2} - ]; - - assert.throws(() => new LegacyQuotientSpur(rootPath, leadEdgeDistribution, { - ...leadEdgeDistribution[0], - sample: {...leadEdgeDistribution[0].sample, id: 15} - })); - }); - - it('may extend with a Transform inserting multiple codepoints', () => { - const rootPath = new LegacyQuotientRoot(testModel); - - const leadEdgeDistribution = [ - {sample: {insert: 't', deleteLeft: 0, id: 13 }, p: 0.5}, - {sample: {insert: 'a', deleteLeft: 0, id: 13 }, p: 0.3}, - {sample: {insert: 'o', deleteLeft: 0, id: 13 }, p: 0.2} - ]; - const inputClone = leadEdgeDistribution.map(e => ({...e})); - - const length1Path = new LegacyQuotientSpur( - rootPath, - leadEdgeDistribution, - leadEdgeDistribution[0] - ); - - const tailEdgeDistribution = [ - {sample: {insert: 'ri', deleteLeft: 0, id: 17 }, p: 0.6}, - {sample: {insert: 'er', deleteLeft: 0, id: 17 }, p: 0.25}, - {sample: {insert: 'hi', deleteLeft: 0, id: 17 }, p: 0.15} - ]; - - const length2Path = new LegacyQuotientSpur( - length1Path, - tailEdgeDistribution, - tailEdgeDistribution[0] - ); - - // Verify that the prior distribution remains fully unaltered. - assert.deepEqual(leadEdgeDistribution, inputClone); - - assert.equal(length2Path.inputCount, 2); - assert.equal(length2Path.codepointLength, 3); - assert.isNumber(length2Path.spaceId); - assert.notEqual(length2Path.spaceId, length1Path.spaceId); - assert.deepEqual(length2Path.bestExample, {text: 'tri', p: leadEdgeDistribution[0].p * tailEdgeDistribution[0].p}); - assert.deepEqual(length2Path.parents, [length1Path]); - assert.deepEqual(length2Path.inputs, tailEdgeDistribution); - assert.deepEqual(length2Path.inputSegments, [ - { - transitionId: leadEdgeDistribution[0].sample.id, - start: 0 - }, { - transitionId: tailEdgeDistribution[0].sample.id, - start: 0 - } - ]); - - assert.equal(length1Path.inputCount, 1); - assert.equal(length1Path.codepointLength, 1); - assert.isNumber(length1Path.spaceId); - assert.notEqual(length1Path.spaceId, rootPath.spaceId); - assert.deepEqual(length1Path.bestExample, {text: 't', p: 0.5}); - assert.deepEqual(length1Path.parents, [rootPath]); - assert.deepEqual(length1Path.inputs, leadEdgeDistribution); - }); - }); - - describe('.edgeKey', () => { - it('changes when input source subset IDs differ', () => { - const root = new LegacyQuotientRoot(testModel); - - const {distributions} = buildCantLinearFixture(); - const inputSrc = { - segment: { - transitionId: distributions[0][0].sample.id, - start: 0 - }, - subsetId: generateSubsetId(), - bestProbFromSet: distributions[0][0].p - }; - - const spur1 = new LegacyQuotientSpur(root, distributions[0], { - ...inputSrc, - subsetId: generateSubsetId() - }); - const spur2 = new LegacyQuotientSpur(root, distributions[0], { - ...inputSrc, - subsetId: generateSubsetId() - }); - - assert.notEqual(spur1.edgeKey, spur2.edgeKey); - }); - - it('changes when different parts of the same input source are used', () => { - const root = new LegacyQuotientRoot(testModel); - - const {distributions} = buildCantLinearFixture(); - const inputSrc = { - segment: { - transitionId: distributions[0][0].sample.id, - start: 0 - }, - subsetId: generateSubsetId(), - bestProbFromSet: distributions[0][0].p - }; - - const spur1 = new LegacyQuotientSpur(root, distributions[0], inputSrc); - const spur2 = new LegacyQuotientSpur(root, distributions[0], { - ...inputSrc, - segment: { - ...inputSrc.segment, - end: 1 - } - }); - const spur3 = new LegacyQuotientSpur(root, distributions[0], { - ...inputSrc, - segment: { - ...inputSrc.segment, - start: inputSrc.segment.start + 1 - } - }); - - assert.notEqual(spur1.edgeKey, spur2.edgeKey); - assert.notEqual(spur2.edgeKey, spur3.edgeKey); - assert.notEqual(spur3.edgeKey, spur1.edgeKey); - }); - }); - describe('split()', () => { describe(`on token comprised of single-char transforms: [crt][ae][nr][t]`, () => { const runSplit = (splitIndex: number) => { From 052f1f1f70defcaec7181991c38361d3d2c6bb0c Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Thu, 5 Mar 2026 13:31:50 -0600 Subject: [PATCH 2/6] refactor(web): extract assertion method used in multiple quotient-spur tests --- .../legacy-quotient-spur.tests.ts | 109 ++++++------------ 1 file changed, 33 insertions(+), 76 deletions(-) diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/legacy-quotient-spur.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/legacy-quotient-spur.tests.ts index fd5ad049a3..91e1f04573 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/legacy-quotient-spur.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/legacy-quotient-spur.tests.ts @@ -16,6 +16,7 @@ import { LegacyQuotientSpur, models, PathResult, + SearchQuotientNode, } from '@keymanapp/lm-worker/test-index'; import { buildCantLinearFixture } from '../../helpers/buildCantLinearFixture.js'; @@ -24,6 +25,34 @@ import TrieModel = models.TrieModel; const testModel = new TrieModel(jsonFixture('models/tries/english-1000')); +function assertResultsFromQuotientNode(path: SearchQuotientNode, results: string[], shouldMatch: boolean) { + let matchCount = 0; + + let result: PathResult = path.handleNextNode(); + while(result.type != 'none') { + if(result.type == 'complete') { + const resultKey = result.finalNode.resultKey; + if(results.find((entry) => entry == resultKey)) { + matchCount++; + } + } + + if(matchCount == results.length) { + break; + } + + result = path.handleNextNode(); + } + + if(shouldMatch) { + assert.notEqual(result.type, 'none'); + assert.equal(matchCount, results.length); + } else { + assert.equal(result.type, 'none'); + assert.equal(matchCount, 0); + } +} + describe('LegacyQuotientSpur', () => { describe('constructor', () => { it('initializes from a lexical model', () => { @@ -267,25 +296,7 @@ describe('LegacyQuotientSpur', () => { 'car', 'cen', // 'cent' and 'center' are supported in this test model. ]; - let matchCount = 0; - - let result: PathResult = canPath.handleNextNode(); - while(result.type != 'none') { - if(result.type == 'complete') { - const resultKey = result.finalNode.resultKey; - if(matchTargets.find((entry) => entry == resultKey)) { - matchCount++; - } - } - - if(matchCount == matchTargets.length) { - break; - } - - result = canPath.handleNextNode(); - } - - assert.notEqual(result.type, 'none'); + assertResultsFromQuotientNode(canPath, matchTargets, true); }); it('outputs results that substitute inputs', () => { @@ -301,25 +312,7 @@ describe('LegacyQuotientSpur', () => { // Replacement of third char 'cal', // 'call' ]; - let matchCount = 0; - - let result: PathResult = canPath.handleNextNode(); - while(result.type != 'none') { - if(result.type == 'complete') { - const resultKey = result.finalNode.resultKey; - if(matchTargets.find((entry) => entry == resultKey)) { - matchCount++; - } - } - - if(matchCount == matchTargets.length) { - break; - } - - result = canPath.handleNextNode(); - } - - assert.notEqual(result.type, 'none'); + assertResultsFromQuotientNode(canPath, matchTargets, true); }); it('outputs results that insert characters as needed', () => { @@ -329,25 +322,7 @@ describe('LegacyQuotientSpur', () => { 'char', // 'c' + (insert) 'h' + 'ar' 'than', // 't' + (insert) 'h' + 'an' ]; - let matchCount = 0; - - let result: PathResult = canPath.handleNextNode(); - while(result.type != 'none') { - if(result.type == 'complete') { - const resultKey = result.finalNode.resultKey; - if(matchTargets.find((entry) => entry == resultKey)) { - matchCount++; - } - } - - if(matchCount == matchTargets.length) { - break; - } - - result = canPath.handleNextNode(); - } - - assert.notEqual(result.type, 'none'); + assertResultsFromQuotientNode(canPath, matchTargets, true); }); it('outputs results that delete incoming keystrokes as needed', () => { @@ -361,25 +336,7 @@ describe('LegacyQuotientSpur', () => { 'n', // model possesses words starting with just 'n' 't' // ... and 't'. ]; - let matchCount = 0; - - let result: PathResult = canPath.handleNextNode(); - while(result.type != 'none') { - if(result.type == 'complete') { - const resultKey = result.finalNode.resultKey; - if(matchTargets.find((entry) => entry == resultKey)) { - matchCount++; - } - } - - if(matchCount == matchTargets.length) { - break; - } - - result = canPath.handleNextNode(); - } - - assert.notEqual(result.type, 'none'); + assertResultsFromQuotientNode(canPath, matchTargets, true); }); }); }); \ No newline at end of file From eb958fa4de04b99c67e95977b873f9fd1012cfeb Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Wed, 11 Mar 2026 08:14:58 -0500 Subject: [PATCH 3/6] fix(web): address most PR review concerns Covers unit test names, describe => it for one case, and consistency in an error case between spurs and roots. --- .../worker-thread/src/main/correction/search-quotient-root.ts | 2 +- .../worker-thread/src/main/correction/search-quotient-spur.ts | 4 ++++ .../correction-search/search-quotient-root.tests.ts | 4 ++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-root.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-root.ts index 845b47c843..8346c62ba1 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-root.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-root.ts @@ -68,7 +68,7 @@ export class SearchQuotientRoot implements SearchQuotientNode { */ public handleNextNode(): PathResult { if(this.hasBeenProcessed) { - return null; + return { type: 'none' }; } this.hasBeenProcessed = true; 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 ce94280950..fb08e99ce8 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 @@ -171,6 +171,10 @@ export abstract class SearchQuotientSpur implements SearchQuotientNode { // spaces are in sequence here. // `this` = head 'space'. public merge(space: SearchQuotientNode): SearchQuotientNode { + if(this.model != space.model) { + throw new Error("Cannot merge search graphs based on different LexicalModels"); + } + // Head node for the incoming path is empty, so skip it. if(space.parents.length == 0 || space instanceof SearchQuotientRoot) { return this; diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-root.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-root.tests.ts index a140376c24..f9c15a24ad 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-root.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-root.tests.ts @@ -23,7 +23,7 @@ import TrieModel = models.TrieModel; const testModel = new TrieModel(jsonFixture('models/tries/english-1000')); const altModel = new TrieModel(jsonFixture('models/tries/accented')); -describe('SearchQuotientSpur', () => { +describe('SearchQuotientRoot', () => { describe('constructor', () => { it('initializes from a lexical model', () => { const path = new SearchQuotientRoot(testModel); @@ -44,7 +44,7 @@ describe('SearchQuotientSpur', () => { assert.deepEqual(constituentPaths(path), []); }); - describe('.isSameNode returns true for separate instance on same model', () => { + it('.isSameNode returns true for separate instance on same model', () => { const root1 = new SearchQuotientRoot(testModel); const root2 = new SearchQuotientRoot(testModel); From 53eb74dccfd707d9ed814cc860c7024a07297d47 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Wed, 11 Mar 2026 08:47:22 -0500 Subject: [PATCH 4/6] refactor(web): relocate + rework node-specific search iteration function This function will also be utiliized for specifying tests for the specialized spur types coming up, so rather than duplicate the function, it's best to make it a proper test helper defined outside a specific unit test suite. --- .../helpers/analyzeQuotientNodeResults.ts | 83 +++++++++++++++++++ .../legacy-quotient-spur.tests.ts | 53 ++++-------- 2 files changed, 101 insertions(+), 35 deletions(-) create mode 100644 web/src/test/auto/headless/engine/predictive-text/helpers/analyzeQuotientNodeResults.ts diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/analyzeQuotientNodeResults.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/analyzeQuotientNodeResults.ts new file mode 100644 index 0000000000..799ef1e0cf --- /dev/null +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/analyzeQuotientNodeResults.ts @@ -0,0 +1,83 @@ +import { PathResult, SearchQuotientNode } from "@keymanapp/lm-worker/test-index"; + +/** + * Represents the results of a call to `analyzeQuotientNodeResults` during unit + * testing. + * + * All specified `expectedResults` entries will be found once - either in + * `found` or `missing` for each. If `found`, the result _may_ also appear in + * `foundWithDuplicates` if it is found more than once during the search. + */ +export interface SearchResultAnalysis { + /** + * Indicates specified strings (expected results) that were found via search + * on the provided node one or more times. + */ + found: string[]; + + /** + * Indicates specified strings (expected results) that were not found via + * search on the provided node. + */ + missing: string[]; + + /** + * Indicates specified strings (expected results) that were found via search + * on the provided node more than once. + */ + foundWithDuplicates: string[]; +} + +/** + * Processes all correction-search search targets that have a path passing + * through the specified SearchQuotientNode in order to determine whether or not + * valid paths exist for specified ("expected") correction strings. + * + * @param node The SearchQuotientNode to utilize for the search. + * @param expectedResults A set of strings used for the test scenario. Strings + * not found here will be ignored. + * @returns + */ +export function analyzeQuotientNodeResults( + node: SearchQuotientNode, + expectedResults: string[] +) { + let matchCount = 0; + + const matchMap: Map = new Map(); + + let result: PathResult = node.handleNextNode(); + while(result.type != 'none') { + if(result.type == 'complete') { + const resultKey = result.finalNode.resultKey; + if(expectedResults.find((entry) => entry == resultKey)) { + matchCount++; + + matchMap.set(resultKey, matchMap.get(resultKey) ?? 0 + 1); + } + } + + result = node.handleNextNode(); + } + + const analysis: SearchResultAnalysis = { + found: [], + missing: [], + foundWithDuplicates: [] + }; + + for(let key of expectedResults) { + const matchCount = matchMap.get(key); + + if(matchCount == 0) { + analysis.missing.push(key); + } else { + analysis.found.push(key); + if(matchCount > 1) { + analysis.foundWithDuplicates.push(key); + } + } + } + + return analysis; +} \ No newline at end of file diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/legacy-quotient-spur.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/legacy-quotient-spur.tests.ts index 91e1f04573..0989ab9d64 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/legacy-quotient-spur.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/legacy-quotient-spur.tests.ts @@ -14,45 +14,16 @@ import { generateSubsetId, LegacyQuotientRoot, LegacyQuotientSpur, - models, - PathResult, - SearchQuotientNode, + models } from '@keymanapp/lm-worker/test-index'; import { buildCantLinearFixture } from '../../helpers/buildCantLinearFixture.js'; +import { analyzeQuotientNodeResults } from '../../helpers/analyzeQuotientNodeResults.js'; import TrieModel = models.TrieModel; const testModel = new TrieModel(jsonFixture('models/tries/english-1000')); -function assertResultsFromQuotientNode(path: SearchQuotientNode, results: string[], shouldMatch: boolean) { - let matchCount = 0; - - let result: PathResult = path.handleNextNode(); - while(result.type != 'none') { - if(result.type == 'complete') { - const resultKey = result.finalNode.resultKey; - if(results.find((entry) => entry == resultKey)) { - matchCount++; - } - } - - if(matchCount == results.length) { - break; - } - - result = path.handleNextNode(); - } - - if(shouldMatch) { - assert.notEqual(result.type, 'none'); - assert.equal(matchCount, results.length); - } else { - assert.equal(result.type, 'none'); - assert.equal(matchCount, 0); - } -} - describe('LegacyQuotientSpur', () => { describe('constructor', () => { it('initializes from a lexical model', () => { @@ -296,7 +267,10 @@ describe('LegacyQuotientSpur', () => { 'car', 'cen', // 'cent' and 'center' are supported in this test model. ]; - assertResultsFromQuotientNode(canPath, matchTargets, true); + const analysis = analyzeQuotientNodeResults(canPath, matchTargets); + + assert.sameMembers(analysis.found, matchTargets); + assert.isEmpty(analysis.foundWithDuplicates); }); it('outputs results that substitute inputs', () => { @@ -312,7 +286,10 @@ describe('LegacyQuotientSpur', () => { // Replacement of third char 'cal', // 'call' ]; - assertResultsFromQuotientNode(canPath, matchTargets, true); + const analysis = analyzeQuotientNodeResults(canPath, matchTargets); + + assert.sameMembers(analysis.found, matchTargets); + assert.isEmpty(analysis.foundWithDuplicates); }); it('outputs results that insert characters as needed', () => { @@ -322,7 +299,10 @@ describe('LegacyQuotientSpur', () => { 'char', // 'c' + (insert) 'h' + 'ar' 'than', // 't' + (insert) 'h' + 'an' ]; - assertResultsFromQuotientNode(canPath, matchTargets, true); + const analysis = analyzeQuotientNodeResults(canPath, matchTargets); + + assert.sameMembers(analysis.found, matchTargets); + assert.isEmpty(analysis.foundWithDuplicates); }); it('outputs results that delete incoming keystrokes as needed', () => { @@ -336,7 +316,10 @@ describe('LegacyQuotientSpur', () => { 'n', // model possesses words starting with just 'n' 't' // ... and 't'. ]; - assertResultsFromQuotientNode(canPath, matchTargets, true); + const analysis = analyzeQuotientNodeResults(canPath, matchTargets); + + assert.sameMembers(analysis.found, matchTargets); + assert.isEmpty(analysis.foundWithDuplicates); }); }); }); \ No newline at end of file From 212588c4a36719199159260d2a240082dae1e69d Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Wed, 11 Mar 2026 09:26:45 -0500 Subject: [PATCH 5/6] fix(web): fix 'not found' case in new helper --- .../predictive-text/helpers/analyzeQuotientNodeResults.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/analyzeQuotientNodeResults.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/analyzeQuotientNodeResults.ts index 799ef1e0cf..c1aa18d79f 100644 --- a/web/src/test/auto/headless/engine/predictive-text/helpers/analyzeQuotientNodeResults.ts +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/analyzeQuotientNodeResults.ts @@ -67,7 +67,7 @@ export function analyzeQuotientNodeResults( }; for(let key of expectedResults) { - const matchCount = matchMap.get(key); + const matchCount = matchMap.get(key) ?? 0; if(matchCount == 0) { analysis.missing.push(key); From 04b1e1bcffdccc445b5cd79c15e8db93a78a8b73 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Thu, 12 Mar 2026 12:34:11 -0500 Subject: [PATCH 6/6] change(web): clean up new test helper function --- .../helpers/analyzeQuotientNodeResults.ts | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/analyzeQuotientNodeResults.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/analyzeQuotientNodeResults.ts index c1aa18d79f..21f44b6e98 100644 --- a/web/src/test/auto/headless/engine/predictive-text/helpers/analyzeQuotientNodeResults.ts +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/analyzeQuotientNodeResults.ts @@ -1,3 +1,13 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by jahorton on 2026-03-11 + * + * This file defines a unit-text helper designed to test what search-graph + * results for corrections may result from paths traversing through a given + * SearchQuotientNode. + */ + import { PathResult, SearchQuotientNode } from "@keymanapp/lm-worker/test-index"; /** @@ -42,8 +52,6 @@ export function analyzeQuotientNodeResults( node: SearchQuotientNode, expectedResults: string[] ) { - let matchCount = 0; - const matchMap: Map = new Map(); let result: PathResult = node.handleNextNode(); @@ -51,9 +59,7 @@ export function analyzeQuotientNodeResults( if(result.type == 'complete') { const resultKey = result.finalNode.resultKey; if(expectedResults.find((entry) => entry == resultKey)) { - matchCount++; - - matchMap.set(resultKey, matchMap.get(resultKey) ?? 0 + 1); + matchMap.set(resultKey, (matchMap.get(resultKey) ?? 0) + 1); } }