feat(web): ensure all child spaces are notified when parent reaches end of a correction path

Build-bot: skip build:web
Test-bot: skip
This commit is contained in:
Joshua Horton 2025-11-13 16:35:37 -06:00
parent 70f04e618e
commit 2faa96754c
7 changed files with 111 additions and 76 deletions

View file

@ -45,10 +45,12 @@ export class LegacyQuotientRoot extends SearchQuotientRoot {
}
this.processed.push(node);
const result = new TokenResultMapping(this, node);
this.saveResult(result);
return {
type: 'complete',
cost: node.currentCost,
mapping: new TokenResultMapping(this, node)
mapping: result
};
}

View file

@ -42,7 +42,6 @@ export class LegacyQuotientSpur extends SearchQuotientSpur {
const codepointLength = space.codepointLength + insertLength - inputSample.deleteLeft;
super(space, inputs, inputSource, codepointLength);
this.queueNodes(this.buildEdgesFromResults(space.previousResults));
this.insertLength = insertLength;
this.leftDeleteLength = inputSample.deleteLeft;
return;

View file

@ -23,20 +23,12 @@ const PATH_QUEUE_COMPARATOR: QueueComparator<SearchQuotientNode> = (a, b) => {
// 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 {
export class SearchQuotientCluster extends 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?: TokenResultMapping[] = [];
// 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.
/**
* Acts as a Map that prevents duplicating a correction-search path if reached
@ -60,6 +52,8 @@ export class SearchQuotientCluster implements SearchQuotientNode {
* @param inboundPaths
*/
constructor(inboundPaths: SearchQuotientNode[]) {
super();
if(inboundPaths.length == 0) {
throw new Error("SearchQuotientCluster requires an array with at least one SearchQuotientNode");
}
@ -86,7 +80,6 @@ export class SearchQuotientCluster implements SearchQuotientNode {
this.spaceId = generateSpaceSeed();
this.lowestPossibleSingleCost = lowestPossibleSingleCost;
this.completedPaths = inboundPaths.flatMap(p => p.previousResults).map(r => new TokenResultMapping(this, r));
this.selectionQueue.enqueueAll(inboundPaths);
return;
@ -140,25 +133,21 @@ export class SearchQuotientCluster implements SearchQuotientNode {
*/
public handleNextNode(): PathResult<TokenResultMapping> {
const bestPath = this.selectionQueue.dequeue();
const currentResult = bestPath.handleNextNode();
const baseResult = bestPath.handleNextNode();
this.selectionQueue.enqueue(bestPath);
let finalResult = currentResult;
if(currentResult.type == 'complete') {
let finalResult = baseResult;
if(baseResult.type == 'complete') {
finalResult = {
...currentResult,
mapping: new TokenResultMapping(this, currentResult.mapping)
...baseResult,
mapping: new TokenResultMapping(this, baseResult.mapping)
};
this.completedPaths.push(finalResult.mapping);
this.saveResult(finalResult.mapping);
}
return finalResult;
}
public get previousResults(): TokenResultMapping[] {
return this.completedPaths;
}
get model(): LexicalModelTypes.LexicalModel {
return this.parents[0].model;
}

View file

@ -80,23 +80,35 @@ export interface PathInputProperties {
* Represents all or a portion of the dynamically-generated graph used to search
* for predictive-text corrections.
*/
export interface SearchQuotientNode extends CorrectionSearchable<SearchNode, TokenResultMapping> {
export abstract class SearchQuotientNode implements CorrectionSearchable<SearchNode, TokenResultMapping> {
/**
* Holds all `incomingNode` child buffers - buffers to hold nodes processed by
* this SearchCluster but not yet by child SearchSpaces.
*/
private childQueues: TokenResultMapping[][] = [];
/**
* Marks all results that have already been returned from this instance of SearchPath.
* Should be deleted and cleared if any paths consider this one as a parent.
*/
private returnedValues?: {[resultKey: string]: TokenResultMapping} = {};
/**
* Returns an identifier uniquely identifying this search-batching structure
* by correction-search results.
*/
readonly spaceId: number;
abstract get spaceId(): number;
/**
* The active LexicalModel for use with correction-search.
*/
readonly model: LexicalModel;
abstract get model(): LexicalModel;
/**
* Notes the SearchQuotientNode(s) whose correction-search paths are extended by this
* SearchQuotientNode.
*/
readonly parents: SearchQuotientNode[];
abstract get parents(): SearchQuotientNode[];
/**
* Retrieves the lowest-cost / lowest-distance edge from the batcher's search
@ -104,20 +116,20 @@ export interface SearchQuotientNode extends CorrectionSearchable<SearchNode, Tok
* what sort of result the edge's destination node represents.
* @returns
*/
handleNextNode(): PathResult<TokenResultMapping>;
abstract handleNextNode(): PathResult<TokenResultMapping>
/**
* Increases the editing range that will be considered for determining
* correction distances.
*/
increaseMaxEditDistance(): void;
abstract increaseMaxEditDistance(): void;
/**
* Reports the cost of the lowest-cost / lowest-distance edge held within the
* batcher's search area.
* @returns
*/
readonly currentCost: number;
abstract get currentCost(): number;
/**
* Provides a heuristic for the base cost at this path's depth if the best
@ -127,19 +139,21 @@ export interface SearchQuotientNode extends CorrectionSearchable<SearchNode, Tok
* This cost is based on the negative log-likelihood of the probability and
* includes the cost from the lowest possible parent nodes visited.
*/
readonly lowestPossibleSingleCost: number;
abstract readonly lowestPossibleSingleCost: number;
/**
* Returns the set of previously-processed results under this batcher's domain.
*/
readonly previousResults: TokenResultMapping[];
get previousResults(): TokenResultMapping[] {
return [...Object.values(this.returnedValues ?? {})];
};
/**
* 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.
*/
readonly correctionsEnabled: boolean;
abstract readonly correctionsEnabled: boolean;
/**
* Reports the total number of input keystrokes represented by this
@ -148,32 +162,32 @@ export interface SearchQuotientNode extends CorrectionSearchable<SearchNode, Tok
* (Their fat-finger alternates, when provided, do not influence this count -
* they're associated with the original keystroke that affected the context.)
*/
readonly inputCount: number;
abstract readonly inputCount: number;
/**
* Reports the length in codepoints of corrected text represented by completed
* paths from this instance.
*/
readonly codepointLength: number;
abstract readonly codepointLength: number;
/**
* Determines the best example text representable by this SearchQuotientNode's
* portion of the correction-search graph and its paths.
*/
readonly bestExample: { text: string, p: number };
abstract readonly bestExample: { text: string, p: number };
/**
* Gets components representing the keystroke range corrected by this
* search-space quotient node. If only part of any keystroke's effects are
* used, this will also be noted.
*/
readonly inputSegments: InputSegment[];
abstract readonly inputSegments: InputSegment[];
/**
* Gets a compact string-based representation of `inputRange` that
* maps compatible token source ranges to each other.
*/
get sourceRangeKey(): string;
abstract get sourceRangeKey(): string;
/**
* Appends this SearchQuotientNode with the provided SearchQuotientNode's search properties,
@ -182,7 +196,7 @@ export interface SearchQuotientNode extends CorrectionSearchable<SearchNode, Tok
* of any split input components will be fully re-merged.
* @param space
*/
merge(space: SearchQuotientNode): SearchQuotientNode;
abstract merge(space: SearchQuotientNode): SearchQuotientNode;
/**
* Splits this SearchQuotientNode into two halves at the specified codepoint index.
@ -195,7 +209,7 @@ export interface SearchQuotientNode extends CorrectionSearchable<SearchNode, Tok
* SearchSpace instance.
* @param charIndex
*/
split(charIndex: number): [SearchQuotientNode, SearchQuotientNode][];
abstract split(charIndex: number): [SearchQuotientNode, SearchQuotientNode][];
/**
* Determines if the SearchQuotientNode is a duplicate of another instance.
@ -203,5 +217,34 @@ export interface SearchQuotientNode extends CorrectionSearchable<SearchNode, Tok
* path(s) taken to reach each must be 100% identical.
* @param node
*/
isSameNode(node: SearchQuotientNode): boolean;
abstract isSameNode(node: SearchQuotientNode): boolean;
// The TS type system prevents this method from being rooted on the instance provided in
// the first parameter, sadly.
/**
* Links the provided queueing buffer to the provided parent node. When the
* parent produces new intermediate results, those results will be made
* available for use in construction of extended paths.
* @param parentNode
* @param childQueue
*/
protected linkAndQueueFromParent(parentNode: SearchQuotientNode, childQueue: TokenResultMapping[]): void {
parentNode.childQueues.push(childQueue);
}
/**
* Log the results of a processed node and queue it within all subscribed
* processor nodes for construction of deeper search paths.
* @param node
*/
protected saveResult(result: TokenResultMapping): boolean {
const priorMatch = this.returnedValues[result.matchString];
if(priorMatch !== undefined && priorMatch.totalCost <= result.totalCost) {
return false;
}
this.returnedValues[result.matchString] = result;
this.childQueues.forEach((buf) => buf.push(result));
return true;
}
}

View file

@ -11,10 +11,9 @@ import LexicalModel = LexicalModelTypes.LexicalModel;
// 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 SearchQuotientRoot implements SearchQuotientNode {
export class SearchQuotientRoot extends SearchQuotientNode {
readonly rootNode: SearchNode;
readonly model: LexicalModel;
private readonly rootResult: TokenResultMapping;
readonly lowestPossibleSingleCost: number = 0;
@ -25,19 +24,19 @@ export class SearchQuotientRoot implements SearchQuotientNode {
private hasBeenProcessed: boolean = false;
/**
* Constructs a fresh SearchQuotientRoot instance to be used as the root of
* the predictive-text correction / suggestion search process.
* Constructs a fresh SearchSpace instance for used in predictive-text correction
* and suggestion searches.
* @param baseSpaceId
* @param model
*/
constructor(model: LexicalModel) {
super();
if(!model.traverseFromRoot) {
throw new Error("The active lexical model does not support traversal-based searching.");
}
this.rootNode = new SearchNode(model.traverseFromRoot(), generateSpaceSeed(), t => model.toKey(t));
this.model = model;
this.rootResult = new TokenResultMapping(this, this.rootNode);
}
get spaceId(): number {
@ -75,10 +74,12 @@ export class SearchQuotientRoot implements SearchQuotientNode {
this.hasBeenProcessed = true;
const result = new TokenResultMapping(this, this.rootNode);
this.saveResult(result);
return {
type: 'complete',
cost: 0,
mapping: new TokenResultMapping(this, this.rootNode)
mapping: result
};
}
@ -86,14 +87,6 @@ export class SearchQuotientRoot implements SearchQuotientNode {
return this.hasBeenProcessed ? Number.POSITIVE_INFINITY : 0;
}
get previousResults(): TokenResultMapping[] {
if(!this.hasBeenProcessed) {
return [];
} else {
return [this.rootResult];
}
}
// Return a new array each time; avoid aliasing potential!
get inputSegments(): InputSegment[] {
return [];

View file

@ -31,8 +31,15 @@ export const QUEUE_NODE_COMPARATOR: QueueComparator<SearchNode> = function(arg1,
// 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 abstract class SearchQuotientSpur implements SearchQuotientNode {
export abstract class SearchQuotientSpur extends SearchQuotientNode {
private selectionQueue: PriorityQueue<SearchNode> = new PriorityQueue(QUEUE_NODE_COMPARATOR);
/**
* Holds all incoming Nodes generated from a parent `SearchSpace` that have not yet been
* extended with this `SearchSpace`'s input.
*/
private incomingNodes: TokenResultMapping[] = [];
readonly inputs?: Distribution<Transform>;
readonly inputSource?: PathInputProperties;
@ -44,12 +51,6 @@ export abstract class SearchQuotientSpur implements SearchQuotientNode {
public abstract readonly insertLength: number;
public abstract readonly leftDeleteLength: number
/**
* Marks all results that have already been returned from this instance of SearchPath.
* Should be deleted and cleared if any paths consider this one as a parent.
*/
private returnedValues?: {[resultKey: string]: SearchNode} = {};
/**
* Provides a heuristic for the base cost at this path's depth if the best
* individual input were taken here, regardless of whether or not that's possible.
@ -74,6 +75,7 @@ export abstract class SearchQuotientSpur implements SearchQuotientNode {
inputSource: PathInputProperties | ProbabilityMass<Transform>,
codepointLength: number
) {
super();
this.spaceId = generateSpaceSeed();
// Coerce inputSource to TokenInputSource format.
@ -101,6 +103,9 @@ export abstract class SearchQuotientSpur implements SearchQuotientNode {
this.inputs = inputs?.length > 0 ? inputs : null;
this.inputCount = parentNode.inputCount + (this.inputs ? 1 : 0);
this.codepointLength = codepointLength;
this.queueNodes(this.buildEdgesFromResults(parentNode.previousResults));
this.linkAndQueueFromParent(parentNode, this.incomingNodes);
}
public get model(): LexicalModel {
@ -334,6 +339,11 @@ export abstract class SearchQuotientSpur implements SearchQuotientNode {
* @returns
*/
public handleNextNode(): PathResult<TokenResultMapping> {
if(this.incomingNodes.length > 0) {
this.buildEdgesFromResults(this.incomingNodes);
this.incomingNodes = [];
}
const parentCost = this.parentNode?.currentCost ?? Number.POSITIVE_INFINITY;
const localCost = this.selectionQueue.peek()?.currentCost ?? Number.POSITIVE_INFINITY;
@ -346,6 +356,9 @@ export abstract class SearchQuotientSpur implements SearchQuotientNode {
if(parentCost <= localCost) {
const result = this.parentNode.handleNextNode();
// The parent will insert the node into our queue. We don't need it, though
// any siblings certainly will.
this.incomingNodes = [];
if(result.type == 'complete') {
this.queueNodes(this.buildEdgesFromResults([result.mapping]));
@ -392,20 +405,20 @@ export abstract class SearchQuotientSpur implements SearchQuotientNode {
// OK, we fully crossed a graph edge and have landed on a transition point;
// time to build more edges / edge batches.
const result = new TokenResultMapping(this, currentNode);
if(currentNode.spaceId == this.spaceId) {
if(this.returnedValues) {
if((this.returnedValues[currentNode.resultKey]?.currentCost ?? Number.POSITIVE_INFINITY) > currentNode.currentCost) {
this.returnedValues[currentNode.resultKey] = currentNode;
} else {
// Not a better cost, so reject it and move on to the next potential result.
return this.handleNextNode();
}
// Verify that we don't already have a better-cost result for the
// correction already.
const isUnhandled = this.saveResult(result);
if(!isUnhandled) {
// Not a better cost, so reject it and move on to the next potential result.
return this.handleNextNode();
}
return {
type: 'complete',
cost: currentNode.currentCost,
mapping: new TokenResultMapping(this, currentNode)
mapping: result
};
}
@ -413,10 +426,6 @@ export abstract class SearchQuotientSpur implements SearchQuotientNode {
return unmatchedResult;
}
public get previousResults(): TokenResultMapping[] {
return Object.values(this.returnedValues ?? {}).map(v => new TokenResultMapping(this, v));
}
public get inputSegments(): InputSegment[] {
if(!this.parentNode) {
return [];

View file

@ -26,4 +26,4 @@ export { ModelCompositor } from './model-compositor.js';
export * from './predict-helpers.js';
export { default as TransformUtils } from './transformUtils.js'
export { default as LMLayerWorker } from './index.js'
export * from './transform-subsets.js';
export * from './transform-subsets.js';