From feba7cd450e04210fa515c45ef48967798f25270 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Tue, 10 Mar 2026 16:23:57 -0500 Subject: [PATCH 1/2] feat(web): extend search-graph test-helper funcs to handle specialized spur types Build-bot: skip build:web Test-bot: skip --- .../helpers/constituentPaths.tests.ts | 50 +++++++++++++++++++ .../helpers/constituentPaths.ts | 27 +++++++++- .../helpers/toSpurTypeSequence.ts | 20 ++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 web/src/test/auto/headless/engine/predictive-text/helpers/toSpurTypeSequence.ts diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.tests.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.tests.ts index 8ea47e9029f..30cac6a53a9 100644 --- a/web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.tests.ts @@ -9,9 +9,13 @@ import { assert } from 'chai'; +import { DeletionQuotientSpur, InsertionQuotientSpur } from '@keymanapp/lm-worker/test-index'; + import { constituentPaths } from "./constituentPaths.js"; +import { toSpurTypeSequence } from './toSpurTypeSequence.js'; import { buildCantLinearFixture } from './buildCantLinearFixture.js'; import { buildAlphabeticClusterFixtures } from './buildAlphabeticClusteredFixture.js'; +import { buildQuotientDocFixture } from './buildQuotientDocFixture.js'; describe('constituentPaths', () => { it('includes a single entry array when all parents are SearchQuotientSpurs', () => { @@ -42,4 +46,50 @@ describe('constituentPaths', () => { return p; })); }); + + describe('for the final quotient-graph doc example', () => { + it('handles insertion-only quotient-graph paths', () => { + const { sc2 } = buildQuotientDocFixture().nodes; + + const sc2Constituents = constituentPaths(sc2); + assert.equal(sc2Constituents.length, 1); + sc2Constituents.forEach(s => s.forEach(p => assert.isTrue(p instanceof InsertionQuotientSpur))); + }); + + it('handles deletion-only quotient-graph paths', () => { + const { k2c0 } = buildQuotientDocFixture().nodes; + + const k2c0Constituents = constituentPaths(k2c0); + assert.equal(k2c0Constituents.length, 1); + k2c0Constituents.forEach(s => s.forEach(p => assert.isTrue(p instanceof DeletionQuotientSpur))); + }); + + it('does not emit sequences with inserts immediately following deletes', () => { + const { k2c3 } = buildQuotientDocFixture().nodes; + + const k2c3Constituents = constituentPaths(k2c3); + + const shouldNotOccur = k2c3Constituents.find((seq) => { + const typeSeq = toSpurTypeSequence(seq); + return typeSeq.find((type, index) => { + return type == 'delete' && typeSeq[index+1] == 'insert'; + }); + }); + assert.isNotOk(shouldNotOccur); + }); + + it('does emit sequences with deletes immediately following inserts', () => { + const { k2c3 } = buildQuotientDocFixture().nodes; + + const k2c3Constituents = constituentPaths(k2c3); + + const shouldOccur = k2c3Constituents.find((seq) => { + const typeSeq = toSpurTypeSequence(seq); + return typeSeq.find((type, index) => { + return type == 'insert' && typeSeq[index+1] == 'delete'; + }); + }); + assert.isNotOk(shouldOccur); + }); + }); }); \ No newline at end of file diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.ts index 9c01297f0ce..80f047989b1 100644 --- a/web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.ts +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.ts @@ -8,6 +8,8 @@ */ import { + DeletionQuotientSpur, + InsertionQuotientSpur, SearchQuotientCluster, SearchQuotientNode, SearchQuotientRoot, @@ -26,8 +28,29 @@ export function constituentPaths(node: SearchQuotientNode): SearchQuotientSpur[] } else if(node instanceof SearchQuotientCluster) { return node.parents.flatMap((p) => constituentPaths(p)); } else if(node instanceof SearchQuotientSpur) { - const parentPaths = constituentPaths(node.parents[0]); - let pathsToExtend = parentPaths; + 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 pathsToExtend.map(p => { diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/toSpurTypeSequence.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/toSpurTypeSequence.ts new file mode 100644 index 00000000000..74c9ea182cb --- /dev/null +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/toSpurTypeSequence.ts @@ -0,0 +1,20 @@ +import { + DeletionQuotientSpur, + InsertionQuotientSpur, + SearchQuotientNode, + SubstitutionQuotientSpur +} from "@keymanapp/lm-worker/test-index"; + +export function toSpurTypeSequence(spurs: SearchQuotientNode[]): ('insert' | 'delete' | 'substitute' | 'legacy')[] { + 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 From 30f2f363b3d06a59364fd85c565b1bc75a169f2f Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Fri, 15 May 2026 01:47:47 +0700 Subject: [PATCH 2/2] change(web): Apply EB suggestions from code review Co-authored-by: Eberhard Beilharz --- .../engine/predictive-text/helpers/constituentPaths.tests.ts | 2 +- .../engine/predictive-text/helpers/constituentPaths.ts | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.tests.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.tests.ts index 30cac6a53a9..d3df0742d50 100644 --- a/web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.tests.ts @@ -89,7 +89,7 @@ describe('constituentPaths', () => { return type == 'insert' && typeSeq[index+1] == 'delete'; }); }); - assert.isNotOk(shouldOccur); + assert.isOk(shouldOccur); }); }); }); \ No newline at end of file diff --git a/web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.ts b/web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.ts index 80f047989b1..38325078719 100644 --- a/web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.ts +++ b/web/src/test/auto/headless/engine/predictive-text/helpers/constituentPaths.ts @@ -28,7 +28,8 @@ export function constituentPaths(node: SearchQuotientNode): SearchQuotientSpur[] } else if(node instanceof SearchQuotientCluster) { return node.parents.flatMap((p) => constituentPaths(p)); } else if(node instanceof SearchQuotientSpur) { - const parentPaths = constituentPaths(node.parents[0]); let pathsToExtend = parentPaths; + const parentPaths = constituentPaths(node.parents[0]); + let pathsToExtend = parentPaths; if(node instanceof InsertionQuotientSpur) { pathsToExtend = pathsToExtend.filter(s => { @@ -36,7 +37,7 @@ export function constituentPaths(node: SearchQuotientNode): SearchQuotientSpur[] // 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 + // stick to the right of a root, while deletions always process inputs; insertions // may thus precede deletions.) // // Also, internally, insertion edges are not built after deletion (or empty) edges.