From 92b1387a29cc22580f2bd3a45de43499ad97a1d1 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Mon, 20 Apr 2026 17:00:30 -0500 Subject: [PATCH 1/2] change(web): add TokenResultMapping construction null guard --- .../worker-thread/src/main/correction/token-result-mapping.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/token-result-mapping.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/token-result-mapping.ts index 7e048148b0..b7e0e0a7b8 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/token-result-mapping.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/token-result-mapping.ts @@ -21,6 +21,10 @@ export class TokenResultMapping { constructor(node: SearchNode) { this.node = node; + + if(!node) { + throw new Error("Token correction-search result may not be null"); + } } get inputSequence(): ProbabilityMass[] { From 8a430ca637e66eca38330b290ca9bd4b59ed1acc Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Tue, 21 Apr 2026 16:27:55 -0500 Subject: [PATCH 2/2] change(web): increase TokenResultMapping encapsulation to prevent .spaceId ambiguity --- .../src/main/correction/distance-modeler.ts | 11 +++--- .../main/correction/legacy-quotient-spur.ts | 13 +++---- .../correction/search-quotient-cluster.ts | 12 +++---- .../main/correction/search-quotient-spur.ts | 4 +-- .../main/correction/token-result-mapping.ts | 36 ++++++++++++++++--- .../worker-thread/src/main/predict-helpers.ts | 6 ++-- .../correction-search/getBestMatches.tests.ts | 2 +- 7 files changed, 54 insertions(+), 30 deletions(-) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/distance-modeler.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/distance-modeler.ts index 159f3045d9..0a47ac7fb4 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/distance-modeler.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/distance-modeler.ts @@ -597,14 +597,14 @@ export async function *getBestMatches(searchModules: SearchQuotientNode[], timer // With potential prior results re-queued, NOW enqueue. (Not before - the heap may reheapify!) spaceQueue.enqueueAll(searchModules); - let currentReturns: {[resultKey: string]: SearchNode} = {}; + let currentReturns: {[resultKey: string]: TokenResultMapping} = {}; // Stage 2: the fun part; actually searching! do { const entry: TokenResultMapping = timer.time(() => { if((priorResultsQueue.peek()?.totalCost ?? Number.POSITIVE_INFINITY) <= spaceQueue.peek().currentCost) { const result = priorResultsQueue.dequeue(); - currentReturns[result.node.resultKey] = result.node; + currentReturns[result.matchString] = result; return result; } @@ -631,10 +631,11 @@ export async function *getBestMatches(searchModules: SearchQuotientNode[], timer // // If it occurs, we should re-emit it - it'll show up earlier in the // suggestions that way, as it should. - if((currentReturns[node.resultKey]?.currentCost ?? Number.MAX_VALUE) > node.currentCost) { - currentReturns[node.resultKey] = node; + if((currentReturns[node.resultKey]?.totalCost ?? Number.MAX_VALUE) > newResult.cost) { + const trm = new TokenResultMapping(newResult.finalNode) + currentReturns[node.resultKey] = trm; // Do not track yielded time. - return new TokenResultMapping(newResult.finalNode); + return trm; } } diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/legacy-quotient-spur.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/legacy-quotient-spur.ts index 80513255a8..ea19114362 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/legacy-quotient-spur.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/legacy-quotient-spur.ts @@ -14,6 +14,7 @@ import { KMWString } from '@keymanapp/web-utils'; import { SearchNode } from './distance-modeler.js'; import { PathResult, SearchQuotientNode, PathInputProperties } 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; @@ -40,7 +41,7 @@ export class LegacyQuotientSpur extends SearchQuotientSpur { const codepointLength = space.codepointLength + insertLength - inputSample.deleteLeft; super(space, inputs, inputSource, codepointLength); - this.queueNodes(this.buildEdgesForNodes(space.previousResults.map(r => r.node))); + this.queueNodes(this.buildEdgesFromResults(space.previousResults)); this.insertLength = insertLength; this.leftDeleteLength = inputSample.deleteLeft; return; @@ -50,19 +51,19 @@ export class LegacyQuotientSpur extends SearchQuotientSpur { return new LegacyQuotientSpur(parentNode, inputs, inputSource) as this; } - protected buildEdgesForNodes(baseNodes: ReadonlyArray) { + protected buildEdgesFromResults(priorResults: ReadonlyArray) { // With a newly-available input, we can extend new input-dependent paths from // our previously-reached 'extractedResults' nodes. - let outboundNodes = baseNodes.map((node) => { + let outboundNodes = priorResults.map((result) => { // Hard restriction: no further edits will be supported. This helps keep the search // more narrowly focused. - const substitutionsOnly = node.editCount == 2; + const substitutionsOnly = result.editCount == 2; let deletionEdges: SearchNode[] = []; if(!substitutionsOnly) { - deletionEdges = node.buildDeletionEdges(this.inputs, this.spaceId); + deletionEdges = result.buildDeletionEdges(this.inputs, this.spaceId); } - const substitutionEdges = node.buildSubstitutionEdges(this.inputs, this.spaceId); + const substitutionEdges = result.buildSubstitutionEdges(this.inputs, this.spaceId); // Skip the queue for the first pass; there will ALWAYS be at least one pass, // and queue-enqueing does come with a cost - avoid unnecessary overhead here. diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-cluster.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-cluster.ts index b622dc2aa7..c010b8fdfb 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-cluster.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-cluster.ts @@ -11,7 +11,6 @@ import { QueueComparator, PriorityQueue } from '@keymanapp/web-utils'; import { LexicalModelTypes } from '@keymanapp/common-types'; -import { SearchNode } from './distance-modeler.js'; import { LegacyQuotientRoot } from './legacy-quotient-root.js'; import { generateSpaceSeed, InputSegment, PathResult, SearchQuotientNode } from './search-quotient-node.js'; import { SearchQuotientSpur } from './search-quotient-spur.js'; @@ -36,7 +35,7 @@ export class SearchQuotientCluster implements SearchQuotientNode { * When new input is received, its entries are then used to append edges to the path in order * to find potential paths to reach a new viable end. */ - private completedPaths?: SearchNode[] = []; + private completedPaths?: TokenResultMapping[] = []; /** * Acts as a Map that prevents duplicating a correction-search path if reached @@ -86,8 +85,7 @@ export class SearchQuotientCluster implements SearchQuotientNode { this.spaceId = generateSpaceSeed(); this.lowestPossibleSingleCost = lowestPossibleSingleCost; - this.completedPaths = inboundPaths.flatMap(p => p.previousResults).map(r => r.node); - this.completedPaths.forEach((p) => p.spaceId = this.spaceId); + this.completedPaths = inboundPaths.flatMap(p => p.previousResults).map(r => new TokenResultMapping(r, this.spaceId)); this.selectionQueue.enqueueAll(inboundPaths); return; @@ -145,16 +143,14 @@ export class SearchQuotientCluster implements SearchQuotientNode { this.selectionQueue.enqueue(bestPath); if(currentResult.type == 'complete') { - const node = currentResult.finalNode; - node.spaceId = this.spaceId; - this.completedPaths?.push(currentResult.finalNode); + this.completedPaths.push(new TokenResultMapping(new TokenResultMapping(currentResult.finalNode), this.spaceId)); } return currentResult; } public get previousResults(): TokenResultMapping[] { - return this.completedPaths?.map((n) => new TokenResultMapping(n)) ?? []; + return this.completedPaths; } get model(): LexicalModelTypes.LexicalModel { 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 19d2ef6045..6a760b4365 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 @@ -320,7 +320,7 @@ export abstract class SearchQuotientSpur implements SearchQuotientNode { return Math.min(localCost, parentCost); } - protected abstract buildEdgesForNodes(baseNodes: ReadonlyArray): SearchNode[]; + protected abstract buildEdgesFromResults(baseNodes: ReadonlyArray): SearchNode[]; protected queueNodes(nodes: SearchNode[]) { this.selectionQueue.enqueueAll(nodes); @@ -347,7 +347,7 @@ export abstract class SearchQuotientSpur implements SearchQuotientNode { const result = this.parentNode.handleNextNode(); if(result.type == 'complete') { - this.queueNodes(this.buildEdgesForNodes([result.finalNode])); + this.queueNodes(this.buildEdgesFromResults([new TokenResultMapping(result.finalNode)])); } return { diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/token-result-mapping.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/token-result-mapping.ts index b7e0e0a7b8..2a175e914f 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/token-result-mapping.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/token-result-mapping.ts @@ -12,18 +12,28 @@ import { LexicalModelTypes } from '@keymanapp/common-types'; import { SearchNode, TraversableToken } from "./distance-modeler.js"; +import Distribution = LexicalModelTypes.Distribution; import LexiconTraversal = LexicalModelTypes.LexiconTraversal; import ProbabilityMass = LexicalModelTypes.ProbabilityMass; import Transform = LexicalModelTypes.Transform; export class TokenResultMapping { - readonly node: SearchNode; + private readonly node: SearchNode; + readonly spaceId: number; - constructor(node: SearchNode) { - this.node = node; + constructor(node: SearchNode); + constructor(mapping: TokenResultMapping, spaceId: number); + constructor(mappingBase: SearchNode | TokenResultMapping, spaceId?: number) { + if(!mappingBase) { + throw new Error("Result-mapping parameters may not be null"); + } - if(!node) { - throw new Error("Token correction-search result may not be null"); + if(mappingBase instanceof SearchNode) { + this.node = mappingBase; + this.spaceId = mappingBase.spaceId; + } else { + this.node = mappingBase.node; + this.spaceId = spaceId; } } @@ -39,6 +49,10 @@ export class TokenResultMapping { return this.node.resultKey; } + get editCount(): number { + return this.node.editCount; + } + /** * Gets the number of Damerau-Levenshtein edits needed to reach the node's * matchString from the output induced by the input sequence used to reach it. @@ -71,4 +85,16 @@ export class TokenResultMapping { get finalTraversal(): LexiconTraversal { return this.node.currentTraversal; } + + buildInsertionEdges(): SearchNode[] { + return this.node.buildInsertionEdges(); + } + + buildDeletionEdges(dist: Distribution, edgeId: number): SearchNode[] { + return this.node.buildDeletionEdges(dist, edgeId); + } + + buildSubstitutionEdges(dist: Distribution, edgeId: number): SearchNode[] { + return this.node.buildSubstitutionEdges(dist, edgeId); + } } \ No newline at end of file diff --git a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts index bff12b04b6..45f53df6af 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/predict-helpers.ts @@ -393,7 +393,7 @@ export function determineSuggestionAlignment( export function buildAndMapPredictions( transition: ContextTransition, tokenization: ContextTokenization, - match: TokenResultMapping + match: Readonly ): CorrectionPredictionTuple[] { const model = transition.final.model; const searchSpace = tokenization.tail.searchModule; @@ -536,7 +536,7 @@ export async function correctAndEnumerate( const correctionPredictionMap: Record> = {}; for await(const match of getBestMatches(searchModules, timer)) { // Corrections obtained: now to predict from them! - const tokenization = tokenizations.find(t => t.spaceId == match.node.spaceId); + const tokenization = tokenizations.find(t => t.spaceId == match.spaceId); // If our 'match' results in fully deleting the new token, reject it and try again. if(match.matchSequence.length == 0 && match.inputSequence.length != 0) { @@ -548,7 +548,7 @@ export async function correctAndEnumerate( continue; } - if(match.node.editCount > 0 && !searchModules.find(s => s.correctionsEnabled)) { + if(match.editCount > 0 && !searchModules.find(s => s.correctionsEnabled)) { continue; } diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/getBestMatches.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/getBestMatches.tests.ts index 88271dbbcd..4a2ee0d303 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/getBestMatches.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/getBestMatches.tests.ts @@ -21,7 +21,7 @@ function buildTestTimer() { } describe('getBestMatches', () => { - const checkRepeatableResults_teh = async (iter: AsyncGenerator) => { + const checkRepeatableResults_teh = async (iter: AsyncGenerator, any, any>) => { const firstIterResult = await iter.next(); // {value: , done: } assert.isFalse(firstIterResult.done);