feat(web): add .codepointLength field for SearchQuotientNode types

Build-bot: skip build:web
Skip-bot: skip
This commit is contained in:
Joshua Horton 2026-01-15 10:55:51 -06:00
parent cfe72f4aa2
commit 3149eabae3
5 changed files with 37 additions and 2 deletions

View file

@ -9,6 +9,7 @@
*/
import { LexicalModelTypes } from '@keymanapp/common-types';
import { KMWString } from '@keymanapp/web-utils';
import { SearchNode } from './distance-modeler.js';
import { PathResult, SearchQuotientNode, PathInputProperties } from './search-quotient-node.js';
@ -21,6 +22,9 @@ import Transform = LexicalModelTypes.Transform;
// The set of search spaces corresponding to the same 'context' for search.
// Whenever a wordbreak boundary is crossed, a new instance should be made.
export class LegacyQuotientSpur extends SearchQuotientSpur {
protected readonly insertLength: number;
protected readonly leftDeleteLength: number;
/**
* Constructs a fresh SearchQuotientNode instance for use in predictive-text
* correction and suggestion searches.
@ -31,6 +35,12 @@ export class LegacyQuotientSpur extends SearchQuotientSpur {
constructor(space: SearchQuotientNode, inputs: Distribution<Transform>, inputSource: PathInputProperties | ProbabilityMass<Transform>) {
super(space, inputs, inputSource);
this.queueNodes(this.buildEdgesForNodes(space.previousResults.map(r => r.node)));
// Compute this SearchPath's codepoint length & edge length.
const insert = this.inputs?.[0].sample.insert ?? '';
this.insertLength = KMWString.length(insert);
this.leftDeleteLength = this.inputs?.[0].sample.deleteLeft ?? 0;
return;
}

View file

@ -168,6 +168,12 @@ export interface SearchQuotientNode {
*/
readonly inputSequence: Distribution<Transform>[];
/**
* Reports the length in codepoints of corrected text represented by completed
* paths from this instance.
*/
readonly codepointLength: number;
/**
* Determines the best example text representable by this batcher's portion of
* the correction-search graph and its paths.

View file

@ -15,6 +15,7 @@ export class SearchQuotientRoot implements SearchQuotientNode {
readonly lowestPossibleSingleCost: number = 0;
readonly inputCount: number = 0;
readonly codepointLength: number = 0;
readonly correctionsEnabled: boolean = false;
private hasBeenProcessed: boolean = false;

View file

@ -34,6 +34,9 @@ export abstract class SearchQuotientSpur implements SearchQuotientNode {
readonly spaceId: number;
readonly inputCount: number;
private _codepointLength: number;
protected abstract readonly insertLength: number;
protected abstract readonly leftDeleteLength: number
/**
* Marks all results that have already been returned from this instance of SearchPath.
@ -102,6 +105,14 @@ export abstract class SearchQuotientSpur implements SearchQuotientNode {
return parentInputs.concat(localInputs);
}
get codepointLength(): number {
if(this._codepointLength === undefined) {
this._codepointLength = this.parentNode.codepointLength + this.insertLength - this.leftDeleteLength;
}
return this._codepointLength;
}
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.
@ -113,7 +124,7 @@ export abstract class SearchQuotientSpur implements SearchQuotientNode {
const bestLocalInput = this.inputs?.reduce((max, curr) => max.p < curr.p ? curr : max) ?? { sample: { insert: '', deleteLeft: 0 }, p: 1};
return {
text: KMWString.substring(bestPrefix.text, 0, KMWString.length(bestPrefix.text) - bestLocalInput.sample.deleteLeft) + bestLocalInput.sample.insert,
text: KMWString.substring(bestPrefix.text, 0, (this.parentNode?.codepointLength ?? 0) - bestLocalInput.sample.deleteLeft) + bestLocalInput.sample.insert,
p: bestPrefix.p * bestLocalInput.p
}
}

View file

@ -54,6 +54,7 @@ describe('SearchQuotientSpur', () => {
it('initializes from a lexical model', () => {
const path = new LegacyQuotientRoot(testModel);
assert.equal(path.inputCount, 0);
assert.equal(path.codepointLength, 0);
assert.isNumber(path.spaceId);
assert.deepEqual(path.bestExample, {text: '', p: 1});
assert.deepEqual(path.parents, []);
@ -71,6 +72,7 @@ describe('SearchQuotientSpur', () => {
const extendedPath = new LegacyQuotientSpur(rootPath, leadEdgeDistribution, leadEdgeDistribution[0]);
assert.equal(extendedPath.inputCount, 1);
assert.equal(extendedPath.codepointLength, 1);
assert.isNumber(extendedPath.spaceId);
assert.notEqual(extendedPath.spaceId, rootPath.spaceId);
assert.deepEqual(extendedPath.bestExample, {text: 't', p: 0.5});
@ -96,7 +98,7 @@ describe('SearchQuotientSpur', () => {
assert.deepEqual(rootPath.parents, []);
});
it('may be built from arbitrary prior SearchPath', () => {
it('may be built from arbitrary prior SearchQuotientSpur', () => {
const rootPath = new LegacyQuotientRoot(testModel);
const leadEdgeDistribution = [
@ -128,6 +130,7 @@ describe('SearchQuotientSpur', () => {
assert.deepEqual(leadEdgeDistribution, inputClone);
assert.equal(length2Path.inputCount, 2);
assert.equal(length2Path.codepointLength, 2);
assert.isNumber(length2Path.spaceId);
assert.notEqual(length2Path.spaceId, length1Path.spaceId);
assert.deepEqual(length2Path.bestExample, {text: 'tr', p: leadEdgeDistribution[0].p * tailEdgeDistribution[0].p});
@ -156,6 +159,7 @@ describe('SearchQuotientSpur', () => {
]);
assert.equal(length1Path.inputCount, 1);
assert.equal(length1Path.codepointLength, 1);
assert.isNumber(length1Path.spaceId);
assert.notEqual(length1Path.spaceId, rootPath.spaceId);
assert.deepEqual(length1Path.bestExample, {text: 't', p: 0.5});
@ -210,6 +214,7 @@ describe('SearchQuotientSpur', () => {
assert.deepEqual(leadEdgeDistribution, inputClone);
assert.equal(length2Path.inputCount, 2);
assert.equal(length2Path.codepointLength, 3);
assert.isNumber(length2Path.spaceId);
assert.notEqual(length2Path.spaceId, length1Path.spaceId);
assert.deepEqual(length2Path.bestExample, {text: 'tri', p: leadEdgeDistribution[0].p * tailEdgeDistribution[0].p});
@ -238,6 +243,7 @@ describe('SearchQuotientSpur', () => {
]);
assert.equal(length1Path.inputCount, 1);
assert.equal(length1Path.codepointLength, 1);
assert.isNumber(length1Path.spaceId);
assert.notEqual(length1Path.spaceId, rootPath.spaceId);
assert.deepEqual(length1Path.bestExample, {text: 't', p: 0.5});
@ -253,6 +259,7 @@ describe('SearchQuotientSpur', () => {
assert.equal(pathToSplit.inputCount, 4);
assert.equal(distributions.length, pathToSplit.inputCount);
assert.equal(pathToSplit.codepointLength, 4); // one char per input, no deletions anywhere
// Per assertions documented in the setup above.
assert.deepEqual(pathToSplit.bestExample, distributions.reduce(
(constructing, current) => ({text: constructing.text + current[0].sample.insert, p: constructing.p * current[0].p}),