From a827893f0af80f9f40951eaf34bfcc89bb26c5fc Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Thu, 15 Jan 2026 10:39:08 -0600 Subject: [PATCH 1/4] change(web): determine all module-paths leading to a SearchQuotientNode A .split operation on a token requires splitting these paths appropriately. Exposing this property, for use only in unit-testing, will help to validate that split operations are performed properly. Build-bot: skip build:web Test-bot: skip --- .../main/correction/search-quotient-node.ts | 26 +++++++++++++------ .../main/correction/search-quotient-root.ts | 5 ++++ .../main/correction/search-quotient-spur.ts | 13 ++++++++++ .../search-quotient-spur.tests.ts | 19 +++++++++++++- 4 files changed, 54 insertions(+), 9 deletions(-) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-node.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-node.ts index 44acc76e9f..f40e016a0f 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-node.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-node.ts @@ -10,10 +10,10 @@ import { LexicalModelTypes } from "@keymanapp/common-types"; import { SearchNode, SearchResult } from "./distance-modeler.js"; +import { SearchQuotientSpur } from "./search-quotient-spur.js"; import Distribution = LexicalModelTypes.Distribution; import Transform = LexicalModelTypes.Transform; -import { SearchQuotientSpur } from "./search-quotient-spur.js"; let SPACE_ID_SEED = 0; @@ -62,20 +62,20 @@ export interface InputSegment { } /** - * Models the properties and portion of an input event applied by a SearchSpace for - * correction-search purposes. + * Models the properties and portion of an input event applied by a + * SearchQuotientNode for correction-search purposes. */ export interface PathInputProperties { /** * Denotes the portion of the ongoing input stream represented by the corresponding - * input distribution(s) of a SearchSpace. + * input distribution(s) of a SearchQuotientNode. */ segment: InputSegment; /** * Notes the highest probability found in the input event's transform * distribution, regardless of whether or not that specific corresponding - * input is included within the SearchSpace's correction space. + * input is included within the SearchQuotientNode's correction space. */ bestProbFromSet: number; @@ -103,8 +103,8 @@ export interface SearchQuotientNode { readonly spaceId: number; /** - * Notes the SearchQuotientNode(s) whose correction-search paths are extended - * by this SearchQuotientNode. + * Notes the SearchQuotientNode(s) whose correction-search paths are extended by this + * SearchQuotientNode. */ readonly parents: SearchQuotientNode[]; @@ -163,7 +163,9 @@ export interface SearchQuotientNode { /** * Retrieves the sequence of inputs that led to this SearchSpace. * - * THIS WILL BE REMOVED SHORTLY. (Once SearchQuotientNode takes on merging & + * THIS WILL BE REMOVED SHORTLY in favor of `constituentPaths` below, which + * provides an improved view into the data and models multiple paths to the + * space when they exist. (Once SearchQuotientNode takes on merging & * splitting) */ readonly inputSequence: Distribution[]; @@ -193,6 +195,14 @@ export interface SearchQuotientNode { * maps compatible token source ranges to each other. */ get sourceRangeKey(): string; + + /** + * Enumerates the different potential SearchQuotientSpur sequences that lead + * to the current SearchQuotientNode. + * + * Intended only for use during unit testing. Does not include the root node. + */ + readonly constituentPaths: SearchQuotientSpur[][]; } /** 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 3a5e74a024..e56e398963 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 @@ -3,6 +3,7 @@ import { LexicalModelTypes } from '@keymanapp/common-types'; import { SearchNode, SearchResult } from './distance-modeler.js'; import { generateSpaceSeed, PathInputProperties, PathResult, SearchQuotientNode } from './search-quotient-node.js'; +import { SearchQuotientSpur } from './search-quotient-spur.js'; import LexicalModel = LexicalModelTypes.LexicalModel; @@ -98,4 +99,8 @@ export class SearchQuotientRoot implements SearchQuotientNode { get sourceRangeKey(): string { return ''; } + + get constituentPaths(): SearchQuotientSpur[][] { + 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 6ec3bffbf1..23c5439941 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 @@ -30,6 +30,7 @@ export abstract class SearchQuotientSpur implements SearchQuotientNode { readonly inputs?: Distribution; readonly inputSource?: PathInputProperties; + readonly bestProbInEdge: number; private parentNode: SearchQuotientNode; readonly spaceId: number; @@ -113,6 +114,18 @@ export abstract class SearchQuotientSpur implements SearchQuotientNode { return this._codepointLength; } + public get constituentPaths(): SearchQuotientSpur[][] { + const parentPaths = this.parents[0]?.constituentPaths ?? []; + if(parentPaths.length > 0) { + return parentPaths.map(p => { + p.push(this); + return p; + }); + } else { + return [[this]]; + } + } + public get lastInput(): Distribution> { // Shallow-copies the array to prevent external modification; the Transforms // are marked Readonly to prevent their modification as well. 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 92fb31f35e..f04690f3a4 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 @@ -44,7 +44,7 @@ export function buildSimplePathSplitFixture() { const path4 = new LegacyQuotientSpur(path3, distrib4, distrib4[0]); return { - paths: [rootPath, path1, path2, path3, path4], + paths: [null, path1, path2, path3, path4], distributions: [distrib1, distrib2, distrib3, distrib4] }; } @@ -270,6 +270,23 @@ describe('SearchQuotientSpur', () => { {text: '', p: 1}) ); assert.isTrue(quotientPathHasInputs(pathToSplit, distributions)); + assert.equal(pathToSplit.constituentPaths.length, 1); }); }); + + describe('constituentPaths', () => { + it('includes a single entry array when all parents are SearchPaths', () => { + const { paths } = buildSimplePathSplitFixture(); + const finalPath = paths[4]; + + assert.equal(finalPath.constituentPaths.length, 1); + + const pathSequence = finalPath.constituentPaths[0]; + assert.equal(pathSequence.length, 4); // 4 inputs; does not include root node + + assert.sameOrderedMembers(pathSequence, paths.slice(1)); + }); + + // TODO: add a test for mixed SearchPath / SearchCluster cases. + }); }); \ No newline at end of file From 1b40d3a3ba50919531e0a6dd24a16e580af62429 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Wed, 28 Jan 2026 17:14:49 -0600 Subject: [PATCH 2/4] refactor(web): make .constituentPaths a standalone method --- .../main/correction/search-quotient-node.ts | 33 ++++++++++++++----- .../main/correction/search-quotient-root.ts | 5 --- .../main/correction/search-quotient-spur.ts | 12 ------- .../search-quotient-spur.tests.ts | 10 +++--- 4 files changed, 30 insertions(+), 30 deletions(-) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-node.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-node.ts index f40e016a0f..32acf3e91d 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-node.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-node.ts @@ -11,6 +11,7 @@ import { LexicalModelTypes } from "@keymanapp/common-types"; import { SearchNode, SearchResult } from "./distance-modeler.js"; import { SearchQuotientSpur } from "./search-quotient-spur.js"; +import { SearchQuotientRoot } from "./search-quotient-root.js"; import Distribution = LexicalModelTypes.Distribution; import Transform = LexicalModelTypes.Transform; @@ -195,14 +196,6 @@ export interface SearchQuotientNode { * maps compatible token source ranges to each other. */ get sourceRangeKey(): string; - - /** - * Enumerates the different potential SearchQuotientSpur sequences that lead - * to the current SearchQuotientNode. - * - * Intended only for use during unit testing. Does not include the root node. - */ - readonly constituentPaths: SearchQuotientSpur[][]; } /** @@ -273,4 +266,28 @@ export function quotientPathHasInputs(node: SearchQuotientNode, keystrokeDistrib return parentHasInput(); } +} + +/** + * Enumerates the different potential SearchQuotientSpur sequences that lead + * to a SearchQuotientNode. + * + * Intended only for use during unit testing. Does not include the root node. + */ +export function constituentPaths(node: SearchQuotientNode): SearchQuotientSpur[][] { + if(node instanceof SearchQuotientRoot) { + return []; + } else if(node instanceof SearchQuotientSpur) { + const parentPaths = constituentPaths(node.parents[0]); + if(parentPaths.length > 0) { + return parentPaths.map(p => { + p.push(node); + return p; + }); + } else { + return [[node]]; + } + } else { + throw new Error("constituentPaths is unable to handle a new, unexpected SearchQuotientNode type"); + } } \ No newline at end of file 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 e56e398963..3a5e74a024 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 @@ -3,7 +3,6 @@ import { LexicalModelTypes } from '@keymanapp/common-types'; import { SearchNode, SearchResult } from './distance-modeler.js'; import { generateSpaceSeed, PathInputProperties, PathResult, SearchQuotientNode } from './search-quotient-node.js'; -import { SearchQuotientSpur } from './search-quotient-spur.js'; import LexicalModel = LexicalModelTypes.LexicalModel; @@ -99,8 +98,4 @@ export class SearchQuotientRoot implements SearchQuotientNode { get sourceRangeKey(): string { return ''; } - - get constituentPaths(): SearchQuotientSpur[][] { - 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 23c5439941..07d3d28962 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 @@ -114,18 +114,6 @@ export abstract class SearchQuotientSpur implements SearchQuotientNode { return this._codepointLength; } - public get constituentPaths(): SearchQuotientSpur[][] { - const parentPaths = this.parents[0]?.constituentPaths ?? []; - if(parentPaths.length > 0) { - return parentPaths.map(p => { - p.push(this); - return p; - }); - } else { - return [[this]]; - } - } - public get lastInput(): Distribution> { // Shallow-copies the array to prevent external modification; the Transforms // are marked Readonly to prevent their modification as well. 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 f04690f3a4..c95e6e333d 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 @@ -10,7 +10,7 @@ import { assert } from 'chai'; import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs'; -import { LegacyQuotientSpur, models, LegacyQuotientRoot, quotientPathHasInputs } from '@keymanapp/lm-worker/test-index'; +import { LegacyQuotientSpur, models, LegacyQuotientRoot, quotientPathHasInputs, constituentPaths } from '@keymanapp/lm-worker/test-index'; import TrieModel = models.TrieModel; @@ -270,18 +270,18 @@ describe('SearchQuotientSpur', () => { {text: '', p: 1}) ); assert.isTrue(quotientPathHasInputs(pathToSplit, distributions)); - assert.equal(pathToSplit.constituentPaths.length, 1); + assert.equal(constituentPaths(pathToSplit).length, 1); }); }); describe('constituentPaths', () => { - it('includes a single entry array when all parents are SearchPaths', () => { + it('includes a single entry array when all parents are SearchQuotientSpurs', () => { const { paths } = buildSimplePathSplitFixture(); const finalPath = paths[4]; - assert.equal(finalPath.constituentPaths.length, 1); + assert.equal(constituentPaths(finalPath).length, 1); - const pathSequence = finalPath.constituentPaths[0]; + const pathSequence = constituentPaths(finalPath)[0]; assert.equal(pathSequence.length, 4); // 4 inputs; does not include root node assert.sameOrderedMembers(pathSequence, paths.slice(1)); From 5fad61d6060034f956fdcd6d82ea02c94c0913ac Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Wed, 28 Jan 2026 17:15:37 -0600 Subject: [PATCH 3/4] change(web): establish unitTestEndpoints for worker-thread --- .../src/main/correction/search-quotient-node.ts | 9 +++++++-- .../predictive-text/worker-thread/src/main/test-index.ts | 8 +++++++- .../worker-thread/context/context-token.tests.ts | 6 ++++-- .../correction-search/search-quotient-node.tests.ts | 4 +++- .../correction-search/search-quotient-spur.tests.ts | 4 +++- 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-node.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-node.ts index 32acf3e91d..684fcb4131 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-node.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-node.ts @@ -210,7 +210,7 @@ export interface SearchQuotientNode { * @param keystrokeDistributions * @internal */ -export function quotientPathHasInputs(node: SearchQuotientNode, keystrokeDistributions: Distribution[]): boolean { +function quotientPathHasInputs(node: SearchQuotientNode, keystrokeDistributions: Distribution[]): boolean { if(!(node instanceof SearchQuotientSpur)) { for(const p of node.parents) { if(quotientPathHasInputs(p, keystrokeDistributions)) { @@ -274,7 +274,7 @@ export function quotientPathHasInputs(node: SearchQuotientNode, keystrokeDistrib * * Intended only for use during unit testing. Does not include the root node. */ -export function constituentPaths(node: SearchQuotientNode): SearchQuotientSpur[][] { +function constituentPaths(node: SearchQuotientNode): SearchQuotientSpur[][] { if(node instanceof SearchQuotientRoot) { return []; } else if(node instanceof SearchQuotientSpur) { @@ -290,4 +290,9 @@ export function constituentPaths(node: SearchQuotientNode): SearchQuotientSpur[] } else { throw new Error("constituentPaths is unable to handle a new, unexpected SearchQuotientNode type"); } +} + +export const unitTestEndpoints = { + quotientPathHasInputs, + constituentPaths } \ 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 291e9296b7..c6a05e7694 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 @@ -20,4 +20,10 @@ export { ModelCompositor } from './model-compositor.js'; export * from './predict-helpers.js'; export { default as TransformUtils } from './transformUtils.js' export { default as LMLayerWorker } from './index.js' -export * from './transform-subsets.js'; \ No newline at end of file +export * from './transform-subsets.js'; + +// unitTestEndpoints +import { unitTestEndpoints as SearchQuotientNodeEndpoints } from './correction/search-quotient-node.js'; +export const unitTestEndpoints: typeof SearchQuotientNodeEndpoints = { + ...SearchQuotientNodeEndpoints +} \ No newline at end of file diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-token.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-token.tests.ts index e242b79c54..07caadbe78 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-token.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/context/context-token.tests.ts @@ -13,14 +13,16 @@ import { assert } from 'chai'; import { default as defaultBreaker } from '@keymanapp/models-wordbreakers'; import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs'; import { LexicalModelTypes } from '@keymanapp/common-types'; +import { KMWString } from '@keymanapp/web-utils'; -import { ContextToken, correction, generateSubsetId, getBestMatches, models, preprocessInputSources, quotientPathHasInputs, SearchQuotientSpur } from '@keymanapp/lm-worker/test-index'; +import { ContextToken, correction, generateSubsetId, getBestMatches, models, preprocessInputSources, SearchQuotientSpur, unitTestEndpoints } from '@keymanapp/lm-worker/test-index'; import Distribution = LexicalModelTypes.Distribution; import ExecutionTimer = correction.ExecutionTimer; import Transform = LexicalModelTypes.Transform; import TrieModel = models.TrieModel; -import { KMWString } from '@keymanapp/web-utils'; + +const { quotientPathHasInputs } = unitTestEndpoints; var plainModel = new TrieModel(jsonFixture('models/tries/english-1000'), {wordBreaker: defaultBreaker}); diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-node.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-node.tests.ts index 85681a944e..7e50825b57 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-node.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-node.tests.ts @@ -1,10 +1,12 @@ import { assert } from 'chai'; import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs'; -import { LegacyQuotientRoot, models, quotientPathHasInputs } from '@keymanapp/lm-worker/test-index'; +import { LegacyQuotientRoot, models, unitTestEndpoints } from '@keymanapp/lm-worker/test-index'; import { buildSimplePathSplitFixture } from './search-quotient-spur.tests.js'; +const { quotientPathHasInputs } = unitTestEndpoints; + import TrieModel = models.TrieModel; const testModel = new TrieModel(jsonFixture('models/tries/english-1000')); 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 c95e6e333d..8c01201c6c 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 @@ -10,10 +10,12 @@ import { assert } from 'chai'; import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs'; -import { LegacyQuotientSpur, models, LegacyQuotientRoot, quotientPathHasInputs, constituentPaths } from '@keymanapp/lm-worker/test-index'; +import { LegacyQuotientSpur, models, LegacyQuotientRoot, unitTestEndpoints } from '@keymanapp/lm-worker/test-index'; import TrieModel = models.TrieModel; +const { constituentPaths, quotientPathHasInputs } = unitTestEndpoints; + const testModel = new TrieModel(jsonFixture('models/tries/english-1000')); export function buildSimplePathSplitFixture() { From 6d7d0ad055215a2fa49a3f612ed05dc8854bc5d6 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Thu, 29 Jan 2026 22:18:11 +0700 Subject: [PATCH 4/4] change(web): remove unused artifact --- .../worker-thread/src/main/correction/search-quotient-spur.ts | 1 - 1 file changed, 1 deletion(-) 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 07d3d28962..6ec3bffbf1 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 @@ -30,7 +30,6 @@ export abstract class SearchQuotientSpur implements SearchQuotientNode { readonly inputs?: Distribution; readonly inputSource?: PathInputProperties; - readonly bestProbInEdge: number; private parentNode: SearchQuotientNode; readonly spaceId: number;