From 3149eabae3a1363bed394ea22098e640cf43fe0c Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Thu, 15 Jan 2026 10:55:51 -0600 Subject: [PATCH] feat(web): add .codepointLength field for SearchQuotientNode types Build-bot: skip build:web Skip-bot: skip --- .../src/main/correction/legacy-quotient-spur.ts | 10 ++++++++++ .../src/main/correction/search-quotient-node.ts | 6 ++++++ .../src/main/correction/search-quotient-root.ts | 1 + .../src/main/correction/search-quotient-spur.ts | 13 ++++++++++++- .../correction-search/search-quotient-spur.tests.ts | 9 ++++++++- 5 files changed, 37 insertions(+), 2 deletions(-) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/legacy-quotient-spur.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/legacy-quotient-spur.ts index 973e4fb0a8..794c279c8c 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/legacy-quotient-spur.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/legacy-quotient-spur.ts @@ -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, inputSource: PathInputProperties | ProbabilityMass) { 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; } diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-node.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-node.ts index 34023addbf..44acc76e9f 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-node.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-node.ts @@ -168,6 +168,12 @@ export interface SearchQuotientNode { */ readonly inputSequence: Distribution[]; + /** + * 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. diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-root.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-root.ts index 8dcb33977a..3a5e74a024 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-root.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-root.ts @@ -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; diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-spur.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-spur.ts index dd36c13490..6ec3bffbf1 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-spur.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/search-quotient-spur.ts @@ -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> { // 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 } } diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts index 837d5e0cae..92fb31f35e 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/search-quotient-spur.tests.ts @@ -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}),