From 4816729d0b1c3857ab1ca631235314c922b98505 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Tue, 10 Mar 2026 16:02:56 -0500 Subject: [PATCH 1/2] feat(web): add dedicated DeletionQuotientSpur type for handling deletion-edit search-graph edges Build-bot: skip build:web Test-bot: skip --- .../main/correction/deletion-quotient-spur.ts | 55 +++++ .../worker-thread/src/main/test-index.ts | 1 + .../helpers/buildQuotientDocFixture.tests.ts | 8 +- .../helpers/buildQuotientDocFixture.ts | 187 ++++++++-------- .../deletion-quotient-spur.tests.ts | 210 ++++++++++++++++++ .../insertion-quotient-spur.tests.ts | 21 +- 6 files changed, 384 insertions(+), 98 deletions(-) create mode 100644 web/src/engine/predictive-text/worker-thread/src/main/correction/deletion-quotient-spur.ts create mode 100644 web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/deletion-quotient-spur.tests.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 0000000000..d592a757e3 --- /dev/null +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/deletion-quotient-spur.ts @@ -0,0 +1,55 @@ +/** + * 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"; +import { PathInputProperties, SearchQuotientNode } from "./search-quotient-node.js"; +import { SearchQuotientSpur } from "./search-quotient-spur.js"; +import { TokenResultMapping } from "./token-result-mapping.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 buildEdgesFromResults(baseResults: ReadonlyArray): SearchNode[] { + return baseResults + // If there are already at least 2 edits for a node, do not add new edits. + .filter((n) => n.editCount < 2) + .flatMap((n) => n.buildDeletionEdges(this.inputs, this.spaceId)); + } + + get edgeKey(): string { + const baseKey = super.edgeKey; + // Be sure to mark the distinction between this node; appending a suffix can + // stack for multiple sequential deletions. + return `${baseKey}DEL`; + } + + get bestExample() { + // As deletion spurs add no new input, we can just re-use the parent node's version. + return this.parentNode.bestExample; + } +} \ 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 c3468beb80..a50eda0ea2 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 @@ -7,6 +7,7 @@ export { ContextTransition } from './correction/context-transition.js'; export * from './correction/correction-searchable.js'; export * from './correction/correction-result-mapping.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'; diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/buildQuotientDocFixture.tests.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/buildQuotientDocFixture.tests.ts index f8c661213b..812c26b50c 100644 --- a/web/src/test/auto/headless/engine/predictive-text/helpers/buildQuotientDocFixture.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/buildQuotientDocFixture.tests.ts @@ -9,17 +9,17 @@ describe('buildQuotientDocFixture() fixture', () => { [searchRoot, nodes.sc1, nodes.sc2].forEach((n) => { assert.equal(n.inputCount, 0); }); - [/*nodes.k1c0,*/ nodes.k1c1, nodes.k1c2, nodes.k1c3].forEach((n) => { + [nodes.k1c0, nodes.k1c1, nodes.k1c2, nodes.k1c3].forEach((n) => { assert.equal(n.inputCount, 1); }); - [/*nodes.k2c0, nodes.k2c1,*/ nodes.k2c2, nodes.k2c3].forEach((n) => { + [nodes.k2c0, nodes.k2c1, nodes.k2c2, nodes.k2c3].forEach((n) => { assert.equal(n.inputCount, 2); }); - [searchRoot/*, nodes.k1c0, nodes.k2c0*/].forEach((n) => { + [searchRoot, nodes.k1c0, nodes.k2c0].forEach((n) => { assert.equal(n.codepointLength, 0); }); - [nodes.sc1, nodes.k1c1/*, nodes.k2c1*/].forEach((n) => { + [nodes.sc1, nodes.k1c1, nodes.k2c1].forEach((n) => { assert.equal(n.codepointLength, 1); }); [nodes.sc2, nodes.k1c2, nodes.k2c2].forEach((n) => { diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/buildQuotientDocFixture.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/buildQuotientDocFixture.ts index f370a45e6a..d9623c27dc 100644 --- a/web/src/test/auto/headless/engine/predictive-text/helpers/buildQuotientDocFixture.ts +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/buildQuotientDocFixture.ts @@ -14,6 +14,7 @@ import { LexicalModelTypes } from '@keymanapp/common-types'; import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs'; import { + DeletionQuotientSpur, generateSubsetId, InsertionQuotientSpur, models, @@ -52,27 +53,27 @@ export function buildQuotientDocFixture() { 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 - // }); + // 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 - // }); + // 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, @@ -82,18 +83,18 @@ export function buildQuotientDocFixture() { subsetId: generateSubsetId(), bestProbFromSet: abDistrib[0].p }); - // const k1c1_ins = new InsertionQuotientSpur(k1c0); - const k1c1 = new SearchQuotientCluster([/*k1c1_del,*/ k1c1_ab, /*k1c1_ins*/]); + 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_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, @@ -113,7 +114,7 @@ export function buildQuotientDocFixture() { bestProbFromSet: abDistrib[0].p }); const k1c2_ins = new InsertionQuotientSpur(k1c1); - const k1c2 = new SearchQuotientCluster([/*k1c2_del, */ k1c2_ab, k1c2_cd, k1c2_ins]); + const k1c2 = new SearchQuotientCluster([k1c2_del, k1c2_ab, k1c2_cd, k1c2_ins]); const k1c3_ab = new SubstitutionQuotientSpur(sc2, abDistrib, { segment: { @@ -148,46 +149,46 @@ export function buildQuotientDocFixture() { { 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 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 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_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, @@ -197,27 +198,27 @@ export function buildQuotientDocFixture() { 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 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_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, @@ -237,11 +238,11 @@ export function buildQuotientDocFixture() { bestProbFromSet: efDistrib[0].p }); const k2c3_ins = new InsertionQuotientSpur(k2c2); - const k2c3 = new SearchQuotientCluster([/*k2c3_del, */ k2c3_ef, k2c3_gh, k2c3_ins]); + const k2c3 = new SearchQuotientCluster([k2c3_del, k2c3_ef, k2c3_gh, k2c3_ins]); return { searchRoot, - spurs: {sc1, sc2, k1c1_ab, k1c2_ab, k1c2_cd, k1c2_ins, k1c3_ab, k1c3_cd, k1c3_ins, k2c2_ef, k2c3_ef, k2c3_gh, k2c3_ins}, - nodes: {sc1, sc2, /* k1c0, */ k1c1, k1c2, k1c3, /* k2c0, k2c1, */ k2c2, k2c3} + spurs: {sc1, sc2, k1c1_ab, k1c2_ab, k1c2_cd, k1c2_ins, k1c3_ab, k1c3_cd, k1c3_ins, k2c1_del, k2c2_del, k2c2_ef, k2c3_ef, k2c3_gh, k2c3_ins}, + nodes: {sc1, sc2, k1c0, k1c1, k1c2, k1c3, k2c0, k2c1, k2c2, k2c3} }; } diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/deletion-quotient-spur.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/deletion-quotient-spur.tests.ts new file mode 100644 index 0000000000..c30282c830 --- /dev/null +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/deletion-quotient-spur.tests.ts @@ -0,0 +1,210 @@ +/* + * Keyman is copyright (C) SIL Global. MIT License. + * + * Created by jahorton on 2026-03-06 + * + * This file defines tests for the InsertionQuotientSpur class of the + * predictive-text correction-search engine's search graph. + */ + +import { assert } from 'chai'; + +import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs'; +import { + DeletionQuotientSpur, + models, + SearchQuotientRoot, + SubstitutionQuotientSpur +} from '@keymanapp/lm-worker/test-index'; + +import TrieModel = models.TrieModel; + +import { buildCantLinearFixture } from '../../helpers/buildCantLinearFixture.js'; +import { buildQuotientDocFixture } from '../../helpers/buildQuotientDocFixture.js'; + +import { analyzeQuotientNodeResults } from '../../helpers/analyzeQuotientNodeResults.js'; + +const testModel = new TrieModel(jsonFixture('models/tries/english-1000')); + +describe('DeletionQuotientSpur', () => { + describe('constructor', () => { + it('may be extended from root path', () => { + const rootPath = new SearchQuotientRoot(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 DeletionQuotientSpur(rootPath, leadEdgeDistribution, leadEdgeDistribution[0]); + + assert.equal(extendedPath.inputCount, 1); + assert.equal(extendedPath.codepointLength, 0); + assert.isNumber(extendedPath.spaceId); + assert.notEqual(extendedPath.spaceId, rootPath.spaceId); + assert.deepEqual(extendedPath.bestExample, { text: '', p: 1 } ); + + assert.deepEqual(extendedPath.parents, [rootPath]); + assert.deepEqual(extendedPath.inputs, leadEdgeDistribution); + + // 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 SearchQuotientRoot(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 SubstitutionQuotientSpur( + 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 DeletionQuotientSpur( + 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, 1); + assert.isNumber(length2Path.spaceId); + assert.notEqual(length2Path.spaceId, length1Path.spaceId); + assert.deepEqual(length2Path.bestExample, length1Path.bestExample); + 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('is different for different delete locations', () => { + const {k1c0, k2c0} = buildQuotientDocFixture().nodes; + + assert.notEqual(k2c0.edgeKey, k1c0.edgeKey); + }); + + it('is different from the parent node\'s key', () => { + const { k1c1_ab, k2c1_del, k1c2_cd, k2c2_del } = buildQuotientDocFixture().spurs; + + assert.notEqual(k2c1_del.edgeKey, k1c1_ab.edgeKey); + assert.notEqual(k2c2_del.edgeKey, k1c2_cd.edgeKey); + }); + }); + + describe('handleNextNode()', () => { + it('does not output results that directly match inputs', () => { + const caPath = buildCantLinearFixture().paths[2]; + const distrib = buildCantLinearFixture().distributions[2]; // for the third entry. + const deletionPath = new DeletionQuotientSpur(caPath, distrib, distrib[0]); + + const matchTargets = [ + 'can', + 'car', + 'cen', // 'cent' and 'center' are supported in this test model. + ]; + const analysis = analyzeQuotientNodeResults(deletionPath, matchTargets); + + assert.sameMembers(analysis.found, []); + assert.sameMembers(analysis.missing, matchTargets); + assert.isEmpty(analysis.foundWithDuplicates); + }); + + it('does not output results that substitute inputs', () => { + const caPath = buildCantLinearFixture().paths[2]; + const distrib = buildCantLinearFixture().distributions[2]; // for the third entry. + const deletionPath = new DeletionQuotientSpur(caPath, distrib, distrib[0]); + + 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' + ]; + const analysis = analyzeQuotientNodeResults(deletionPath, matchTargets); + + assert.sameMembers(analysis.found, []); + assert.sameMembers(analysis.missing, matchTargets); + assert.isEmpty(analysis.foundWithDuplicates); + }); + + it('does not output results that insert characters as needed', () => { + const caPath = buildCantLinearFixture().paths[2]; + const distrib = buildCantLinearFixture().distributions[2]; // for the third entry. + const deletionPath = new DeletionQuotientSpur(caPath, distrib, distrib[0]); + + const matchTargets = [ + 'can', // 'can' + (insert) 'n' (=> 'cannot') + 'car', // 'car' + (insert) 'e' + 'ran', // 'ran' + (insert) 'g' (=> 'range') + 'cann', // 'can' + (insert) 'n' (=> 'cannot') + 'care', // 'car' + (insert) 'e' + 'rang', // 'ran' + (insert) 'g' (=> 'range') + ]; + const analysis = analyzeQuotientNodeResults(deletionPath, matchTargets); + + assert.sameMembers(analysis.found, []); + assert.sameMembers(analysis.missing, matchTargets); + assert.isEmpty(analysis.foundWithDuplicates); + }); + + it('outputs results that delete incoming keystrokes as needed', () => { + const caPath = buildCantLinearFixture().paths[2]; + const distrib = buildCantLinearFixture().distributions[2]; // for the third entry. + const deletionPath = new DeletionQuotientSpur(caPath, distrib, distrib[0]); + + const matchTargets = [ + // Deletes the last char. + 'ca', + 'ce', + 're' + ]; + const analysis = analyzeQuotientNodeResults(deletionPath, matchTargets); + + assert.sameMembers(analysis.found, matchTargets); + assert.sameMembers(analysis.missing, []); + assert.isEmpty(analysis.foundWithDuplicates); + }); + }); +}); \ No newline at end of file diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/insertion-quotient-spur.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/insertion-quotient-spur.tests.ts index 205433ca77..074f261d34 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/insertion-quotient-spur.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/insertion-quotient-spur.tests.ts @@ -11,6 +11,7 @@ import { assert } from 'chai'; import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs'; import { + DeletionQuotientSpur, InsertionQuotientSpur, models, SearchQuotientRoot, @@ -166,8 +167,26 @@ describe('InsertionQuotientSpur', () => { assert.isEmpty(analysis.foundWithDuplicates); }); - it.skip('does not output results when immediately following a deletion spur edit', () => { + it('does not output results when immediately following a deletion spur edit', () => { + const caPath = buildCantLinearFixture().paths[2]; + const distrib = buildCantLinearFixture().distributions[2]; // for the third entry. + const deletionPath = new DeletionQuotientSpur(caPath, distrib, distrib[0]); + const followingInsert = new InsertionQuotientSpur(deletionPath); + + for( + let searchResult = followingInsert.handleNextNode(); + searchResult.type != 'none'; + searchResult = followingInsert.handleNextNode() + ) { + // While the insertion quotient node will forward results from the deletion version, + // it should never actually build search routes that go delete -> insert directly. + if(searchResult.type == 'complete') { + assert.notEqual(searchResult.mapping.lastEdgeType, 'insertion'); + assert.notEqual(searchResult.mapping.spaceId, followingInsert.spaceId); + } + } + // We should reach the end of the loop without once processing an actual 'insert' case. }); }); }); \ No newline at end of file From 98165291ea47f30c026c64eb2e0649b31d884f41 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Fri, 15 May 2026 01:40:44 +0700 Subject: [PATCH 2/2] docs(web): fix header comment Co-authored-by: Eberhard Beilharz --- .../correction-search/deletion-quotient-spur.tests.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/deletion-quotient-spur.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/deletion-quotient-spur.tests.ts index c30282c830..e9020b51ec 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/deletion-quotient-spur.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/deletion-quotient-spur.tests.ts @@ -3,7 +3,7 @@ * * Created by jahorton on 2026-03-06 * - * This file defines tests for the InsertionQuotientSpur class of the + * This file defines tests for the DeletionQuotientSpur class of the * predictive-text correction-search engine's search graph. */