Merge pull request #14949 from keymanapp/refactor/web/complex-search-space-reuse

refactor(web): begin implementation of quotient-path convergence via SearchQuotientCluster 🚂
This commit is contained in:
Joshua Horton 2026-02-16 10:24:21 +07:00 committed by GitHub
commit eaaee70289
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 704 additions and 59 deletions

View file

@ -5,7 +5,7 @@ import { LexicalModelTypes } from '@keymanapp/common-types';
import { ClassicalDistanceCalculation } from './classical-calculation.js';
import { ExecutionTimer, STANDARD_TIME_BETWEEN_DEFERS } from './execution-timer.js';
import { PathResult, SearchQuotientNode } from './search-quotient-node.js';
import { SearchQuotientNode } from './search-quotient-node.js';
import { subsetByChar, subsetByInterval, mergeSubset, TransformSubset } from '../transform-subsets.js';
import TransformUtils from '../transformUtils.js';
@ -573,9 +573,12 @@ export class SearchNode {
export class SearchResult {
readonly node: SearchNode;
// Supports SearchPath -> SearchSpace remapping.
readonly spaceId: number;
constructor(node: SearchNode) {
constructor(node: SearchNode, spaceId?: number) {
this.node = node;
this.spaceId = spaceId ?? node.spaceId;
}
get inputSequence(): ProbabilityMass<Transform>[] {
@ -622,10 +625,6 @@ export class SearchResult {
get finalTraversal(): LexiconTraversal {
return this.node.currentTraversal;
}
get spaceId(): number {
return this.node.spaceId;
}
}
/**
@ -655,15 +654,15 @@ export async function *getBestMatches(searchModules: SearchQuotientNode[], timer
// Stage 2: the fun part; actually searching!
do {
const entry: SearchResult = timer.time(() => {
if((priorResultsQueue.peek()?.totalCost ?? Number.POSITIVE_INFINITY) < spaceQueue.peek().currentCost) {
const entry = timer.time(() => {
if((priorResultsQueue.peek()?.totalCost ?? Number.POSITIVE_INFINITY) <= spaceQueue.peek().currentCost) {
const result = priorResultsQueue.dequeue();
currentReturns[result.node.resultKey] = result.node;
return result;
}
let lowestCostSource = spaceQueue.dequeue();
let newResult: PathResult = lowestCostSource.handleNextNode();
const newResult = lowestCostSource.handleNextNode();
spaceQueue.enqueue(lowestCostSource);
if(newResult.type == 'none') {
@ -688,7 +687,7 @@ export async function *getBestMatches(searchModules: SearchQuotientNode[], timer
if((currentReturns[node.resultKey]?.currentCost ?? Number.MAX_VALUE) > node.currentCost) {
currentReturns[node.resultKey] = node;
// Do not track yielded time.
return new SearchResult(node);
return new SearchResult(node, newResult.spaceId);
}
}

View file

@ -0,0 +1,182 @@
/*
* Keyman is copyright (C) SIL Global. MIT License.
*
* Created by jahorton on 2025-10-20
*
* This file defines the predictive-text engine's SearchQuotientCluster class,
* which is used to manage the search-space(s) for text corrections within the
* engine.
*/
import { QueueComparator, PriorityQueue } from '@keymanapp/web-utils';
import { LexicalModelTypes } from '@keymanapp/common-types';
import { SearchNode, SearchResult } from './distance-modeler.js';
import { generateSpaceSeed, InputSegment, PathResult, SearchQuotientNode } from './search-quotient-node.js';
const PATH_QUEUE_COMPARATOR: QueueComparator<SearchQuotientNode> = (a, b) => {
return a.currentCost - b.currentCost;
}
// 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 implements SearchQuotientNode {
private selectionQueue: PriorityQueue<SearchQuotientNode> = new PriorityQueue(PATH_QUEUE_COMPARATOR);
readonly spaceId: number;
// We use an array and not a PriorityQueue b/c batch-heapifying at a single point in time
// is cheaper than iteratively building a priority queue.
/**
* This tracks all paths that have reached the end of a viable input-matching path - even
* those of lower cost that produce the same correction as other paths.
*
* When new input is received, its entries are then used to append edges to the path in order
* to find potential paths to reach a new viable end.
*/
private completedPaths?: SearchNode[] = [];
/**
* Acts as a Map that prevents duplicating a correction-search path if reached
* more than once.
*/
protected get processedEdgeSet(): {[pathKey: string]: boolean} {
return this._processedEdgeSet;
}
private _processedEdgeSet?: {[pathKey: string]: boolean} = {};
/**
* Provides a heuristic for the base cost at each depth if the best individual
* input were taken at that level.
*/
readonly lowestPossibleSingleCost: number;
/**
* Constructs a fresh SearchQuotientCluster instance for use in
* predictive-text correction and suggestion searches.
* @param inboundPaths
*/
constructor(inboundPaths: SearchQuotientNode[]) {
if(inboundPaths.length == 0) {
throw new Error("SearchQuotientCluster requires an array with at least one SearchQuotientNode");
}
let lowestPossibleSingleCost = Number.POSITIVE_INFINITY;
const firstPath = inboundPaths[0];
const inputCount = firstPath.inputCount;
const codepointLength = firstPath.codepointLength;
const sourceRangeKey = firstPath.sourceRangeKey;
for(let path of inboundPaths) {
if(path.inputCount != inputCount || path.codepointLength != codepointLength) {
throw new Error(`SearchQuotientNode does not share same properties as others in the cluster: inputCount ${path.inputCount} vs ${inputCount}, codepointLength ${path.codepointLength} vs ${codepointLength}`);
}
// If there's a source-range key mismatch - via mismatch in count or in actual ID, we have an error.
if(path.sourceRangeKey != sourceRangeKey) {
throw new Error(`SearchQuotientNode does not share the same source identifiers as others in the cluster`);
}
lowestPossibleSingleCost = Math.min(lowestPossibleSingleCost, path.lowestPossibleSingleCost);
}
this.spaceId = generateSpaceSeed();
this.lowestPossibleSingleCost = lowestPossibleSingleCost;
this.completedPaths = inboundPaths.flatMap(p => p.previousResults).map(r => r.node);
this.selectionQueue.enqueueAll(inboundPaths);
return;
}
public get inputCount(): number {
return this.selectionQueue.peek()?.inputCount ?? 0;
}
public get bestExample(): {text: string, p: number} {
const bestPrefixes = this.selectionQueue.toArray().map(p => p.bestExample);
return bestPrefixes.reduce((max, curr) => max.p < curr.p ? curr : max);
}
public get parents(): SearchQuotientNode[] {
return this.selectionQueue.toArray().slice();
}
increaseMaxEditDistance() {
// By extracting the entries from the priority queue and increasing distance outside of it as a batch job,
// we get an O(N) implementation, rather than the O(N log N) that would result from maintaining the original queue.
const entries = this.selectionQueue.toArray();
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>(PATH_QUEUE_COMPARATOR, entries.slice());
}
/**
* When true, this indicates that the currently-represented portion of context
* has fat-finger data available, which itself indicates that the user has
* corrections enabled.
*/
get correctionsEnabled(): boolean {
const paths = this.selectionQueue.toArray();
// When corrections are disabled, the Web engine will only provide individual Transforms
// for an input, not a distribution. No distributions means we shouldn't do corrections.
return !!paths.find(p => p.correctionsEnabled);
}
public get currentCost(): number {
return this.selectionQueue.peek()?.currentCost ?? Number.POSITIVE_INFINITY;
}
/**
* 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 bestPath = this.selectionQueue.dequeue();
const currentResult = bestPath.handleNextNode();
this.selectionQueue.enqueue(bestPath);
if(currentResult.type == 'complete') {
this.completedPaths?.push(currentResult.finalNode);
currentResult.spaceId = this.spaceId;
}
return currentResult;
}
public get previousResults(): SearchResult[] {
return this.completedPaths?.map((n => new SearchResult(n, this.spaceId))) ?? [];
}
get model(): LexicalModelTypes.LexicalModel {
return this.parents[0].model;
}
get codepointLength(): number {
return this.parents[0].codepointLength;
}
get inputSegments(): InputSegment[] {
return this.parents[0].inputSegments;
};
/**
* Gets a compact string-based representation of `inputRange` that
* maps compatible token source ranges to each other.
*/
get sourceRangeKey(): string {
return this.parents[0].sourceRangeKey;
}
merge(space: SearchQuotientNode): SearchQuotientNode {
throw new Error('Method not implemented.');
}
split(charIndex: number): [SearchQuotientNode, SearchQuotientNode] {
throw new Error('Method not implemented.');
}
}

View file

@ -8,7 +8,7 @@
* engine.
*/
import { QueueComparator as Comparator, KMWString, PriorityQueue } from '@keymanapp/web-utils';
import { QueueComparator, KMWString, PriorityQueue } from '@keymanapp/web-utils';
import { LexicalModelTypes } from '@keymanapp/common-types';
import { buildMergedTransform } from '@keymanapp/models-templates';
@ -23,7 +23,7 @@ import LexicalModel = LexicalModelTypes.LexicalModel;
import ProbabilityMass = LexicalModelTypes.ProbabilityMass;
import Transform = LexicalModelTypes.Transform;
export const QUEUE_NODE_COMPARATOR: Comparator<SearchNode> = function(arg1, arg2) {
export const QUEUE_NODE_COMPARATOR: QueueComparator<SearchNode> = function(arg1, arg2) {
return arg1.currentCost - arg2.currentCost;
}

View file

@ -5,7 +5,7 @@ export * from './correction/context-tokenization.js';
export { ContextTracker } from './correction/context-tracker.js';
export { ContextTransition } from './correction/context-transition.js';
export * from './correction/distance-modeler.js';
export * from './correction/search-quotient-node.js';
export * from './correction/search-quotient-cluster.js';
export * from './correction/search-quotient-spur.js';
export * from './correction/search-quotient-node.js';
export * from './correction/legacy-quotient-root.js';

View file

@ -256,12 +256,8 @@ describe('ContextState', () => {
assert.equal(state.tokenization.tokens[state.tokenization.tokens.length - 2].searchModule.inputCount, 1);
// empty transform
assert.equal(state.tokenization.tokens[state.tokenization.tokens.length - 1].searchModule.inputCount, 1);
// if(!newContextMatch.final.tokenization.alignment.canAlign) {
// assert.fail("context alignment failed");
// }
// assert.equal(newContextMatch.final.tokenization.alignment.leadTokenShift, 0);
// assert.equal(newContextMatch.final.tokenization.alignment.tailTokenShift, 2);
assert.isTrue(state.tokenization.tail.searchModule instanceof SearchQuotientSpur);
assert.deepEqual((state.tokenization.tail.searchModule as SearchQuotientSpur).lastInput, [{sample: { insert: '', deleteLeft: 0 }, p: 1}]);
});
it("properly matches and aligns when whitespace before final empty token is extended", function() {
@ -286,8 +282,10 @@ describe('ContextState', () => {
// Two whitespaces, one of which is new!
const preTail = state.tokenization.tokens[state.tokenization.tokens.length - 2];
assert.equal(preTail.searchModule.inputCount, 2);
assert.deepEqual((preTail.searchModule as SearchQuotientSpur).lastInput, [{sample: transform, p: 1}]);
assert.deepEqual((preTail.searchModule.parents[0] as SearchQuotientSpur).lastInput, [{sample: transform, p: 1}]);
assert.equal(state.tokenization.tail.searchModule.inputCount, 1);
assert.isTrue(state.tokenization.tail.searchModule instanceof SearchQuotientSpur);
assert.deepEqual((state.tokenization.tail.searchModule as SearchQuotientSpur).lastInput, [{sample: { insert: '', deleteLeft: 0 }, p: 1}]);
});
it("properly matches and aligns when a 'wordbreak' is removed via backspace", function() {
@ -304,12 +302,6 @@ describe('ContextState', () => {
let newContextMatch = baseState.analyzeTransition(existingContext, toWrapperDistribution(transform));
assert.isOk(newContextMatch?.final);
assert.deepEqual(newContextMatch?.final.tokenization.tokens.map(token => token.exampleInput), rawTokens);
// if(!newContextMatch.final.tokenization.alignment.canAlign) {
// assert.fail("context alignment failed");
// }
// assert.equal(newContextMatch.final.tokenization.alignment.leadTokenShift, 0);
// assert.equal(newContextMatch.final.tokenization.alignment.tailTokenShift, -2);
});
it("properly matches and aligns when an implied 'wordbreak' occurs (as when following \"'\")", function() {
@ -332,12 +324,6 @@ describe('ContextState', () => {
let state = newContextMatch.final;
assert.equal(state.tokenization.tokens[state.tokenization.tokens.length - 2].searchModule.inputCount, 1);
assert.equal(state.tokenization.tokens[state.tokenization.tokens.length - 1].searchModule.inputCount, 1);
// if(!newContextMatch.final.tokenization.alignment.canAlign) {
// assert.fail("context alignment failed");
// }
// assert.equal(newContextMatch.final.tokenization.alignment.leadTokenShift, 0);
// assert.equal(newContextMatch.final.tokenization.alignment.tailTokenShift, 1);
})
// Needs improved context-state management (due to 2x tokens)
@ -398,12 +384,6 @@ describe('ContextState', () => {
assert.equal(
state.tokenization.tokens[state.tokenization.tokens.length - 1].searchModule.inputCount, 1
);
// if(!newContextMatch.final.tokenization.alignment.canAlign) {
// assert.fail("context alignment failed");
// }
// assert.equal(newContextMatch.final.tokenization.alignment.leadTokenShift, 0);
// assert.equal(newContextMatch.final.tokenization.alignment.tailTokenShift, 2);
});
it("properly matches and aligns when tail token is modified AND a 'wordbreak' is added'", function() {
@ -427,12 +407,6 @@ describe('ContextState', () => {
let state = newContextMatch.final;
assert.equal(state.tokenization.tokens[state.tokenization.tokens.length - 2].searchModule.inputCount, 1);
assert.equal(state.tokenization.tokens[state.tokenization.tokens.length - 1].searchModule.inputCount, 1);
// if(!newContextMatch.final.tokenization.alignment.canAlign) {
// assert.fail("context alignment failed");
// }
// assert.equal(newContextMatch.final.tokenization.alignment.leadTokenShift, 0);
// assert.equal(newContextMatch.final.tokenization.alignment.tailTokenShift, 2);
});
it('handles case where tail token is split into three rather than two', function() {

View file

@ -625,7 +625,7 @@ describe('ContextTokenization', function() {
});
});
it('handles case that triggers a token split: can\' +. => can, \', .', () => {
it.skip('handles case that triggers a token split: can\' +. => can, \', .', () => {
const baseTokens = ['an', ' ', 'apple', ' ', 'a', ' ', 'day', ' ', 'can\''];
const baseTokenization = new ContextTokenization(baseTokens.map(t => toToken(t)));

View file

@ -0,0 +1,437 @@
/*
* Keyman is copyright (C) SIL Global. MIT License.
*
* Created by jahorton on 2025-10-29
*
* This file defines tests for the SearchQuotientCluster class of the
* predictive-text correction-search engine.
*/
import { assert } from 'chai';
import { LexicalModelTypes } from '@keymanapp/common-types';
import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs';
import { LegacyQuotientRoot, LegacyQuotientSpur, models, SearchQuotientCluster } from '@keymanapp/lm-worker/test-index';
import Distribution = LexicalModelTypes.Distribution;
import Transform = LexicalModelTypes.Transform;
import TrieModel = models.TrieModel;
import { constituentPaths } from '#test-resources/searchQuotientUtils.js';
const testModel = new TrieModel(jsonFixture('models/tries/english-1000'));
export const buildAlphabeticClusterFixtures = () => {
const rootPath = new LegacyQuotientRoot(testModel);
// consonant-cluster 1, insert 1, delete 0
const distrib_c1_i1d0: Distribution<Transform> = [
{ sample: { insert: 'b', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.3 }, // most likely for id 11
{ sample: { insert: 'c', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.2 },
{ sample: { insert: 'd', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.1 },
];
// consonant-cluster 1, insert 2, delete 0
const distrib_c1_i2d0: Distribution<Transform> = [
{ sample: { insert: 'fg', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.16 },
{ sample: { insert: 'hj', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.14 },
{ sample: { insert: 'kl', deleteLeft: 0, deleteRight: 0, id: 11 }, p: 0.1 },
];
// keystrokes 1, codepoints 1, total inserts 1, delete 0
const path_k1c1_i1d0 = new LegacyQuotientSpur(rootPath, distrib_c1_i1d0, distrib_c1_i1d0[0]);
// keystrokes 1, codepoints 2, total inserts 2, delete 0
const path_k1c2_i2d0 = new LegacyQuotientSpur(rootPath, distrib_c1_i2d0, distrib_c1_i1d0[0]);
// Second input
const distrib_v1_i1d0: Distribution<Transform> = [
{ sample: { insert: 'e', deleteLeft: 0, deleteRight: 0, id: 12 }, p: 0.4 }, // most likely for id 12
{ sample: { insert: 'a', deleteLeft: 0, deleteRight: 0, id: 12 }, p: 0.3 },
{ sample: { insert: 'i', deleteLeft: 0, deleteRight: 0, id: 12 }, p: 0.1 },
{ sample: { insert: 'o', deleteLeft: 0, deleteRight: 0, id: 12 }, p: 0.1 },
{ sample: { insert: 'u', deleteLeft: 0, deleteRight: 0, id: 12 }, p: 0.1 },
];
const path_k2c2_i2d0 = new LegacyQuotientSpur(path_k1c1_i1d0, distrib_v1_i1d0, distrib_v1_i1d0[0]);
const path_k2c3_i3d0 = new LegacyQuotientSpur(path_k1c2_i2d0, distrib_v1_i1d0, distrib_v1_i1d0[0]);
// Third input
const distrib_v2_i1d0: Distribution<Transform> = [
{ sample: { insert: 'e', deleteLeft: 0, deleteRight: 0, id: 13 }, p: 0.15 }, // most likely for id 13
{ sample: { insert: 'a', deleteLeft: 0, deleteRight: 0, id: 13 }, p: 0.13 },
{ sample: { insert: 'i', deleteLeft: 0, deleteRight: 0, id: 13 }, p: 0.12 },
{ sample: { insert: 'o', deleteLeft: 0, deleteRight: 0, id: 13 }, p: 0.11 },
{ sample: { insert: 'u', deleteLeft: 0, deleteRight: 0, id: 13 }, p: 0.09 },
]; // 0.60 total
const distrib_v2_i1d1: Distribution<Transform> = [
{ sample: { insert: 'á', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.05 },
{ sample: { insert: 'é', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.06 },
{ sample: { insert: 'í', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.04 },
{ sample: { insert: 'ó', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.03 },
{ sample: { insert: 'ú', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.02 },
]; // 0.2 total
const distrib_v2_i2d1: Distribution<Transform> = [
{ sample: { insert: 'áá', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.05 },
{ sample: { insert: 'éé', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.06 },
{ sample: { insert: 'íí', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.04 },
{ sample: { insert: 'óó', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.03 },
{ sample: { insert: 'úú', deleteLeft: 1, deleteRight: 0, id: 13 }, p: 0.02 },
]; // 0.2 total
const path_k3c2_i3d1 = new LegacyQuotientSpur(path_k2c2_i2d0, distrib_v2_i1d1, distrib_v2_i1d0[0]);
const path_k3c3_i3d0 = new LegacyQuotientSpur(path_k2c2_i2d0, distrib_v2_i1d0, distrib_v2_i1d0[0]);
const path_k3c3_i4d1a = new LegacyQuotientSpur(path_k2c2_i2d0, distrib_v2_i2d1, distrib_v2_i1d0[0]);
const path_k3c3_i4d1b = new LegacyQuotientSpur(path_k2c3_i3d0, distrib_v2_i1d1, distrib_v2_i1d0[0]);
const path_k3c4_i4d0 = new LegacyQuotientSpur(path_k2c3_i3d0, distrib_v2_i1d0, distrib_v2_i1d0[0]);
const path_k3c4_i5d1 = new LegacyQuotientSpur(path_k2c3_i3d0, distrib_v2_i2d1, distrib_v2_i1d0[0]);
const cluster_k3c3 = new SearchQuotientCluster([path_k3c3_i3d0, path_k3c3_i4d1a, path_k3c3_i4d1b]);
const cluster_k3c4 = new SearchQuotientCluster([path_k3c4_i4d0, path_k3c4_i5d1]);
// Input 4
const distrib_c2_i1d0: Distribution<Transform> = [ // most likely for id 11
{ sample: { insert: 'n', deleteLeft: 0, deleteRight: 0, id: 14 }, p: 0.12 },
{ sample: { insert: 'p', deleteLeft: 0, deleteRight: 0, id: 14 }, p: 0.08 },
];
const distrib_c2_i2d0: Distribution<Transform> = [
{ sample: { insert: 'qr', deleteLeft: 0, deleteRight: 0, id: 14 }, p: 0.3 }, // most likely for id 14
{ sample: { insert: 'st', deleteLeft: 0, deleteRight: 0, id: 14 }, p: 0.2 },
{ sample: { insert: 'vw', deleteLeft: 0, deleteRight: 0, id: 14 }, p: 0.1 }
];
const path_k4c4_i2 = new LegacyQuotientSpur(path_k3c2_i3d1, distrib_c2_i2d0, distrib_c2_i2d0[0]);
const path_k4c4_i1 = new LegacyQuotientSpur(cluster_k3c3, distrib_c2_i1d0, distrib_c2_i2d0[0]);
const path_k4c5_i2 = new LegacyQuotientSpur(cluster_k3c3, distrib_c2_i2d0, distrib_c2_i2d0[0]);
const path_k4c5_i1 = new LegacyQuotientSpur(cluster_k3c4, distrib_c2_i1d0, distrib_c2_i2d0[0]);
const path_k4c6 = new LegacyQuotientSpur(cluster_k3c4, distrib_c2_i2d0, distrib_c2_i2d0[0]);
const cluster_k4c4 = new SearchQuotientCluster([path_k4c4_i2, path_k4c4_i1]);
const cluster_k4c5 = new SearchQuotientCluster([path_k4c5_i2, path_k4c5_i1]);
return {
distributions: {
1: {
distrib_c1_i1d0,
distrib_c1_i2d0
},
2: {
distrib_v1_i1d0
},
3: {
distrib_v2_i1d0,
distrib_v2_i1d1,
distrib_v2_i2d1
},
4: {
distrib_c2_i1d0,
distrib_c2_i2d0
}
},
paths: {
0: {
rootPath
},
1: {
path_k1c1_i1d0,
path_k1c2_i2d0,
},
2: {
path_k2c2_i2d0,
path_k2c3_i3d0,
},
3: {
path_k3c2_i3d1,
path_k3c3_i3d0,
path_k3c3_i4d1a,
path_k3c3_i4d1b,
path_k3c4_i4d0,
path_k3c4_i5d1,
},
4: {
path_k4c4_i2,
path_k4c4_i1,
path_k4c5_i2,
path_k4c5_i1,
path_k4c6
}
},
clusters: {
cluster_k3c3,
cluster_k3c4,
cluster_k4c4,
cluster_k4c5
}
}
}
describe('SearchCluster', () => {
describe('constructor()', () => {
it('initializes from LegacySearchRoot', () => {
const path = new LegacyQuotientRoot(testModel);
const cluster = new SearchQuotientCluster([path]);
assert.equal(cluster.inputCount, 0);
assert.equal(cluster.codepointLength, 0);
assert.isNumber(cluster.spaceId);
assert.deepEqual(cluster.bestExample, {text: '', p: 1});
assert.deepEqual(cluster.parents, [path]);
});
it('initializes from arbitrary SearchQuotientSpur', () => {
const rootPath = new LegacyQuotientRoot(testModel);
const leadEdgeDistribution: Distribution<Transform> = [
{sample: {insert: 't', deleteLeft: 0, id: 13 }, p: 0.5},
{sample: {insert: 'a', deleteLeft: 0, id: 13 }, p: 0.3},
{sample: {insert: 'o', deleteLeft: 0, id: 13 }, p: 0.2}
];
const length1Path = new LegacyQuotientSpur(
rootPath,
leadEdgeDistribution,
leadEdgeDistribution[0]
);
const tailEdgeDistribution = [
{sample: {insert: 'r', deleteLeft: 0, id: 17 }, p: 0.6},
{sample: {insert: 'e', deleteLeft: 0, id: 17 }, p: 0.25},
{sample: {insert: 'h', deleteLeft: 0, id: 17 }, p: 0.15}
];
const length2Path = new LegacyQuotientSpur(
length1Path,
tailEdgeDistribution,
tailEdgeDistribution[0]
);
const cluster = new SearchQuotientCluster([length2Path]);
assert.equal(cluster.inputCount, 2);
assert.equal(cluster.codepointLength, 2);
assert.isNumber(cluster.spaceId);
assert.notEqual(cluster.spaceId, length1Path.spaceId);
assert.deepEqual(cluster.bestExample, {text: 'tr', p: leadEdgeDistribution[0].p * tailEdgeDistribution[0].p});
assert.deepEqual(cluster.parents, [length2Path]);
assert.deepEqual(cluster.inputSegments, [
{
start: 0,
transitionId: leadEdgeDistribution[0].sample.id
}, {
start: 0,
transitionId: tailEdgeDistribution[0].sample.id
}
]);
});
it('throws an error when constructor array parameter is empty', () => {
assert.throws(() => new SearchQuotientCluster([]));
});
it('throws an error if parent .inputCount values don\'t match', () => {
const rootPath = new LegacyQuotientRoot(testModel);
const leadEdgeDistribution: Distribution<Transform> = [
{sample: {insert: 't', deleteLeft: 0, id: 13 }, p: 0.5},
{sample: {insert: 'a', deleteLeft: 0, id: 13 }, p: 0.3},
{sample: {insert: 'o', deleteLeft: 0, id: 13 }, p: 0.2}
];
const length1Path = new LegacyQuotientSpur(
rootPath,
leadEdgeDistribution,
leadEdgeDistribution[0]
);
const tailEdgeDistribution = [
{sample: {insert: 'r', deleteLeft: 0, id: 17 }, p: 0.6},
{sample: {insert: 'e', deleteLeft: 0, id: 17 }, p: 0.25},
{sample: {insert: 'h', deleteLeft: 0, id: 17 }, p: 0.15}
];
const length2Path = new LegacyQuotientSpur(
length1Path,
tailEdgeDistribution,
tailEdgeDistribution[0]
);
const altDistribution = [
{sample: {insert: 'tr', deleteLeft: 0, id: 13 }, p: 0.6},
{sample: {insert: 'te', deleteLeft: 0, id: 13 }, p: 0.25},
{sample: {insert: 'th', deleteLeft: 0, id: 13 }, p: 0.15}
];
const singleInputPath = new LegacyQuotientSpur(rootPath, altDistribution, altDistribution[0]);
assert.throws(() => new SearchQuotientCluster([length2Path, singleInputPath]));
});
it('throws an error if SearchPath .sourceIdentifier values don\'t match', () => {
const rootPath = new LegacyQuotientRoot(testModel);
const distribution1: Distribution<Transform> = [
{sample: {insert: 't', deleteLeft: 0, id: 13 }, p: 0.5},
{sample: {insert: 'a', deleteLeft: 0, id: 13 }, p: 0.3},
{sample: {insert: 'o', deleteLeft: 0, id: 13 }, p: 0.2}
];
const path1 = new LegacyQuotientSpur(
rootPath,
distribution1,
distribution1[0]
);
const distribution2 = [
{sample: {insert: 'r', deleteLeft: 0, id: 17 }, p: 0.6},
{sample: {insert: 'e', deleteLeft: 0, id: 17 }, p: 0.25},
{sample: {insert: 'h', deleteLeft: 0, id: 17 }, p: 0.15}
];
const path2 = new LegacyQuotientSpur(
rootPath,
distribution2,
distribution2[0]
);
assert.throws(() => new SearchQuotientCluster([path1, path2]));
});
it('throws an error if SearchPath .codepointLength values don\'t match', () => {
const rootPath = new LegacyQuotientRoot(testModel);
const dist1: Distribution<Transform> = [
{sample: {insert: 't', deleteLeft: 0, id: 13 }, p: 0.5},
{sample: {insert: 'a', deleteLeft: 0, id: 13 }, p: 0.3},
{sample: {insert: 'o', deleteLeft: 0, id: 13 }, p: 0.2}
];
const path1 = new LegacyQuotientSpur(
rootPath,
dist1,
dist1[0]
);
const dist2 = [
{sample: {insert: 'tr', deleteLeft: 0, id: 13 }, p: 0.6},
{sample: {insert: 'te', deleteLeft: 0, id: 13 }, p: 0.25},
{sample: {insert: 'th', deleteLeft: 0, id: 13 }, p: 0.15}
];
const path2 = new LegacyQuotientSpur(rootPath, dist2, dist2[0]);
assert.throws(() => new SearchQuotientCluster([path1, path2]));
});
});
it('constructs a SearchPath + SearchCluster fixture properly', () => {
// Finishing construction of the fixture without errors is itself an
// implicit test.
buildAlphabeticClusterFixtures();
});
// As it's used to validate other SearchCluster unit tests, it's wise to test
// this early.
describe('constituentPaths()', () => {
it('enumerates clusters built only from paths', () => {
const { paths, clusters } = buildAlphabeticClusterFixtures();
const threeCharCluster = clusters.cluster_k3c3;
assert.equal(constituentPaths(threeCharCluster).length, 3);
// Root path counts for this.
constituentPaths(threeCharCluster).forEach(sequence => assert.equal(sequence.length, 3));
assert.includeDeepMembers(constituentPaths(threeCharCluster), [
[
paths[1].path_k1c1_i1d0,
paths[2].path_k2c2_i2d0,
paths[3].path_k3c3_i3d0
], [
paths[1].path_k1c1_i1d0,
paths[2].path_k2c2_i2d0,
paths[3].path_k3c3_i4d1a
], [
paths[1].path_k1c2_i2d0,
paths[2].path_k2c3_i3d0,
paths[3].path_k3c3_i4d1b
]
]);
const fourCharCluster = clusters.cluster_k3c4;
assert.equal(constituentPaths(fourCharCluster).length, 2);
// Root path counts for this.
constituentPaths(fourCharCluster).forEach(sequence => assert.equal(sequence.length, 3));
assert.includeDeepMembers(constituentPaths(fourCharCluster), [
[
paths[1].path_k1c2_i2d0,
paths[2].path_k2c3_i3d0,
paths[3].path_k3c4_i4d0
], [
paths[1].path_k1c2_i2d0,
paths[2].path_k2c3_i3d0,
paths[3].path_k3c4_i5d1
]
]);
});
it('enumerates clusters built from a mix of parent paths and clusters', () => {
const { paths, clusters } = buildAlphabeticClusterFixtures();
const fourCharCluster = clusters.cluster_k4c4;
assert.equal(constituentPaths(fourCharCluster).length, 4);
// Root path counts for this.
constituentPaths(fourCharCluster).forEach(sequence => assert.equal(sequence.length, 4));
assert.notIncludeDeepOrderedMembers(constituentPaths(fourCharCluster), [
[
paths[1].path_k1c1_i1d0,
paths[2].path_k2c2_i2d0,
paths[3].path_k3c3_i3d0,
paths[4].path_k4c4_i2 // last component writes the third char
]
]);
assert.includeDeepMembers(constituentPaths(fourCharCluster),
// Should have all paths enumerable from cluster_k3c3 as a prefix.
constituentPaths(clusters.cluster_k3c3).map((seq) => {
seq.push(paths[4].path_k4c4_i1);
return seq;
})
);
assert.includeDeepMembers(constituentPaths(fourCharCluster), [
[
paths[1].path_k1c1_i1d0,
paths[2].path_k2c2_i2d0,
paths[3].path_k3c2_i3d1,
paths[4].path_k4c4_i2
]
]);
const fiveCharCluster = clusters.cluster_k4c5;
assert.equal(constituentPaths(fiveCharCluster).length, 5);
// Root path counts for this.
constituentPaths(fiveCharCluster).forEach(sequence => assert.equal(sequence.length, 4));
assert.includeDeepMembers(constituentPaths(fiveCharCluster),
// Should have all paths enumerable from cluster_k3c3 as a prefix.
constituentPaths(clusters.cluster_k3c3).map((seq) => {
seq.push(paths[4].path_k4c5_i2);
return seq;
})
);
assert.includeDeepMembers(constituentPaths(fiveCharCluster),
// Should have all paths enumerable from cluster_k3c4 as a prefix.
constituentPaths(clusters.cluster_k3c4).map((seq) => {
seq.push(paths[4].path_k4c5_i1);
return seq;
})
);
});
});
});

View file

@ -8,6 +8,7 @@ import { buildSimplePathSplitFixture } from './search-quotient-spur.tests.js';
import { quotientPathHasInputs } from '#test-resources/searchQuotientUtils.js';
import TrieModel = models.TrieModel;
import { buildAlphabeticClusterFixtures } from './search-quotient-cluster.tests.js';
const testModel = new TrieModel(jsonFixture('models/tries/english-1000'));
@ -50,5 +51,38 @@ describe('quotientNodeHasParents()', () => {
} while(!isShuffled);
assert.isFalse(quotientPathHasInputs(paths[4], shuffled));
});
it('is able to match inputs against SearchQuotientCluster constituent input paths', () => {
const { distributions, clusters } = buildAlphabeticClusterFixtures();
const fourCharCluster = clusters.cluster_k4c4;
const fiveCharCluster = clusters.cluster_k4c5;
assert.isTrue(quotientPathHasInputs(fourCharCluster, [
distributions[1].distrib_c1_i1d0,
distributions[2].distrib_v1_i1d0,
distributions[3].distrib_v2_i1d0,
distributions[4].distrib_c2_i1d0
]));
assert.isFalse(quotientPathHasInputs(fiveCharCluster,[
distributions[1].distrib_c1_i1d0,
distributions[2].distrib_v1_i1d0,
distributions[3].distrib_v2_i1d0,
distributions[4].distrib_c2_i1d0
]));
assert.isFalse(quotientPathHasInputs(fourCharCluster, [
distributions[1].distrib_c1_i1d0,
distributions[2].distrib_v1_i1d0,
distributions[3].distrib_v2_i1d0,
distributions[4].distrib_c2_i2d0
]));
assert.isTrue(quotientPathHasInputs(fiveCharCluster, [
distributions[1].distrib_c1_i1d0,
distributions[2].distrib_v1_i1d0,
distributions[3].distrib_v2_i1d0,
distributions[4].distrib_c2_i2d0
]));
});
});

View file

@ -23,6 +23,8 @@ import {
SearchQuotientSpur
} from '@keymanapp/lm-worker/test-index';
import { buildAlphabeticClusterFixtures } from './search-quotient-cluster.tests.js';
import Distribution = LexicalModelTypes.Distribution;
import Transform = LexicalModelTypes.Transform;
import TrieModel = models.TrieModel;
@ -302,7 +304,22 @@ describe('SearchQuotientSpur', () => {
assert.sameOrderedMembers(pathSequence, paths.slice(1));
});
// TODO: add a test for mixed SearchQuotientSpur / SearchCluster cases.
it('properly enumerates child paths when encountering SearchCluster ancestors', () => {
const fixture = buildAlphabeticClusterFixtures();
const finalPath = fixture.paths[4].path_k4c6;
// The longest SearchPath at the end of that fixture's set is based on a
// lead-in cluster; all variants of that should be included.
assert.equal(constituentPaths(finalPath).length, constituentPaths(fixture.clusters.cluster_k3c4).length);
// That cluster holds the different potential penultimate paths;
// finalPath's inputs are added directly after any variation that may be
// output from the cluster.
assert.sameDeepMembers(constituentPaths(finalPath), constituentPaths(fixture.clusters.cluster_k3c4).map((p) => {
p.push(finalPath);
return p;
}));
});
});
describe('split()', () => {
@ -1618,27 +1635,27 @@ describe('SearchQuotientSpur', () => {
}
});
it('splits properly at index 0', () => {
it('merges tokens previously split at index 0', () => {
runCommonAssertions(0);
});
it('splits properly at index 1', () => {
it('merges tokens previously split at index 1', () => {
runCommonAssertions(1);
});
it('splits properly at index 2', () => {
it('merges tokens previously split at index 2', () => {
runCommonAssertions(2);
});
it('splits properly at index 3', () => {
it('merges tokens previously split at index 3', () => {
runCommonAssertions(3);
});
it('splits properly at index 4', () => {
it('merges tokens previously split at index 4', () => {
runCommonAssertions(4);
});
it('splits properly at index 5', () => {
it('merges tokens previously split at index 5', () => {
runCommonAssertions(5);
});
});
@ -1795,27 +1812,27 @@ describe('SearchQuotientSpur', () => {
}
});
it('splits properly at index 0', () => {
it('merges tokens previously split at index 0', () => {
runCommonAssertions(0);
});
it('splits properly at index 1', () => {
it('merges tokens previously split at index 1', () => {
runCommonAssertions(1);
});
it('splits properly at index 2', () => {
it('merges tokens previously split at index 2', () => {
runCommonAssertions(2);
});
it('splits properly at index 3', () => {
it('merges tokens previously split at index 3', () => {
runCommonAssertions(3);
});
it('splits properly at index 4', () => {
it('merges tokens previously split at index 4', () => {
runCommonAssertions(4);
});
it('splits properly at index 5', () => {
it('merges tokens previously split at index 5', () => {
runCommonAssertions(5);
});
});

View file

@ -9,7 +9,7 @@
import { LexicalModelTypes } from "@keymanapp/common-types";
import { SearchQuotientNode, SearchQuotientRoot, SearchQuotientSpur } from "@keymanapp/lm-worker/test-index";
import { SearchQuotientCluster, SearchQuotientNode, SearchQuotientRoot, SearchQuotientSpur } from "@keymanapp/lm-worker/test-index";
import Distribution = LexicalModelTypes.Distribution;
import Transform = LexicalModelTypes.Transform;
@ -93,6 +93,8 @@ export function quotientPathHasInputs(node: SearchQuotientNode, keystrokeDistrib
export function constituentPaths(node: SearchQuotientNode): SearchQuotientSpur[][] {
if(node instanceof SearchQuotientRoot) {
return [];
} else if(node instanceof SearchQuotientCluster) {
return node.parents.flatMap((p) => constituentPaths(p));
} else if(node instanceof SearchQuotientSpur) {
const parentPaths = constituentPaths(node.parents[0]);
if(parentPaths.length > 0) {