Merge branch 'refactor/web/use-interface-as-search-parent' into refactor/web/differentiate-module-types

This commit is contained in:
Joshua Horton 2026-01-22 09:35:00 -06:00
commit 5d294df1bf
6 changed files with 236 additions and 195 deletions

View file

@ -13,6 +13,7 @@ import { SearchNode, SearchResult } from "./distance-modeler.js";
import Distribution = LexicalModelTypes.Distribution;
import Transform = LexicalModelTypes.Transform;
import { SearchQuotientSpur } from "./search-quotient-spur.js";
let SPACE_ID_SEED = 0;
@ -63,19 +64,6 @@ export interface SearchQuotientNode {
*/
handleNextNode(): PathResult;
/**
* Denotes whether or not the represented search space includes paths built from
* the specified set of keystroke input distributions. The distribution count
* should match .inputCount - no omissions or extras are permitted.
*
* Designed explicitly for use in unit testing; it's not super-efficient, so
* avoid live use.
*
* @param keystrokeDistributions
* @internal
*/
hasInputs(keystrokeDistributions: Distribution<Transform>[]): boolean;
/**
* Increases the editing range that will be considered for determining
* correction distances.
@ -133,4 +121,73 @@ export interface SearchQuotientNode {
* the correction-search graph and its paths.
*/
readonly bestExample: { text: string, p: number };
}
/**
* Denotes whether or not the represented search-space quotient path includes
* paths built from the specified set of keystroke input distributions. The
* distribution count should match .inputCount - no omissions or extras are
* permitted.
*
* Designed explicitly for use in unit testing; it's not super-efficient, so
* avoid live use.
*
* @param keystrokeDistributions
* @internal
*/
export function quotientPathHasInputs(node: SearchQuotientNode, keystrokeDistributions: Distribution<Transform>[]): boolean {
if(!(node instanceof SearchQuotientSpur)) {
for(const p of node.parents) {
if(quotientPathHasInputs(p, keystrokeDistributions)) {
return true;
}
}
return false;
}
if(node.inputCount == 0) {
return keystrokeDistributions.length == 0;
} else if(keystrokeDistributions.length != node.inputCount) {
return false;
}
const tailInput = [...keystrokeDistributions[keystrokeDistributions.length - 1]];
keystrokeDistributions = keystrokeDistributions.slice(0, keystrokeDistributions.length - 1);
const localInput = node.lastInput;
const parentHasInput = () => !!node.parents.find(p => quotientPathHasInputs(p, keystrokeDistributions));
// Actual reference match? Easy mode.
if(localInput == tailInput) {
return parentHasInput();
} else if(localInput.length != tailInput.length) {
return false;
} else {
for(let entry of tailInput) {
const matchIndex = localInput.findIndex((x) => {
const s1 = x.sample;
const s2 = entry.sample;
// Check for equal reference first before the other checks; it makes a nice shortcut.
if(x == entry) {
return true;
}
if(x.p == entry.p && s1.deleteLeft == s2.deleteLeft
&& s1.id == s2.id && ((s1.deleteRight ?? 0) == (s2.deleteRight ?? 0)) && s1.insert == s2.insert
) {
return true;
}
return false;
});
if(matchIndex == -1) {
return false;
} else {
tailInput.splice(matchIndex, 1);
}
}
return parentHasInput();
}
}

View file

