diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/correction-result-mapping.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/correction-result-mapping.ts index 67e8fa62b6..d1903b6caf 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/correction-result-mapping.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/correction-result-mapping.ts @@ -27,10 +27,16 @@ export interface CorrectionResultMapping { readonly matchedResult: Readonly; /** - * Gets the "total cost" of the edge, which should be considered as the + * Gets the "correction cost" of the edge, which should be considered as the * negative log-likelihood of the input path taken to reach the node * multiplied by the 'probability' induced by needed Damerau-Levenshtein edits * to the resulting output. */ - readonly totalCost: number; + readonly correctionCost: number; + + /** + * The "total cost" of the edge - comprised of both the correction cost and the + * prediction cost based on the model's frequency data for the word. + */ + readonly currentCost: number; } \ No newline at end of file diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/correction-searchable.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/correction-searchable.ts index 74ca206e1e..648734c3ef 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/correction-searchable.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/correction-searchable.ts @@ -29,10 +29,14 @@ type CompleteSearchPath = { export type PathResult = NullPath | IntermediateSearchPath | CompleteSearchPath; -export function CORRECTION_QUEUE_COMPARATOR(a: T, b: T) { +export function PREDICTION_QUEUE_COMPARATOR(a: T, b: T) { return a.currentCost - b.currentCost; } +export function CORRECTION_QUEUE_COMPARATOR(a: T, b: T) { + return a.correctionCost - b.correctionCost; +} + /** * Represents objects that support correction search via the `getBestMatches` * method, providing metadata relative to optimizing the search process for 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 b85f6b98c0..d3ffc2e0a4 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 @@ -4,7 +4,7 @@ import { PriorityQueue } from 'keyman/common/web-utils'; import { LexicalModelTypes } from '@keymanapp/common-types'; import { ClassicalDistanceCalculation } from './classical-calculation.js'; -import { CORRECTION_QUEUE_COMPARATOR, CorrectionSearchable } from './correction-searchable.js'; +import { PREDICTION_QUEUE_COMPARATOR, CorrectionSearchable } from './correction-searchable.js'; import { CorrectionResultMapping } from './correction-result-mapping.js'; import { ExecutionTimer, STANDARD_TIME_BETWEEN_DEFERS } from './execution-timer.js'; import { SearchQuotientNode } from './search-quotient-node.js'; @@ -262,7 +262,7 @@ export class SearchNode { * The correction search evaluates Nodes in cost-ascending order based on this property's * return value. */ - get currentCost(): number { + get correctionCost(): number { // - We reintrepret 'known cost' as a psuedo-probability. // - Noting that 1/e = 0.367879441, an edit-distance cost of 1 may be intepreted as -ln(1/e) - a log-space 'likelihood'. // - Not exactly normalized, though. @@ -278,6 +278,14 @@ export class SearchNode { return EDIT_DISTANCE_COST_SCALE * this.editCount + this.inputSamplingCost; } + get predictionCost(): number { + return -Math.log(this.currentTraversal.p); + } + + get currentCost(): number { + return this.correctionCost + this.predictionCost; + } + addEdit() { this.addedEditCost++; } @@ -625,7 +633,7 @@ export async function *getBestMatches< // If no filter function is provided, default to one that always returns true. filter ??= () => true; - let spaceQueue = new PriorityQueue(CORRECTION_QUEUE_COMPARATOR); + let spaceQueue = new PriorityQueue(PREDICTION_QUEUE_COMPARATOR); // Stage 1 - if we already have extracted results, build a queue just for them // and iterate over it first. @@ -633,7 +641,7 @@ export async function *getBestMatches< // Does not get any results that another iterator pulls up after this is // created - and those results won't come up later in stage 2, either. Only // intended for restarting a search, not searching twice in parallel. - const priorResultsQueue = new PriorityQueue((a, b) => a.totalCost - b.totalCost); + const priorResultsQueue = new PriorityQueue(PREDICTION_QUEUE_COMPARATOR); priorResultsQueue.enqueueAll(searchModules.map((space) => space.previousResults).flat()); // With potential prior results re-queued, NOW enqueue. (Not before - the heap may reheapify!) @@ -642,7 +650,7 @@ export async function *getBestMatches< // Stage 2: the fun part; actually searching! do { const entry: ResultMapping = timer.time(() => { - if((priorResultsQueue.peek()?.totalCost ?? Number.POSITIVE_INFINITY) <= spaceQueue.peek().currentCost) { + if((priorResultsQueue.peek()?.currentCost ?? Number.POSITIVE_INFINITY) <= spaceQueue.peek().currentCost) { const result = priorResultsQueue.dequeue(); // There's no guarantee that the filter closure is the same instance as @@ -669,7 +677,7 @@ export async function *getBestMatches< let lowestCostSource = spaceQueue.dequeue(); const newResult = lowestCostSource.handleNextNode(); spaceQueue.enqueue(lowestCostSource); - spaceQueue = new PriorityQueue(CORRECTION_QUEUE_COMPARATOR, spaceQueue.toArray()); + spaceQueue = new PriorityQueue(PREDICTION_QUEUE_COMPARATOR, spaceQueue.toArray()); if(newResult.type == 'none') { return null; diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/legacy-quotient-root.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/legacy-quotient-root.ts index 7b381890be..dc73a02be8 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/legacy-quotient-root.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/legacy-quotient-root.ts @@ -1,7 +1,7 @@ import { PriorityQueue } from 'keyman/common/web-utils'; import { LexicalModelTypes } from '@keymanapp/common-types'; -import { CORRECTION_QUEUE_COMPARATOR, PathResult } from './correction-searchable.js'; +import { PREDICTION_QUEUE_COMPARATOR, PathResult } from './correction-searchable.js'; import { SearchQuotientNode } from './search-quotient-node.js'; import { SearchQuotientRoot } from './search-quotient-root.js'; import { SearchNode } from './distance-modeler.js'; @@ -10,7 +10,7 @@ import LexicalModel = LexicalModelTypes.LexicalModel; import { TokenResultMapping } from './token-result-mapping.js'; export class LegacyQuotientRoot extends SearchQuotientRoot { - private selectionQueue: PriorityQueue = new PriorityQueue(CORRECTION_QUEUE_COMPARATOR); + private selectionQueue: PriorityQueue = new PriorityQueue(PREDICTION_QUEUE_COMPARATOR); private processed: SearchNode[] = []; constructor(model: LexicalModel) { 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 a1134ddd3e..99e97b7f84 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 @@ -11,7 +11,7 @@ import { LexicalModelTypes } from '@keymanapp/common-types'; import { KMWString, PriorityQueue } from 'keyman/common/web-utils'; -import { CORRECTION_QUEUE_COMPARATOR, PathResult } from './correction-searchable.js'; +import { PREDICTION_QUEUE_COMPARATOR, PathResult } from './correction-searchable.js'; import { SearchNode } from './distance-modeler.js'; import { SearchQuotientNode, PathInputProperties } from './search-quotient-node.js'; import { SearchQuotientSpur } from './search-quotient-spur.js'; @@ -24,7 +24,7 @@ import Transform = LexicalModelTypes.Transform; // The set of search spaces corresponding to the same 'context' for search. // Whenever a wordbreak boundary is crossed, a new instance should be made. export class LegacyQuotientSpur extends SearchQuotientSpur { - private transposeQueue: PriorityQueue = new PriorityQueue(CORRECTION_QUEUE_COMPARATOR); + private transposeQueue: PriorityQueue = new PriorityQueue(PREDICTION_QUEUE_COMPARATOR); private incomingTransposeRootNodes: TokenResultMapping[] = []; public readonly insertLength: number; 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 4a5ee0723f..ba625b5d81 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,7 @@ import { PriorityQueue } from 'keyman/common/web-utils'; import { LexicalModelTypes } from '@keymanapp/common-types'; -import { CORRECTION_QUEUE_COMPARATOR, PathResult } from './correction-searchable.js'; +import { PREDICTION_QUEUE_COMPARATOR, PathResult } from './correction-searchable.js'; import { LegacyQuotientRoot } from './legacy-quotient-root.js'; import { generateSpaceSeed, InputSegment, SearchQuotientNode } from './search-quotient-node.js'; import { SearchQuotientSpur } from './search-quotient-spur.js'; @@ -20,7 +20,7 @@ import { TokenResultMapping } from './token-result-mapping.js'; // The set of search spaces corresponding to the same 'context' for search. // Whenever a wordbreak boundary is crossed, a new instance should be made. export class SearchQuotientCluster extends SearchQuotientNode { - private selectionQueue: PriorityQueue = new PriorityQueue(CORRECTION_QUEUE_COMPARATOR); + private selectionQueue: PriorityQueue = new PriorityQueue(PREDICTION_QUEUE_COMPARATOR); readonly spaceId: number; // We use an array and not a PriorityQueue b/c batch-heapifying at a single @@ -102,7 +102,7 @@ export class SearchQuotientCluster extends SearchQuotientNode { entries.forEach((path) => path.increaseMaxEditDistance()); // Since we just modified the stored instances, and the costs may have shifted, we need to re-heapify. - this.selectionQueue = new PriorityQueue(CORRECTION_QUEUE_COMPARATOR, entries.slice()); + this.selectionQueue = new PriorityQueue(PREDICTION_QUEUE_COMPARATOR, entries.slice()); } /** @@ -131,7 +131,7 @@ export class SearchQuotientCluster extends SearchQuotientNode { const bestPath = this.selectionQueue.dequeue(); const baseResult = bestPath.handleNextNode(); this.selectionQueue.enqueue(bestPath); - this.selectionQueue = new PriorityQueue(CORRECTION_QUEUE_COMPARATOR, this.selectionQueue.toArray()); + this.selectionQueue = new PriorityQueue(PREDICTION_QUEUE_COMPARATOR, this.selectionQueue.toArray()); let finalResult = baseResult; if(baseResult.type == 'complete') { 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 dbc0742287..78fa68eb6e 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 @@ -12,7 +12,7 @@ import { KMWString, PriorityQueue } from 'keyman/common/web-utils'; import { LexicalModelTypes } from '@keymanapp/common-types'; import { buildMergedTransform } from '@keymanapp/models-templates'; -import { CORRECTION_QUEUE_COMPARATOR, PathResult } from './correction-searchable.js'; +import { PREDICTION_QUEUE_COMPARATOR, PathResult } from './correction-searchable.js'; import { EDIT_DISTANCE_COST_SCALE, SearchNode } from './distance-modeler.js'; import { generateSpaceSeed, InputSegment, PathInputProperties, SearchQuotientNode } from './search-quotient-node.js'; import { generateSubsetId } from './tokenization-subsets.js'; @@ -34,7 +34,7 @@ export const MAX_EDIT_THRESHOLD_FACTOR = 2.5; // The set of search spaces corresponding to the same 'context' for search. // Whenever a wordbreak boundary is crossed, a new instance should be made. export abstract class SearchQuotientSpur extends SearchQuotientNode { - private selectionQueue: PriorityQueue = new PriorityQueue(CORRECTION_QUEUE_COMPARATOR); + private selectionQueue: PriorityQueue = new PriorityQueue(PREDICTION_QUEUE_COMPARATOR); /** * Holds all incoming Nodes generated from a parent `SearchSpace` that have not yet been @@ -152,7 +152,7 @@ export abstract class SearchQuotientSpur extends SearchQuotientNode { entries.forEach(function(edge) { edge.calculation = edge.calculation.increaseMaxDistance(); }); // Since we just modified the stored instances, and the costs may have shifted, we need to re-heapify. - this.selectionQueue = new PriorityQueue(CORRECTION_QUEUE_COMPARATOR, entries); + this.selectionQueue = new PriorityQueue(PREDICTION_QUEUE_COMPARATOR, entries); } /** @@ -419,7 +419,7 @@ export abstract class SearchQuotientSpur extends SearchQuotientNode { // Allows a little 'wiggle room' + 2 "hard" edits. // Can be important if needed characters don't actually exist on the keyboard // ... or even just not the then-current layer of the keyboard. - if(currentNode.currentCost > this.lowestPossibleSingleCost + MAX_EDIT_THRESHOLD_FACTOR * EDIT_DISTANCE_COST_SCALE) { + if(currentNode.correctionCost > this.lowestPossibleSingleCost + MAX_EDIT_THRESHOLD_FACTOR * EDIT_DISTANCE_COST_SCALE) { return unmatchedResult; } 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 37e9d1d00b..d37f02ce37 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 @@ -33,8 +33,8 @@ export function initTokenResultFilterer() { return false; } - if((priorReturnCosts.get(searchResult.matchString) ?? Number.MAX_VALUE) > searchResult.totalCost) { - priorReturnCosts.set(searchResult.matchString, searchResult.totalCost); + if((priorReturnCosts.get(searchResult.matchString) ?? Number.MAX_VALUE) > searchResult.correctionCost) { + priorReturnCosts.set(searchResult.matchString, searchResult.correctionCost); return true; } else { @@ -118,6 +118,14 @@ export class TokenResultMapping implements CorrectionResultMapping { * multiplied by the 'probability' induced by needed Damerau-Levenshtein edits * to the resulting output. */ + get correctionCost(): number { + return this.node.correctionCost; + } + + get currentCost(): number { + return this.node.currentCost; + } + get totalCost(): number { return this.node.currentCost; } 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 2ba308fdea..dd1146793c 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 @@ -532,7 +532,7 @@ export function buildAndMapPredictions( transition: ContextTransition, tokenization: ContextTokenization, // Originally, Readonly - but we only need these three components here. - match: Readonly<{matchString: string, totalCost: number, editCount: number}>, + match: Readonly<{matchString: string, correctionCost: number, editCount: number}>, costFactor: number ): CorrectionPredictionTuple[] { const model = transition.final.model; @@ -545,7 +545,7 @@ export function buildAndMapPredictions( // --- to move into predictFromCorrections --- let correction = match.matchString; - let rootCost = match.totalCost; + let rootCost = match.correctionCost; // Replace the existing context with the correction. const correctionTransform: Transform = { @@ -656,7 +656,7 @@ export async function correctAndEnumerate( // Only run the correction search when corrections are enabled. let rawPredictions: CorrectionPredictionTuple[] = []; - let bestCorrectionCost: number; + let bestTotalCost: number; const correctionPredictionMap: Record> = {}; for await(const match of getBestTokenMatches(searchModules, timer)) { // Corrections obtained: now to predict from them! @@ -700,8 +700,8 @@ export async function correctAndEnumerate( const predictions = buildAndMapPredictions(transition, tokenization, match, costFactor); // Only set 'best correction' cost when a correction ACTUALLY YIELDS predictions. - if(predictions.length > 0 && bestCorrectionCost === undefined) { - bestCorrectionCost = match.totalCost * costFactor; + if(predictions.length > 0 && bestTotalCost === undefined) { + bestTotalCost = match.totalCost * costFactor; } // If we're getting the same prediction again, it's lower-cost. Update! @@ -714,7 +714,7 @@ export async function correctAndEnumerate( rawPredictions = rawPredictions.concat(predictions); - if(shouldStopSearchingEarly(bestCorrectionCost, match.totalCost, rawPredictions)) { + if(shouldStopSearchingEarly(bestTotalCost, match.totalCost, rawPredictions)) { break; } } @@ -738,20 +738,15 @@ export function shouldStopSearchingEarly( return true; // If enough have been found, we're safe to terminate earlier. } else if(rawPredictions.length >= ModelCompositor.MAX_SUGGESTIONS) { - if(currentCorrectionCost >= bestCorrectionCost + CORRECTION_SEARCH_THRESHOLDS.REPLACEMENT_SEARCH_THRESHOLD) { - // Very useful for stopping 'sooner' when words reach a sufficient length. - return true; - } else { - // Sort the prediction list; we need them in descending probability order - // for the next check. - rawPredictions.sort((a, b) => b.totalProb - a.totalProb); + // Sort the prediction list; we need them in descending probability order + // for the next check. + rawPredictions.sort((a, b) => b.totalProb - a.totalProb); - // If the best result at the current state of the search fails to beat the worst - // pending suggestion from previous tiers, assume all further corrections will - // similarly fail to win; terminate the search-loop. - if(rawPredictions[ModelCompositor.MAX_SUGGESTIONS-1].totalProb > Math.exp(-currentCorrectionCost)) { - return true; - } + // If the best result at the current state of the search fails to beat the worst + // pending suggestion from previous tiers, assume all further corrections will + // similarly fail to win; terminate the search-loop. + if(rawPredictions[ModelCompositor.MAX_SUGGESTIONS-1].totalProb > Math.exp(-currentCorrectionCost)) { + return true; } } diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/distance-modeler.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/distance-modeler.tests.ts index dbd9f54a1d..97260c24ee 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/distance-modeler.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/distance-modeler.tests.ts @@ -13,7 +13,7 @@ import { PriorityQueue } from 'keyman/common/web-utils'; import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs'; import { LexicalModelTypes } from '@keymanapp/common-types'; -import { CORRECTION_QUEUE_COMPARATOR, models, SearchNode } from '@keymanapp/lm-worker/test-index'; +import { CORRECTION_QUEUE_COMPARATOR, models, PREDICTION_QUEUE_COMPARATOR, SearchNode } from '@keymanapp/lm-worker/test-index'; import SENTINEL_CODE_UNIT = models.SENTINEL_CODE_UNIT; import Distribution = LexicalModelTypes.Distribution; @@ -50,15 +50,6 @@ function edgeHasChars(edge: SearchNode, input: string, match: string) { return lastEntry(edge.calculation.matchSequence) == match; } -function findEdgesWithChars(edgeArray: SearchNode[], match: string) { - let results = edgeArray.filter(function(value) { - return lastEntry(value.calculation.matchSequence) == match; - }); - - assert.isAtLeast(results.length, 1); - return results; -} - function fetchCommonTENode() { const rootSeed = SEARCH_EDGE_SEED++; const rootNode = new SearchNode(testModel.traverseFromRoot(), rootSeed, toKey); @@ -130,7 +121,9 @@ describe('Correction Distance Modeler', () => { assert.equal(rootNode.editCount, 0); assert.equal(rootNode.inputSamplingCost, 0); - assert.equal(rootNode.currentCost, 0); + assert.equal(rootNode.correctionCost, 0); + assert.isAbove(rootNode.predictionCost, 0); + assert.isAbove(rootNode.currentCost, 0); assert.equal((rootNode.currentTraversal as TrieTraversal).prefix, ''); assert.isFalse(rootNode.hasPartialInput); @@ -151,7 +144,7 @@ describe('Correction Distance Modeler', () => { assert.equal(clonedNode.editCount, 0); assert.equal(clonedNode.inputSamplingCost, 0); - assert.equal(clonedNode.currentCost, 0); + assert.equal(clonedNode.correctionCost, 0); assert.equal((clonedNode.currentTraversal as TrieTraversal).prefix, ''); assert.isFalse(clonedNode.hasPartialInput); @@ -167,6 +160,9 @@ describe('Correction Distance Modeler', () => { // Verify aliasing for properties holding immutable objects assert.equal(clonedNode.calculation, originalNode.calculation); assert.equal(clonedNode.currentTraversal, originalNode.currentTraversal); + + // Verify local values are properly copied. + assert.equal(clonedNode.currentCost, originalNode.currentCost); }); it('properly deep-copies fully-processed nodes later in the search path', () => { @@ -222,12 +218,12 @@ describe('Correction Distance Modeler', () => { // ***** - function assertSourceNodeProps(node: SearchNode) { + function assertExpectedNodeProps(node: SearchNode) { assert.equal(node.resultKey, 'te'); assert.equal(node.editCount, 0); assert.equal(node.inputSamplingCost, -Math.log(firstLayerTransforms[0].p) - Math.log(secondLayerTransforms[0].p)); - assert.equal(node.currentCost, node.inputSamplingCost); + assert.equal(node.correctionCost, node.inputSamplingCost); assert.isFalse(node.hasPartialInput); assert.isFalse(node.isFullReplacement) @@ -238,12 +234,12 @@ describe('Correction Distance Modeler', () => { assert.equal(node.spaceId, secondSpaceId); } - assertSourceNodeProps(teNode); + assertExpectedNodeProps(teNode); const clonedNode = new SearchNode(teNode); // Root node properties; may as well re-assert 'em. - assertSourceNodeProps(clonedNode); + assertExpectedNodeProps(clonedNode); // Avoid aliasing for properties holding mutable objects assert.notEqual(clonedNode.priorInput, teNode.priorInput); @@ -308,12 +304,12 @@ describe('Correction Distance Modeler', () => { // ***** - function assertSourceNodeProps(node: SearchNode) { + function assertExpectedNodeProbs(node: SearchNode) { assert.equal(node.resultKey, 'te'); assert.equal(node.editCount, 0); assert.equal(node.inputSamplingCost, -Math.log(firstLayerTransforms[0].p) - Math.log(secondLayerTransforms[0].p)); - assert.equal(node.currentCost, node.inputSamplingCost); + assert.equal(node.correctionCost, node.inputSamplingCost); assert.isTrue(node.hasPartialInput); assert.isFalse(node.isFullReplacement) @@ -324,12 +320,12 @@ describe('Correction Distance Modeler', () => { assert.equal(node.spaceId, secondLayerId); } - assertSourceNodeProps(teNode); + assertExpectedNodeProbs(teNode); const clonedNode = new SearchNode(teNode); // Root node properties; may as well re-assert 'em. - assertSourceNodeProps(clonedNode); + assertExpectedNodeProbs(clonedNode); // Avoid aliasing for properties holding mutable objects assert.notEqual(clonedNode.priorInput, teNode.priorInput); @@ -577,7 +573,7 @@ describe('Correction Distance Modeler', () => { // Allow a little value wiggle due to double-precision limitations. assert.approximately(subsetNodes[i].inputSamplingCost, expectedCosts[i], 1e-8); // No actual edit-tracking is done yet, so these should also match. - assert.approximately(subsetNodes[i].currentCost, expectedCosts[i], 1e-8); + assert.approximately(subsetNodes[i].correctionCost, expectedCosts[i], 1e-8); } }); @@ -605,7 +601,7 @@ describe('Correction Distance Modeler', () => { assert.equal(lastEntry(ins1_dl0[1].calculation.matchSequence), 'h'); assert.equal(ins1_dl0[1].editCount, 0); assert.isBelow(ins1_dl0[0].inputSamplingCost, ins1_dl0[1].inputSamplingCost); - assert.isBelow(ins1_dl0[0].currentCost, ins1_dl0[1].currentCost); + assert.isBelow(ins1_dl0[0].correctionCost, ins1_dl0[1].correctionCost); // Correction of _other_ input characters to the 't' and the 'h' come // after ALL other corrections - these don't get both 't' and 'h' input @@ -616,13 +612,13 @@ describe('Correction Distance Modeler', () => { assert.equal(lastEntry(ins1_dl0[FIRST_CHAR_VARIANTS].calculation.matchSequence), 'h'); assert.equal(ins1_dl0[FIRST_CHAR_VARIANTS].editCount, 1); assert.isBelow(ins1_dl0[FIRST_CHAR_VARIANTS-1].inputSamplingCost, ins1_dl0[FIRST_CHAR_VARIANTS].inputSamplingCost); - assert.isBelow(ins1_dl0[FIRST_CHAR_VARIANTS-1].currentCost, ins1_dl0[FIRST_CHAR_VARIANTS].currentCost); + assert.isBelow(ins1_dl0[FIRST_CHAR_VARIANTS-1].correctionCost, ins1_dl0[FIRST_CHAR_VARIANTS].correctionCost); assert.equal(lastEntry(ins1_dl0[FIRST_CHAR_VARIANTS+1].calculation.inputSequence), SENTINEL_CODE_UNIT); assert.equal(lastEntry(ins1_dl0[FIRST_CHAR_VARIANTS+1].calculation.matchSequence), 't'); assert.equal(ins1_dl0[FIRST_CHAR_VARIANTS+1].editCount, 1); assert.isBelow(ins1_dl0[FIRST_CHAR_VARIANTS].inputSamplingCost, ins1_dl0[FIRST_CHAR_VARIANTS+1].inputSamplingCost); - assert.isBelow(ins1_dl0[FIRST_CHAR_VARIANTS].currentCost, ins1_dl0[FIRST_CHAR_VARIANTS+1].currentCost); + assert.isBelow(ins1_dl0[FIRST_CHAR_VARIANTS].correctionCost, ins1_dl0[FIRST_CHAR_VARIANTS+1].correctionCost); // For everything in between... well, the input-sampling weight is uniform, and // all require a full edit. @@ -649,7 +645,7 @@ describe('Correction Distance Modeler', () => { assert.equal(ins0_dl1[0].editCount, 0); assert.isUndefined(lastEntry(ins0_dl1[0].calculation.inputSequence)); assert.equal(ins0_dl1[0].inputSamplingCost, subsetNodes[3].inputSamplingCost); - assert.equal(ins0_dl1[0].currentCost, subsetNodes[3].currentCost); + assert.equal(ins0_dl1[0].correctionCost, subsetNodes[3].correctionCost); // ************ // Set 1: set for ins 2, dl 1 - 'tr' + 'th'. @@ -667,8 +663,8 @@ describe('Correction Distance Modeler', () => { assert.equal(lastEntry(ins2_dl1[0].calculation.matchSequence), 't'); assert.equal(ins2_dl1[0].editCount, 0); // The subset hasn't yet split! - assert.equal(ins2_dl1[0].currentCost, subsetNodes[1].currentCost); - assert.isBelow(ins2_dl1[0].currentCost, ins2_dl1[1].currentCost); + assert.equal(ins2_dl1[0].correctionCost, subsetNodes[1].correctionCost); + assert.isBelow(ins2_dl1[0].correctionCost, ins2_dl1[1].correctionCost); // All other (non-'t') entries get full subset probability with edit count 1; // they're all substitutions, as they fail to match against a non-'t' path. @@ -699,8 +695,8 @@ describe('Correction Distance Modeler', () => { assert.equal(lastEntry(ins2_dl0[0].calculation.matchSequence), 'c'); assert.equal(ins2_dl0[0].editCount, 0); // The subset won't split. - assert.equal(ins2_dl0[0].currentCost, subsetNodes[2].currentCost); - assert.isBelow(ins2_dl0[0].currentCost, ins2_dl0[1].currentCost); + assert.equal(ins2_dl0[0].correctionCost, subsetNodes[2].correctionCost); + assert.isBelow(ins2_dl0[0].correctionCost, ins2_dl0[1].correctionCost); // All other (non-'c') entries get full subset probability with edit count 1; // they're all substitutions, as they fail to match against a non-'t' path. @@ -816,7 +812,7 @@ describe('Correction Distance Modeler', () => { const subsetNodes = teNode.buildSubstitutionEdges(synthDistribution, SEARCH_EDGE_SEED++); assert.equal(subsetNodes.length, 4); subsetNodes.sort(CORRECTION_QUEUE_COMPARATOR); - const expectedCosts = [0.5, .25, 0.15, 0.1].map(x => -Math.log(x) + teNode.currentCost); + const expectedCosts = [0.5, .25, 0.15, 0.1].map(x => -Math.log(x) + teNode.correctionCost); // The known subs for the subsets defined above. for(let i=0; i < expectedCosts.length; i++) { assert.isTrue(subsetNodes[i].hasPartialInput); @@ -826,7 +822,7 @@ describe('Correction Distance Modeler', () => { // Allow a little value wiggle due to double-precision limitations. assert.approximately(subsetNodes[i].inputSamplingCost, expectedCosts[i], 1e-8); // No actual edit-tracking is done yet, so these should also match. - assert.approximately(subsetNodes[i].currentCost, expectedCosts[i], 1e-8); + assert.approximately(subsetNodes[i].correctionCost, expectedCosts[i], 1e-8); } }); @@ -862,13 +858,13 @@ describe('Correction Distance Modeler', () => { assert.equal(lastEntry(ins1_dl0[TE_CHILD_PATH_COUNT].calculation.matchSequence), 'l'); assert.equal(ins1_dl0[TE_CHILD_PATH_COUNT].editCount, 1); assert.isBelow(ins1_dl0[TE_CHILD_PATH_COUNT-1].inputSamplingCost, ins1_dl0[TE_CHILD_PATH_COUNT].inputSamplingCost); - assert.isBelow(ins1_dl0[TE_CHILD_PATH_COUNT-1].currentCost, ins1_dl0[TE_CHILD_PATH_COUNT].currentCost); + assert.isBelow(ins1_dl0[TE_CHILD_PATH_COUNT-1].correctionCost, ins1_dl0[TE_CHILD_PATH_COUNT].correctionCost); assert.equal(lastEntry(ins1_dl0[TE_CHILD_PATH_COUNT+1].calculation.inputSequence), SENTINEL_CODE_UNIT); assert.equal(lastEntry(ins1_dl0[TE_CHILD_PATH_COUNT+1].calculation.matchSequence), 'r'); assert.equal(ins1_dl0[TE_CHILD_PATH_COUNT+1].editCount, 1); assert.isBelow(ins1_dl0[TE_CHILD_PATH_COUNT].inputSamplingCost, ins1_dl0[TE_CHILD_PATH_COUNT+1].inputSamplingCost); - assert.isBelow(ins1_dl0[TE_CHILD_PATH_COUNT].currentCost, ins1_dl0[TE_CHILD_PATH_COUNT+1].currentCost); + assert.isBelow(ins1_dl0[TE_CHILD_PATH_COUNT].correctionCost, ins1_dl0[TE_CHILD_PATH_COUNT+1].correctionCost); // For everything in between... well, the input-sampling weight is uniform, and // all require a full edit. @@ -912,8 +908,8 @@ describe('Correction Distance Modeler', () => { assert.equal(lastEntry(ins2_dl1[0].calculation.matchSequence), 'a'); assert.equal(ins2_dl1[0].editCount, 0); // The subset hasn't yet split! - assert.equal(ins2_dl1[0].currentCost, subsetNodes[1].currentCost); - assert.isBelow(ins2_dl1[0].currentCost, ins2_dl1[1].currentCost); + assert.equal(ins2_dl1[0].correctionCost, subsetNodes[1].correctionCost); + assert.isBelow(ins2_dl1[0].correctionCost, ins2_dl1[1].correctionCost); // All other (non-'t') entries get full subset probability with edit count 1; // they're all substitutions, as they fail to match against a non-'t' path. @@ -943,8 +939,8 @@ describe('Correction Distance Modeler', () => { assert.equal(lastEntry(ins2_dl0[0].calculation.matchSequence), 'c'); assert.equal(ins2_dl0[0].editCount, 0); // The subset won't split. - assert.equal(ins2_dl0[0].currentCost, subsetNodes[2].currentCost); - assert.isBelow(ins2_dl0[0].currentCost, ins2_dl0[1].currentCost); + assert.equal(ins2_dl0[0].correctionCost, subsetNodes[2].correctionCost); + assert.isBelow(ins2_dl0[0].correctionCost, ins2_dl0[1].correctionCost); // All other (non-'c') entries get full subset probability with edit count 1; // they're all substitutions, as they fail to match against a non-'t' path. @@ -1058,7 +1054,7 @@ describe('Correction Distance Modeler', () => { const layer1Edges = rootNode.buildSubstitutionEdges(synthDistribution1, layer1Id) // No 2+ inserts here; we're fine with just one call. .flatMap(e => e.processSubsetEdge()); - const layer1Queue = new PriorityQueue(CORRECTION_QUEUE_COMPARATOR, layer1Edges); + const layer1Queue = new PriorityQueue(PREDICTION_QUEUE_COMPARATOR, layer1Edges); const tEdge = layer1Queue.dequeue(); assertEdgeChars(tEdge, 't', 't'); @@ -1068,7 +1064,7 @@ describe('Correction Distance Modeler', () => { const layer2Edges = tEdge.buildSubstitutionEdges(synthDistribution2, layer2Id) // No 2+ inserts here; we're fine with just one call. .flatMap(e => e.processSubsetEdge()); - const layer2Queue = new PriorityQueue(CORRECTION_QUEUE_COMPARATOR, layer2Edges); + const layer2Queue = new PriorityQueue(PREDICTION_QUEUE_COMPARATOR, layer2Edges); const eEdge = layer2Queue.dequeue(); assertEdgeChars(eEdge, 'e', 'e'); @@ -1078,21 +1074,16 @@ describe('Correction Distance Modeler', () => { assertEdgeChars(hEdge, 'h', 'h'); assert.equal(hEdge.spaceId, layer2Id); - // Needed for a proper e <-> h transposition. - const ehEdge = findEdgesWithChars(layer2Edges, 'h')[0]; - - assert.isOk(ehEdge); // Final round: we'll use three nodes and throw all of their results into the same priority queue. + // Note: as we're constructing these directly, we're not modeling transpositions. const layer3Id = SEARCH_EDGE_SEED++; const layer3eEdges = eEdge.buildSubstitutionEdges(synthDistribution3, layer3Id) // No 2+ inserts here; we're fine with just one call. .flatMap(e => e.processSubsetEdge()); const layer3hEdges = hEdge.buildSubstitutionEdges(synthDistribution3, layer3Id) .flatMap(e => e.processSubsetEdge()); - const layer3ehEdges = ehEdge.buildSubstitutionEdges(synthDistribution3, layer3Id) - .flatMap(e => e.processSubsetEdge()); - const layer3Queue = new PriorityQueue(CORRECTION_QUEUE_COMPARATOR, layer3eEdges.concat(layer3hEdges).concat(layer3ehEdges)); + const layer3Queue = new PriorityQueue(PREDICTION_QUEUE_COMPARATOR, layer3eEdges.concat(layer3hEdges)); // Find the first result with an actual word directly represented. let bestEdge; 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 4bccef2ce9..dcc3cf3f0c 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 @@ -18,7 +18,8 @@ import { models, LegacyQuotientRoot, SearchQuotientCluster, - TokenResultMapping + TokenResultMapping, + CORRECTION_QUEUE_COMPARATOR } from '@keymanapp/lm-worker/test-index'; import TrieModel = models.TrieModel; @@ -32,74 +33,31 @@ function buildTestTimer() { describe('Correction Searching', () => { describe('without multi-tokenization; using a single SearchPath sequence', () => { const checkRepeatableResults_teh = async (iter: AsyncGenerator, any, any>) => { - const firstIterResult = await iter.next(); // {value: , done: } - assert.isFalse(firstIterResult.done); - - const firstResult: TokenResultMapping = firstIterResult.value; // Retrieves - // No checks on the first set's cost. - assert.equal(firstResult.matchString, "ten"); - - // All start with 'te' but one, and invoke one edit of the same cost. - // 'th' has an 'h' at the same cost (input 3) of the 'e' (input 2). - const secondBatch = [ - 'tec', 'tel', 'tem', - 'ter', 'tes', 'th', - 'te' + const expectedFirstTwenty = [ + 'ten', // no edits required whatsoever + 'th', // one edit (deletion), but gets to ignore the cost of a keystroke and still prefixes 'the' + 'the', // one edit (transposition), incurs the cost of all three keystrokes + 'te', // one edit (deletion), but gets to ignore the cost of a keystroke + 'tel', 'beh', // both cost one edit (hard character replacement: n/h -> l vs t -> b) + // Other edits, generally of one edit cost, predicting words of varying frequency. + 'ter', 'tha', 'thi', 'thr', 'tho', 'tem', 'thu', 'then', 'men', 'wen', 'gen', 'en', 'sen', 'tec' ]; - async function checkBatch(batch: string[], prevCost: number) { - let cost; - while(batch.length > 0) { - const iter_result = await iter.next(); - assert.isFalse(iter_result.done); - - const result = iter_result.value; - assert.isAbove(result.totalCost, prevCost); - if(cost !== undefined) { - assert.equal(result.totalCost, cost); - } else { - cost = result.totalCost; - } - - const matchIndex = batch.findIndex((entry) => entry == result.matchString); - assert.notEqual(matchIndex, -1, `'${result.matchString}' received as prediction too early`); - batch.splice(matchIndex, 1); - } - - return cost; + let results: TokenResultMapping[] = []; + for(let i = 0; i < expectedFirstTwenty.length; i++) { + const iterResult = await iter.next(); + results.push(iterResult.value as TokenResultMapping); } - const secondCost = await checkBatch(secondBatch, firstResult.totalCost); + assert.sameOrderedMembers(results.map((r) => r.matchString), expectedFirstTwenty); + for(let i=0; i < expectedFirstTwenty.length - 1; i++) { + assert.isAtLeast(results[i+1].totalCost, results[i].totalCost); + } - // Single hard edit, all other input probability aspects are equal - const thirdBatch = [ - // 't' -> 'b' (sub) - 'beh', - // '' -> 'c' (insertion) - 'tech', - // 'eh' -> 'he' (transposition) - 'the' - ]; - - await checkBatch(thirdBatch, secondCost); - - // All replace the low-likelihood case for the third input. - const fourthBatch = [ - 'thi', 'tho', 'thr', - 'thu', 'tha' - ]; - - await checkBatch(fourthBatch, secondCost); - - // Replace the _first_ input's char OR insert an extra char, - // also matching the low-likelihood third-char option. - const fifthBatch = [ - 'cen', 'en', 'gen', - 'ken', 'len', 'men', - 'sen', 'then', 'wen' - ]; - - await checkBatch(fifthBatch, secondCost); + // The results will not be in the order as raw correction likelihood because some words + // are more frequent than others. + results.sort(CORRECTION_QUEUE_COMPARATOR); + assert.notSameOrderedMembers(results.map((r) => r.matchString), expectedFirstTwenty); } it('Empty search root, loaded model', async () => { @@ -113,12 +71,13 @@ describe('Correction Searching', () => { // While there's no input, insertion operations can produce suggestions. const resultState = await iter.next(); - const result = resultState.value; + const result = resultState.value as TokenResultMapping; // Just one suggestion root should be returned as the first result. - assert.equal(result.totalCost, 0); // Gives a perfect match + assert.equal(result.correctionCost, 0); // Gives a perfect match assert.equal(result.matchString, ''); // an empty match string. assert.isFalse(resultState.done); + assert.isAbove(result.totalCost, 0); }); // Hmm... how best to update this... @@ -423,9 +382,9 @@ describe('Correction Searching', () => { // paths of lower total cost. pathsResults.push(nextFromPaths); - assert.isAtLeast(nextFromCluster.totalCost, baseCost); - assert.isAtLeast(nextFromPaths.totalCost, baseCost); - baseCost = Math.max(baseCost, nextFromCluster.totalCost, nextFromPaths.totalCost); + assert.isAtLeast(nextFromCluster.correctionCost, baseCost); + assert.isAtLeast(nextFromPaths.correctionCost, baseCost); + baseCost = Math.max(baseCost, nextFromCluster.correctionCost, nextFromPaths.correctionCost); } assert.deepEqual(genResults.map(r => r.matchString), pathsResults.map(r => r.matchString)); @@ -434,22 +393,13 @@ describe('Correction Searching', () => { assert.sameDeepMembers(pathsResults.slice(0, 3).map(r => r.matchString), ['th', 'to', 'tr']); // These involve likely-enough corrections that should show, given the model fixture. assert.includeDeepMembers(pathsResults.map(r => r.matchString), [ - 'ty', // 'type' is quite frequent according to the text fixture. 't', // Deleting the second keystroke outright lands here. 'oth', // What if we insert an 'o' early on? 'other' is a very common English word - 'ti' // 'time' is pretty common too. + 'ti', // 'time' is pretty common too. + 'thi', // 'this' is common enough to show up early despite inserting the 'i'. + 'wh', // "which" is a super-frequent English word, worthy of being a forced correction + 'sh' // "she" is also strong enough to force an early appearance. ]); - - // NOTE: this level of corrections does not yet consider the word likelihood - only - // the raw correction cost. No ordering of "likely word" to "unlikely word" should - // occur yet. - - // 'time': weight 934 - // 'type': weight 540 - const timeResult = pathsResults.find(r => r.matchString == 'ti'); - const typeResult = pathsResults.find(r => r.matchString == 'ty'); - // Correction to either should be equally likely. - assert.equal(timeResult.totalCost, typeResult.totalCost); }); }); }); 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 ccc38467a5..74b2af3e43 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 @@ -427,7 +427,7 @@ describe('LegacyQuotientSpur', () => { const entry_the = thirdResults.find((entry) => entry.matchString == 'the' && entry.editCount == 1); assert.isOk(entry_the); - thirdResults.sort((a, b) => a.totalCost - b.totalCost); + thirdResults.sort((a, b) => a.correctionCost - b.correctionCost); const the_index = thirdResults.findIndex((entry) => entry.matchString == 'the' && entry.editCount == 1); // `teh` should appear fairly early as a viable correction. assert.isBelow(the_index, 10); @@ -438,7 +438,7 @@ describe('LegacyQuotientSpur', () => { // We want to make sure we don't auto-ignore transposition cases by // accident by failing that conditional. const the_entry = thirdResults[the_index]; - assert.isBelow(the_entry.totalCost - thirdResults[0].totalCost, CORRECTION_SEARCH_THRESHOLDS.REPLACEMENT_SEARCH_THRESHOLD); + assert.isBelow(the_entry.correctionCost - thirdResults[0].correctionCost, CORRECTION_SEARCH_THRESHOLDS.REPLACEMENT_SEARCH_THRESHOLD); }); }); }); \ No newline at end of file diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/quotient-node-finalizer.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/quotient-node-finalizer.tests.ts index ee6ee088e6..1a89a68321 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/quotient-node-finalizer.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/quotient-node-finalizer.tests.ts @@ -99,9 +99,9 @@ describe('QuotientNodeFinalizer', () => { assert.equal(searchResult.type, 'complete'); if(searchResult.type == 'complete') { - assert.equal(searchResult.mapping.totalCost, -Math.log(therefo.bestExample.p)); + assert.equal(searchResult.mapping.correctionCost, -Math.log(therefo.bestExample.p)); assert.isNotNaN(searchResult.cost); - assert.equal(searchResult.cost, searchResult.mapping.totalCost); + assert.isAtLeast(searchResult.cost, searchResult.mapping.totalCost); } else { return; } @@ -129,9 +129,9 @@ describe('QuotientNodeFinalizer', () => { assert.equal(searchResult.type, 'complete'); if(searchResult.type == 'complete') { - assert.isAbove(searchResult.mapping.totalCost, -Math.log(therefo.bestExample.p)); + assert.isAbove(searchResult.mapping.correctionCost, -Math.log(therefo.bestExample.p)); assert.isNotNaN(searchResult.cost); - assert.equal(searchResult.cost, searchResult.mapping.totalCost); + assert.isAtLeast(searchResult.cost, searchResult.mapping.totalCost); } else { return; } diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/build-and-map-predictions.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/build-and-map-predictions.tests.ts index 5051c2acbf..72876e4ca1 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/build-and-map-predictions.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/prediction-helpers/build-and-map-predictions.tests.ts @@ -58,7 +58,7 @@ describe('buildAndMapPredictions', () => { const mappedPredictions = buildAndMapPredictions( transition, transition.base.displayTokenization, - {matchString: 'the', totalCost: 0, editCount: 0}, + {matchString: 'the', correctionCost: 0, editCount: 0}, 1 ); @@ -104,7 +104,7 @@ describe('buildAndMapPredictions', () => { const mappedPredictions = buildAndMapPredictions( transition, transition.base.displayTokenization, - {matchString: '', totalCost: 0, editCount: 0}, + {matchString: '', correctionCost: 0, editCount: 0}, 1 ); @@ -148,7 +148,7 @@ describe('buildAndMapPredictions', () => { const mappedPredictions = buildAndMapPredictions( transition, transition.base.displayTokenization, - {matchString: '', totalCost: 0, editCount: 0}, + {matchString: '', correctionCost: 0, editCount: 0}, 1 ); @@ -210,7 +210,7 @@ describe('buildAndMapPredictions', () => { const mappedPredictions = buildAndMapPredictions( transition, transition.final.displayTokenization, - {matchString: '', totalCost: 0, editCount: 0}, + {matchString: '', correctionCost: 0, editCount: 0}, 1 );