Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ import { applyTransform, buildMergedTransform } from "@keymanapp/models-template
import { LexicalModelTypes } from '@keymanapp/common-types';
import { deepCopy, KMWString } from "@keymanapp/web-utils";

import { SearchQuotientSpur } from "./search-quotient-spur.js";
import { SearchQuotientNode } from "./search-quotient-node.js";
import { TokenSplitMap } from "./context-tokenization.js";
import { LegacyQuotientSpur } from "./legacy-quotient-spur.js";
import { LegacyQuotientRoot } from "./legacy-quotient-root.js";

import Distribution = LexicalModelTypes.Distribution;
import LexicalModel = LexicalModelTypes.LexicalModel;
Expand Down Expand Up @@ -121,18 +122,18 @@ export class ContextToken {

// Supports the old pathway for: updateWithBackspace(tokenText: string, transformId: number)
// Build a token that represents the current text with no ambiguity - probability at max (1.0)
let searchSpace = new SearchQuotientSpur(model);
let searchModule: SearchQuotientNode = new LegacyQuotientRoot(model);
const BASE_PROBABILITY = 1;
textToCharTransforms(rawText).forEach((transform) => {
this._inputRange.push({
trueTransform: transform,
inputStartIndex: 0,
bestProbFromSet: BASE_PROBABILITY
});
searchSpace = new SearchQuotientSpur(searchSpace, [{sample: transform, p: BASE_PROBABILITY}], 1);
searchModule = new LegacyQuotientSpur(searchModule, [{sample: transform, p: BASE_PROBABILITY}], 1);
});

this._searchModule = searchSpace;
this._searchModule = searchModule;
}
}

