mirror of
https://github.com/keymanapp/keyman.git
synced 2026-10-22 22:07:48 +00:00
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:
parent
70f04e618e
commit
2faa96754c
7 changed files with 111 additions and 76 deletions
|
|
@ -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
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue