Merge pull request #16600 from keymanapp/change/web/lexically-weight-prediction-search

change(web): add lexical weighting to prediction search
This commit is contained in:
Joshua Horton 2026-09-21 21:50:24 +07:00 committed by GitHub
commit bf98a2f03b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 142 additions and 180 deletions

View file

@ -27,10 +27,16 @@ export interface CorrectionResultMapping<ResultType> {
readonly matchedResult: Readonly<ResultType>;
/**
* 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;
}

View file

@ -29,10 +29,14 @@ type CompleteSearchPath<MappingType> = {
export type PathResult<MappingType> = NullPath | IntermediateSearchPath | CompleteSearchPath<MappingType>;
export function CORRECTION_QUEUE_COMPARATOR<T extends {currentCost: number}>(a: T, b: T) {
export function PREDICTION_QUEUE_COMPARATOR<T extends {currentCost: number}>(a: T, b: T) {
return a.currentCost - b.currentCost;
}
export function CORRECTION_QUEUE_COMPARATOR<T extends {correctionCost: number}>(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

View file

@ -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<Correctable>(CORRECTION_QUEUE_COMPARATOR);
let spaceQueue = new PriorityQueue<Correctable>(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<ResultMapping>((a, b) => a.totalCost - b.totalCost);
const priorResultsQueue = new PriorityQueue<ResultMapping>(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;

View file

@ -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<SearchNode> = new PriorityQueue(CORRECTION_QUEUE_COMPARATOR);
private selectionQueue: PriorityQueue<SearchNode> = new PriorityQueue(PREDICTION_QUEUE_COMPARATOR);
private processed: SearchNode[] = [];
constructor(model: LexicalModel) {

View file

@ -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<SearchNode> = new PriorityQueue(CORRECTION_QUEUE_COMPARATOR);
private transposeQueue: PriorityQueue<SearchNode> = new PriorityQueue(PREDICTION_QUEUE_COMPARATOR);
private incomingTransposeRootNodes: TokenResultMapping[] = [];
public readonly insertLength: number;

View file

@ -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<SearchQuotientNode> = new PriorityQueue(CORRECTION_QUEUE_COMPARATOR);
private selectionQueue: PriorityQueue<SearchQuotientNode> = 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<SearchQuotientNode>(CORRECTION_QUEUE_COMPARATOR, entries.slice());
this.selectionQueue = new PriorityQueue<SearchQuotientNode>(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') {

View file

@ -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<SearchNode> = new PriorityQueue(CORRECTION_QUEUE_COMPARATOR);
private selectionQueue: PriorityQueue<SearchNode> = 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<SearchNode>(CORRECTION_QUEUE_COMPARATOR, entries);
this.selectionQueue = new PriorityQueue<SearchNode>(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;
}

View file

@ -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<SearchNode> {
* 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;
}

View file

@ -532,7 +532,7 @@ export function buildAndMapPredictions(
transition: ContextTransition,
tokenization: ContextTokenization,
// Originally, Readonly<TokenResultMapping> - 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<string, Distribution<Suggestion>> = {};
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;
}
}

View file

@ -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;

View file

@ -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<Readonly<TokenResultMapping>, any, any>) => {
const firstIterResult = await iter.next(); // {value: <actual value>, done: <iteration complete?>}
assert.isFalse(firstIterResult.done);
const firstResult: TokenResultMapping = firstIterResult.value; // Retrieves <actual value>
// 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);
});
});
});

View file

@ -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);
});
});
});

View file

@ -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;
}

View file

@ -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
);