Expand All @@ -142,7 +143,7 @@ export class ContextToken {
*/
addInput(inputSource: TokenInputSource, distribution: Distribution<Transform>) {
this._inputRange.push(inputSource);
this._searchModule = new SearchQuotientSpur(this._searchModule, distribution, inputSource.bestProbFromSet);
this._searchModule = new LegacyQuotientSpur(this._searchModule, distribution, inputSource.bestProbFromSet);
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { PriorityQueue } from '@keymanapp/web-utils';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing prolog comment

import { LexicalModelTypes } from '@keymanapp/common-types';

import { SearchQuotientRoot } from './search-quotient-root.js';
import { QUEUE_NODE_COMPARATOR } from './search-quotient-spur.js';
import { SearchNode, SearchResult } from './distance-modeler.js';

import LexicalModel = LexicalModelTypes.LexicalModel;
import { PathResult } from './search-quotient-node.js';

export class LegacyQuotientRoot extends SearchQuotientRoot {
private selectionQueue: PriorityQueue<SearchNode> = new PriorityQueue(QUEUE_NODE_COMPARATOR);
private processed: SearchResult[] = [];

constructor(model: LexicalModel) {
super(model);

this.selectionQueue.enqueue(this.rootNode);
}

// TODO: Remove when removing LegacyQuotientSpur!
// At that time, inserts should have their own devoted 'Spur' type and not be managed
// within the same pre-existing instance.
/**
* Retrieves the lowest-cost / lowest-distance edge from the selection queue,
* checks its validity as a correction to the input text, and reports on what
* sort of result the edge's destination node represents.
* @returns
*/
public handleNextNode(): PathResult {
const node = this.selectionQueue.dequeue();

if(!node) {
return {
type: 'none'
};
}

// The legacy variant includes 'insert' operations!
if(node.editCount < 2) {
let insertionEdges = node.buildInsertionEdges();
this.selectionQueue.enqueueAll(insertionEdges);
}

this.processed.push(new SearchResult(node));
return {
type: 'complete',
cost: node.currentCost,
finalNode: node,
spaceId: this.spaceId
};
}

public get currentCost(): number {
return this.selectionQueue.peek()?.currentCost ?? Number.POSITIVE_INFINITY;
}

get previousResults(): SearchResult[] {
return this.processed.slice();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/*
* Keyman is copyright (C) SIL Global. MIT License.
*
* Created by jahorton on 2025-10-09
*
* This file defines tests for the predictive-text engine's SearchPath class,
* which is used to manage the search-space(s) for text corrections within the
* engine.
*/

import { LexicalModelTypes } from '@keymanapp/common-types';

import { SearchNode } from './distance-modeler.js';
import { PathResult, SearchQuotientNode } from './search-quotient-node.js';
import { SearchQuotientSpur } from './search-quotient-spur.js';

import Distribution = LexicalModelTypes.Distribution;
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 {
/**
* Constructs a fresh SearchSpace instance for used in predictive-text correction
* and suggestion searches.
* @param baseSpaceId
* @param model
*/
constructor(space: SearchQuotientNode, inputs: Distribution<Transform>, bestProbFromSet: number) {
super(space, inputs, space.lowestPossibleSingleCost - Math.log(bestProbFromSet));
this.queueNodes(this.buildEdgesForNodes(space.previousResults.map(r => r.node)));
return;
}

protected buildEdgesForNodes(baseNodes: ReadonlyArray<SearchNode>) {
// With a newly-available input, we can extend new input-dependent paths from
// our previously-reached 'extractedResults' nodes.
let outboundNodes = baseNodes.map((node) => {
// Hard restriction: no further edits will be supported. This helps keep the search
// more narrowly focused.
const substitutionsOnly = node.editCount == 2;

let deletionEdges: SearchNode[] = [];
if(!substitutionsOnly) {
deletionEdges = node.buildDeletionEdges(this.inputs, this.spaceId);
}
const substitutionEdges = node.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.
return substitutionEdges.flatMap(e => e.processSubsetEdge()).concat(deletionEdges);
}).flat();

return outboundNodes;
}

/**
* Retrieves the lowest-cost / lowest-distance edge from the selection queue,
* checks its validity as a correction to the input text, and reports on what
* sort of result the edge's destination node represents.
* @returns
*/
public handleNextNode(): PathResult {
const result = super.handleNextNode();

if(result.type == 'complete') {
const currentNode = result.finalNode;

// Forbid a raw edit-distance of greater than 2.
// Note: .knownCost is not scaled, while its contribution to .currentCost _is_ scaled. const currentNode = result.finalNode;
if(currentNode.editCount < 2) {
let insertionEdges = currentNode.buildInsertionEdges();
this.queueNodes(insertionEdges);
}
}

return result;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

missing header comment

import { LexicalModelTypes } from '@keymanapp/common-types';

import { SearchNode, SearchResult } from './distance-modeler.js';
import { generateSpaceSeed, PathResult, SearchQuotientNode } from './search-quotient-node.js';

import LexicalModel = LexicalModelTypes.LexicalModel;

// 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 SearchQuotientRoot implements SearchQuotientNode {
readonly rootNode: SearchNode;
private readonly rootResult: SearchResult;

readonly lowestPossibleSingleCost: number = 0;

readonly inputCount: number = 0;
readonly correctionsEnabled: boolean = false;

private hasBeenProcessed: boolean = false;

/**
* Constructs a fresh SearchSpace instance for used in predictive-text correction
* and suggestion searches.
* @param baseSpaceId
* @param model
*/
constructor(model: LexicalModel) {
this.rootNode = new SearchNode(model.traverseFromRoot(), generateSpaceSeed(), t => model.toKey(t));
this.rootResult = new SearchResult(this.rootNode);
}

get spaceId(): number {
return this.rootNode.spaceId;
}

hasInputs(keystrokeDistributions: LexicalModelTypes.Distribution<LexicalModelTypes.Transform>[]): boolean {
return keystrokeDistributions.length == 0;
}

// Return a new array each time; avoid aliasing potential!
get parents(): SearchQuotientNode[] {
return [];
}

// Return a new array each time; avoid aliasing potential!
get inputSequence(): LexicalModelTypes.Distribution<LexicalModelTypes.Transform>[] {
return [];
}

// Return a new instance each time; avoid aliasing potential!
get bestExample(): { text: string; p: number; } {
return { text: '', p: 1 };
}

increaseMaxEditDistance(): void {
this.rootNode.calculation = this.rootNode.calculation.increaseMaxDistance();
}

/**
* Retrieves the lowest-cost / lowest-distance edge from the selection queue,
* checks its validity as a correction to the input text, and reports on what
* sort of result the edge's destination node represents.
* @returns
*/
public handleNextNode(): PathResult {
if(this.hasBeenProcessed) {
return null;
}

this.hasBeenProcessed = true;

return {
type: 'complete',
cost: 0,
finalNode: this.rootNode,
spaceId: this.spaceId
};
}

public get currentCost(): number {
return this.hasBeenProcessed ? Number.POSITIVE_INFINITY : 0;
}

get previousResults(): SearchResult[] {
if(!this.hasBeenProcessed) {
return [];
} else {
return [this.rootResult];
}
}
}
Loading