@ -70,51 +70,6 @@ export abstract class SearchQuotientSpur implements SearchQuotientNode {
return parentInputs.concat(localInputs);
}
public hasInputs(keystrokeDistributions: Distribution<Transform>[]): boolean {
if(this.inputCount == 0) {
return keystrokeDistributions.length == 0;
} else if(keystrokeDistributions.length != this.inputCount) {
return false;
}
const tailInput = [...keystrokeDistributions[keystrokeDistributions.length - 1]];
keystrokeDistributions = keystrokeDistributions.slice(0, keystrokeDistributions.length - 1);
const localInput = this.lastInput;
const parentHasInput = () => !!this.parents.find(p => p.hasInputs(keystrokeDistributions));
// Actual reference match? Easy mode.
if(localInput == tailInput) {
return parentHasInput();
} else if(localInput.length != tailInput.length) {
return false;
} else {
for(let entry of tailInput) {
const matchIndex = localInput.findIndex((x) => {
const s1 = x.sample;
const s2 = entry.sample;
// Check for equal reference first before the other checks; it makes a nice shortcut.
if(x == entry) {
return true;
} if(x.p == entry.p && s1.deleteLeft == s2.deleteLeft
&& s1.id == s2.id && ((s1.deleteRight ?? 0) == (s2.deleteRight ?? 0)) && s1.insert == s2.insert
) {
return true;
}
return false;
});
if(matchIndex == -1) {
return false;
} else {
tailInput.splice(matchIndex, 1);
}
}
return parentHasInput();
}
}
public get lastInput(): Distribution<Readonly<Transform>> {
// Shallow-copies the array to prevent external modification; the Transforms
// are marked Readonly to prevent their modification as well.

View file

@ -5,6 +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-spur.js';
export * from './correction/search-quotient-node.js';
export * from './correction/legacy-quotient-root.js';

View file

@ -14,7 +14,7 @@ import { default as defaultBreaker } from '@keymanapp/models-wordbreakers';
import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs';
import { LexicalModelTypes } from '@keymanapp/common-types';
import { ContextToken, correction, getBestMatches, models, preprocessInputSources, SearchQuotientSpur } from '@keymanapp/lm-worker/test-index';
import { ContextToken, correction, getBestMatches, models, preprocessInputSources, quotientPathHasInputs, SearchQuotientSpur } from '@keymanapp/lm-worker/test-index';
import Distribution = LexicalModelTypes.Distribution;
import ExecutionTimer = correction.ExecutionTimer;
@ -71,7 +71,8 @@ describe('ContextToken', function() {
assert.equal(token.exampleInput, 'and');
assert.equal(token.searchModule.inputCount, 3);
assert.isTrue(token.searchModule.hasInputs([
assert.isTrue(quotientPathHasInputs(
token.searchModule, [
[{sample: { insert: 'a', deleteLeft: 0 }, p: 1}],
[{sample: { insert: 'n', deleteLeft: 0 }, p: 1}],
[{sample: { insert: 'd', deleteLeft: 0 }, p: 1}]
@ -110,7 +111,8 @@ describe('ContextToken', function() {
token2.inputRange.forEach((entry) => assert.isTrue(merged.inputRange.indexOf(entry) > -1));
token3.inputRange.forEach((entry) => assert.isTrue(merged.inputRange.indexOf(entry) > -1));
assert.isTrue(merged.searchModule.hasInputs([
assert.isTrue(quotientPathHasInputs(
merged.searchModule, [
[{sample: { insert: 'c', deleteLeft: 0 }, p: 1}],
[{sample: { insert: 'a', deleteLeft: 0 }, p: 1}],
[{sample: { insert: 'n', deleteLeft: 0 }, p: 1}],
@ -207,7 +209,8 @@ describe('ContextToken', function() {
const merged = ContextToken.merge(tokensToMerge, plainModel);
assert.equal(merged.exampleInput, "applesandsourgrapes");
assert.deepEqual(merged.inputRange, srcTransforms.map((t) => ({ trueTransform: t, inputStartIndex: 0, bestProbFromSet: 1 }) ));
assert.isTrue(merged.searchModule.hasInputs(
assert.isTrue(quotientPathHasInputs(
merged.searchModule,
srcTransforms.map((t) => ([{sample: t, p: 1}]))
));
});
@ -268,7 +271,8 @@ describe('ContextToken', function() {
const merged = ContextToken.merge(tokensToMerge, plainModel);
assert.equal(merged.exampleInput, toMathematicalSMP("applesandsourgrapes"));
assert.deepEqual(merged.inputRange, srcTransforms.map((t) => ({ trueTransform: t, inputStartIndex: 0, bestProbFromSet: 1 }) ));
assert.isTrue(merged.searchModule.hasInputs(
assert.isTrue(quotientPathHasInputs(
merged.searchModule,
srcTransforms.map((t) => ([{sample: t, p: 1}]))
));
});
@ -302,7 +306,7 @@ describe('ContextToken', function() {
};
assert.equal(tokenToSplit.sourceText, 'can\'');
tokenToSplit.searchModule.hasInputs(keystrokeDistributions);
assert.isTrue(quotientPathHasInputs(tokenToSplit.searchModule, keystrokeDistributions));
// And now for the "fun" part.
const resultsOfSplit = tokenToSplit.split({
@ -319,8 +323,8 @@ describe('ContextToken', function() {
assert.equal(resultsOfSplit.length, 2);
assert.sameOrderedMembers(resultsOfSplit.map(t => t.exampleInput), ['can', '\'']);
assert.isTrue(resultsOfSplit[0].searchModule.hasInputs(keystrokeDistributions.slice(0, 3)));
assert.isTrue(resultsOfSplit[1].searchModule.hasInputs([keystrokeDistributions[3]]));
assert.isTrue(quotientPathHasInputs(resultsOfSplit[0].searchModule, keystrokeDistributions.slice(0, 3)));
assert.isTrue(quotientPathHasInputs(resultsOfSplit[1].searchModule, [keystrokeDistributions[3]]));
});
it("handles mid-transform splits correctly", () => {
@ -338,7 +342,7 @@ describe('ContextToken', function() {
};
assert.equal(tokenToSplit.sourceText, 'biglargetransform');
assert.isTrue(tokenToSplit.searchModule.hasInputs(keystrokeDistributions));
assert.isTrue(quotientPathHasInputs(tokenToSplit.searchModule, keystrokeDistributions));
// And now for the "fun" part.
const resultsOfSplit = tokenToSplit.split({
@ -367,7 +371,8 @@ describe('ContextToken', function() {
})));
for(let i = 0; i < resultsOfSplit.length; i++) {
assert.isTrue(resultsOfSplit[i].searchModule.hasInputs([
assert.isTrue(quotientPathHasInputs(
resultsOfSplit[i].searchModule, [
[{sample: { insert: splitTextArray[i], deleteLeft: 0, deleteRight: 0 }, p: 1}]
]));
}
@ -392,7 +397,7 @@ describe('ContextToken', function() {
};
assert.equal(tokenToSplit.exampleInput, 'largelongtransforms');
tokenToSplit.searchModule.hasInputs(keystrokeDistributions);
assert.isTrue(quotientPathHasInputs(tokenToSplit.searchModule, keystrokeDistributions));
// And now for the "fun" part.
const resultsOfSplit = tokenToSplit.split({
@ -422,49 +427,55 @@ describe('ContextToken', function() {
{ trueTransform: keystrokeDistributions[2][0].sample, inputStartIndex: 'ng'.length, bestProbFromSet: 1 }
]);
assert.isTrue(resultsOfSplit[0].searchModule.hasInputs([
keystrokeDistributions[0],
keystrokeDistributions[1].map((entry) => {
return {
sample: {
...entry.sample,
insert: entry.sample.insert.slice(0, 4) // gets the 'arge' portion & the deleteLefts.
}, p: entry.p
}
}),
]));
assert.isTrue(quotientPathHasInputs(
resultsOfSplit[0].searchModule,[
keystrokeDistributions[0],
keystrokeDistributions[1].map((entry) => {
return {
sample: {
...entry.sample,
insert: entry.sample.insert.slice(0, 4) // gets the 'arge' portion & the deleteLefts.
}, p: entry.p
}
})
]
));
assert.isTrue(resultsOfSplit[1].searchModule.hasInputs([
keystrokeDistributions[1].map((entry) => {
return {
sample: {
...entry.sample,
insert: entry.sample.insert.slice('arge'.length),
deleteLeft: 0
}, p: entry.p
}
}),
keystrokeDistributions[2].map((entry) => {
return {
sample: {
...entry.sample,
insert: entry.sample.insert.slice(0, 'ng'.length), // gets the 'ng' portion.
}, p: entry.p
}
}),
]));
assert.isTrue(quotientPathHasInputs(
resultsOfSplit[1].searchModule, [
keystrokeDistributions[1].map((entry) => {
return {
sample: {
...entry.sample,
insert: entry.sample.insert.slice('arge'.length),
deleteLeft: 0
}, p: entry.p
}
}),
keystrokeDistributions[2].map((entry) => {
return {
sample: {
...entry.sample,
insert: entry.sample.insert.slice(0, 'ng'.length), // gets the 'ng' portion.
}, p: entry.p
}
})
]
));
assert.isTrue(resultsOfSplit[2].searchModule.hasInputs([
keystrokeDistributions[2].map((entry) => {
return {
sample: {
...entry.sample,
insert: entry.sample.insert.slice('ng'.length), // drops the 'ng' portion.
deleteLeft: 0
}, p: entry.p
}
}),
]));
assert.isTrue(quotientPathHasInputs(
resultsOfSplit[2].searchModule, [
keystrokeDistributions[2].map((entry) => {
return {
sample: {
...entry.sample,
insert: entry.sample.insert.slice('ng'.length), // drops the 'ng' portion.
deleteLeft: 0
}, p: entry.p
}
}),
]
));
});
it("handles messy mid-transform splits correctly - non-BMP text", () => {
@ -486,7 +497,7 @@ describe('ContextToken', function() {
};
assert.equal(tokenToSplit.exampleInput, toMathematicalSMP('largelongtransforms'));
tokenToSplit.searchModule.hasInputs(keystrokeDistributions);
assert.isTrue(quotientPathHasInputs(tokenToSplit.searchModule, keystrokeDistributions));
// And now for the "fun" part.
const resultsOfSplit = tokenToSplit.split({
@ -516,49 +527,55 @@ describe('ContextToken', function() {
{ trueTransform: keystrokeDistributions[2][0].sample, inputStartIndex: 'ng'.length, bestProbFromSet: 1 }
]);
assert.isTrue(resultsOfSplit[0].searchModule.hasInputs([
keystrokeDistributions[0],
keystrokeDistributions[1].map((entry) => {
return {
sample: {
...entry.sample,
insert: KMWString.substring(entry.sample.insert, 0, 4) // gets the 'arge' portion & the deleteLefts.
}, p: entry.p
}
}),
]));
assert.isTrue(quotientPathHasInputs(
resultsOfSplit[0].searchModule, [
keystrokeDistributions[0],
keystrokeDistributions[1].map((entry) => {
return {
sample: {
...entry.sample,
insert: KMWString.substring(entry.sample.insert, 0, 4) // gets the 'arge' portion & the deleteLefts.
}, p: entry.p
}
})
]
));
assert.isTrue(resultsOfSplit[1].searchModule.hasInputs([
keystrokeDistributions[1].map((entry) => {
return {
sample: {
...entry.sample,
insert: KMWString.substring(entry.sample.insert, 'arge'.length),
deleteLeft: 0
}, p: entry.p
}
}),
keystrokeDistributions[2].map((entry) => {
return {
sample: {
...entry.sample,
insert: KMWString.substring(entry.sample.insert, 0, 'ng'.length), // gets the 'ng' portion.
}, p: entry.p
}
}),
]));
assert.isTrue(quotientPathHasInputs(
resultsOfSplit[1].searchModule, [
keystrokeDistributions[1].map((entry) => {
return {
sample: {
...entry.sample,
insert: KMWString.substring(entry.sample.insert, 'arge'.length),
deleteLeft: 0
}, p: entry.p
}
}),
keystrokeDistributions[2].map((entry) => {
return {
sample: {
...entry.sample,
insert: KMWString.substring(entry.sample.insert, 0, 'ng'.length), // gets the 'ng' portion.
}, p: entry.p
}
})
]
));
assert.isTrue(resultsOfSplit[2].searchModule.hasInputs([
keystrokeDistributions[2].map((entry) => {
return {
sample: {
...entry.sample,
insert: KMWString.substring(entry.sample.insert, 'ng'.length), // drops the 'ng' portion.
deleteLeft: 0
}, p: entry.p
}
}),
]));
assert.isTrue(quotientPathHasInputs(
resultsOfSplit[2].searchModule, [
keystrokeDistributions[2].map((entry) => {
return {
sample: {
...entry.sample,
insert: KMWString.substring(entry.sample.insert, 'ng'.length), // drops the 'ng' portion.
deleteLeft: 0
}, p: entry.p
}
})
]
));
});
});
});

View file

@ -0,0 +1,52 @@
import { assert } from 'chai';
import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs';
import { LegacyQuotientRoot, models, quotientPathHasInputs } from '@keymanapp/lm-worker/test-index';
import { buildSimplePathSplitFixture } from './search-quotient-spur.tests.js';
import TrieModel = models.TrieModel;
const testModel = new TrieModel(jsonFixture('models/tries/english-1000'));
describe('quotientNodeHasParents()', () => {
it('matches an empty array on root SearchPaths', () => {
assert.isTrue(quotientPathHasInputs(new LegacyQuotientRoot(testModel), []));
});
it('matches all path inputs when provided in proper order', () => {
const { paths, distributions } = buildSimplePathSplitFixture();
assert.isTrue(quotientPathHasInputs(paths[4], distributions));
});
it('does not match when any path input component is missing', () => {
const { paths, distributions } = buildSimplePathSplitFixture();
assert.isFalse(quotientPathHasInputs(paths[4], distributions.slice(1)));
assert.isFalse(quotientPathHasInputs(paths[4], distributions.slice(2)));
assert.isFalse(quotientPathHasInputs(paths[4], distributions.slice(3)));
assert.isFalse(quotientPathHasInputs(paths[4], distributions.slice(0, 3)));
assert.isFalse(quotientPathHasInputs(paths[4], distributions.slice(0, 1).concat(distributions.slice(2))));
});
it('does not match when path inputs are not in proper order', () => {
const { paths, distributions } = buildSimplePathSplitFixture();
assert.isFalse(quotientPathHasInputs(paths[4], distributions.slice().reverse()));
// Random shuffle.
let shuffled: typeof distributions;
let isShuffled: boolean;
do {
shuffled = distributions.slice().sort(() => Math.random() * 2 - 1);
// Validate that we actually shuffled - that we didn't land on the original order!
isShuffled = false;
for(let i = 0; i < distributions.length; i++) {
if(distributions[i] != shuffled[i]) {
isShuffled = true;
break;
}
}
} while(!isShuffled);
assert.isFalse(quotientPathHasInputs(paths[4], shuffled));
});
});

View file

@ -10,7 +10,7 @@
import { assert } from 'chai';
import { jsonFixture } from '@keymanapp/common-test-resources/model-helpers.mjs';
import { LegacyQuotientSpur, models, LegacyQuotientRoot } from '@keymanapp/lm-worker/test-index';
import { LegacyQuotientSpur, models, LegacyQuotientRoot, quotientPathHasInputs } from '@keymanapp/lm-worker/test-index';
import TrieModel = models.TrieModel;
@ -193,48 +193,7 @@ describe('SearchQuotientSpur', () => {
(constructing, current) => ({text: constructing.text + current[0].sample.insert, p: constructing.p * current[0].p}),
{text: '', p: 1})
);
assert.isTrue(pathToSplit.hasInputs(distributions));
});
});
describe('hasInputs()', () => {
it('matches an empty array on root SearchPaths', () => {
assert.isTrue(new LegacyQuotientRoot(testModel).hasInputs([]));
});
it('matches all path inputs when provided in proper order', () => {
const { paths, distributions } = buildSimplePathSplitFixture();
assert.isTrue(paths[4].hasInputs(distributions));
});
it('does not match when any path input component is missing', () => {
const { paths, distributions } = buildSimplePathSplitFixture();
assert.isFalse(paths[4].hasInputs(distributions.slice(1)));
assert.isFalse(paths[4].hasInputs(distributions.slice(2)));
assert.isFalse(paths[4].hasInputs(distributions.slice(3)));
assert.isFalse(paths[4].hasInputs(distributions.slice(0, 3)));
assert.isFalse(paths[4].hasInputs(distributions.slice(0, 1).concat(distributions.slice(2))));
});
it('does not match when path inputs are not in proper order', () => {
const { paths, distributions } = buildSimplePathSplitFixture();
assert.isFalse(paths[4].hasInputs(distributions.slice().reverse()));
// Random shuffle.
let shuffled: typeof distributions;
let isShuffled: boolean;
do {
shuffled = distributions.slice().sort(() => Math.random() * 2 - 1);
// Validate that we actually shuffled - that we didn't land on the original order!
isShuffled = false;
for(let i = 0; i < distributions.length; i++) {
if(distributions[i] != shuffled[i]) {
isShuffled = true;
break;
}
}
} while(!isShuffled);
assert.isFalse(paths[4].hasInputs(shuffled));
assert.isTrue(quotientPathHasInputs(pathToSplit, distributions));
});
});
});