mirror of
https://github.com/keymanapp/keyman.git
synced 2026-08-29 11:47:39 +00:00
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:
commit
eaaee70289
10 changed files with 704 additions and 59 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.');
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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)));
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
]));
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue