Merge branch 'refactor/web/extract-duplicate-result-filter-extended' into feat/web/correction-search-abstraction-extended

This commit is contained in:
Joshua Horton 2026-04-21 16:56:27 -05:00
commit a4f392fb2a
9 changed files with 70 additions and 41 deletions

View file

@ -48,7 +48,7 @@ export class LegacyQuotientRoot extends SearchQuotientRoot {
return {
type: 'complete',
cost: node.currentCost,
mapping: new TokenResultMapping(node, this)
mapping: new TokenResultMapping(this, node)
};
}
@ -57,7 +57,7 @@ export class LegacyQuotientRoot extends SearchQuotientRoot {
}
get previousResults(): TokenResultMapping[] {
return this.processed.map((n) => new TokenResultMapping(n, this));
return this.processed.map((n) => new TokenResultMapping(this, n));
}
split(charIndex: number): [SearchQuotientNode, SearchQuotientNode][] {

View file

@ -42,7 +42,7 @@ export class LegacyQuotientSpur extends SearchQuotientSpur {
const codepointLength = space.codepointLength + insertLength - inputSample.deleteLeft;
super(space, inputs, inputSource, codepointLength);
this.queueNodes(this.buildEdgesForNodes(space.previousResults.map(r => r.node)));
this.queueNodes(this.buildEdgesFromResults(space.previousResults));
this.insertLength = insertLength;
this.leftDeleteLength = inputSample.deleteLeft;
return;
@ -52,19 +52,19 @@ export class LegacyQuotientSpur extends SearchQuotientSpur {
return new LegacyQuotientSpur(parentNode, inputs, inputSource) as this;
}
protected buildEdgesForNodes(baseNodes: ReadonlyArray<SearchNode>) {
protected buildEdgesFromResults(priorResults: ReadonlyArray<TokenResultMapping>) {
// With a newly-available input, we can extend new input-dependent paths from
// our previously-reached 'extractedResults' nodes.
let outboundNodes = baseNodes.map((node) => {
let outboundNodes = priorResults.map((result) => {
// Hard restriction: no further edits will be supported. This helps keep the search
// more narrowly focused.
const substitutionsOnly = node.editCount == 2;
const substitutionsOnly = result.editCount == 2;
let deletionEdges: SearchNode[] = [];
if(!substitutionsOnly) {
deletionEdges = node.buildDeletionEdges(this.inputs, this.spaceId);
deletionEdges = result.buildDeletionEdges(this.inputs, this.spaceId);
}
const substitutionEdges = node.buildSubstitutionEdges(this.inputs, this.spaceId);
const substitutionEdges = result.buildSubstitutionEdges(this.inputs, this.spaceId);
// Skip the queue for the first pass; there will ALWAYS be at least one pass,
// and queue-enqueing does come with a cost - avoid unnecessary overhead here.
@ -84,12 +84,12 @@ export class LegacyQuotientSpur extends SearchQuotientSpur {
const result = super.handleNextNode();
if(result.type == 'complete') {
const currentNode = result.mapping.node;
const parentResult = result.mapping;
// Forbid a raw edit-distance of greater than 2.
// Note: .knownCost is not scaled, while its contribution to .currentCost _is_ scaled.
if(currentNode.editCount < 2) {
let insertionEdges = currentNode.buildInsertionEdges();
if(parentResult.editCount < 2) {
let insertionEdges = parentResult.buildInsertionEdges();
this.queueNodes(insertionEdges);
}
}

View file

@ -12,7 +12,6 @@ import { QueueComparator, PriorityQueue } from '@keymanapp/web-utils';
import { LexicalModelTypes } from '@keymanapp/common-types';
import { PathResult } from './correction-searchable.js';
import { SearchNode } from './distance-modeler.js';
import { LegacyQuotientRoot } from './legacy-quotient-root.js';
import { generateSpaceSeed, InputSegment, SearchQuotientNode } from './search-quotient-node.js';
import { SearchQuotientSpur } from './search-quotient-spur.js';
@ -37,7 +36,7 @@ export class SearchQuotientCluster implements SearchQuotientNode {
* 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?: SearchNode[] = [];
private completedPaths?: TokenResultMapping[] = [];
/**
* Acts as a Map that prevents duplicating a correction-search path if reached
@ -87,8 +86,7 @@ export class SearchQuotientCluster implements SearchQuotientNode {
this.spaceId = generateSpaceSeed();
this.lowestPossibleSingleCost = lowestPossibleSingleCost;
this.completedPaths = inboundPaths.flatMap(p => p.previousResults).map(r => r.node);
this.completedPaths.forEach((p) => p.spaceId = this.spaceId);
this.completedPaths = inboundPaths.flatMap(p => p.previousResults).map(r => new TokenResultMapping(this, r));
this.selectionQueue.enqueueAll(inboundPaths);
return;
@ -146,16 +144,14 @@ export class SearchQuotientCluster implements SearchQuotientNode {
this.selectionQueue.enqueue(bestPath);
if(currentResult.type == 'complete') {
const node = currentResult.mapping.node;
node.spaceId = this.spaceId;
this.completedPaths?.push(node);
this.completedPaths.push(new TokenResultMapping(this, currentResult.mapping));
}
return currentResult;
}
public get previousResults(): TokenResultMapping[] {
return this.completedPaths?.map((n) => new TokenResultMapping(n, this)) ?? [];
return this.completedPaths;
}
get model(): LexicalModelTypes.LexicalModel {

View file

@ -37,7 +37,7 @@ export class SearchQuotientRoot implements SearchQuotientNode {
this.rootNode = new SearchNode(model.traverseFromRoot(), generateSpaceSeed(), t => model.toKey(t));
this.model = model;
this.rootResult = new TokenResultMapping(this.rootNode, this);
this.rootResult = new TokenResultMapping(this, this.rootNode);
}
get spaceId(): number {
@ -78,7 +78,7 @@ export class SearchQuotientRoot implements SearchQuotientNode {
return {
type: 'complete',
cost: 0,
mapping: new TokenResultMapping(this.rootNode, this)
mapping: new TokenResultMapping(this, this.rootNode)
};
}

View file

@ -321,7 +321,7 @@ export abstract class SearchQuotientSpur implements SearchQuotientNode {
return Math.min(localCost, parentCost);
}
protected abstract buildEdgesForNodes(baseNodes: ReadonlyArray<SearchNode>): SearchNode[];
protected abstract buildEdgesFromResults(baseNodes: ReadonlyArray<TokenResultMapping>): SearchNode[];
protected queueNodes(nodes: SearchNode[]) {
this.selectionQueue.enqueueAll(nodes);
@ -348,7 +348,7 @@ export abstract class SearchQuotientSpur implements SearchQuotientNode {
const result = this.parentNode.handleNextNode();
if(result.type == 'complete') {
this.queueNodes(this.buildEdgesForNodes([result.mapping.node]));
this.queueNodes(this.buildEdgesFromResults([result.mapping]));
}
return {
@ -405,7 +405,7 @@ export abstract class SearchQuotientSpur implements SearchQuotientNode {
return {
type: 'complete',
cost: currentNode.currentCost,
mapping: new TokenResultMapping(currentNode, this)
mapping: new TokenResultMapping(this, currentNode)
};
}
@ -414,7 +414,7 @@ export abstract class SearchQuotientSpur implements SearchQuotientNode {
}
public get previousResults(): TokenResultMapping[] {
return Object.values(this.returnedValues ?? {}).map(v => new TokenResultMapping(v, this));
return Object.values(this.returnedValues ?? {}).map(v => new TokenResultMapping(this, v));
}
public get inputSegments(): InputSegment[] {

View file

@ -10,29 +10,27 @@
import { LexicalModelTypes } from '@keymanapp/common-types';
import { CorrectionResultMapping } from "./correction-result-mapping.js";
import { SearchNode, TraversableToken } from "./distance-modeler.js";
import { SearchQuotientNode } from "./search-quotient-node.js";
import Distribution = LexicalModelTypes.Distribution;
import LexiconTraversal = LexicalModelTypes.LexiconTraversal;
import ProbabilityMass = LexicalModelTypes.ProbabilityMass;
import Transform = LexicalModelTypes.Transform;
export function initTokenResultFilterer() {
const priorReturns: Map<string, SearchNode> = new Map();
const priorReturns: Map<string, TokenResultMapping> = new Map();
const closure = (searchResult: TokenResultMapping) => {
const node = searchResult.node;
if(node.isFullReplacement) {
if(searchResult.isFullReplacement) {
// If the entry's 'match' fully replaces the input string, we consider it
// unreasonable and ignore it. Also, if we've reached this point...
// we can(?) assume that everything thereafter is as well.
return false;
}
if((priorReturns.get(node.resultKey)?.currentCost ?? Number.MAX_VALUE) > searchResult.totalCost) {
priorReturns.set(node.resultKey, node);
if((priorReturns.get(searchResult.matchString)?.totalCost ?? Number.MAX_VALUE) > searchResult.totalCost) {
priorReturns.set(searchResult.matchString, searchResult);
return true;
} else {
@ -43,13 +41,29 @@ export function initTokenResultFilterer() {
return closure;
}
export class TokenResultMapping implements CorrectionResultMapping<SearchNode> {
export class TokenResultMapping {
readonly matchingSpace: SearchQuotientNode;
readonly node: SearchNode;
private readonly node: SearchNode;
readonly spaceId: number;
constructor(node: SearchNode, finalQuotientNode: SearchQuotientNode) {
constructor(finalQuotientNode: SearchQuotientNode, node: SearchNode);
constructor(finalQuotientNode: SearchQuotientNode, mapping: TokenResultMapping);
constructor(finalQuotientNode: SearchQuotientNode, mappingBase: SearchNode | TokenResultMapping) {
if(!mappingBase) {
throw new Error("Result-mapping parameters may not be null");
}
this.matchingSpace = finalQuotientNode;
this.node = node;
if(mappingBase instanceof SearchNode) {
this.node = mappingBase;
this.spaceId = mappingBase.spaceId;
if(mappingBase.spaceId != this.matchingSpace.spaceId) {
throw new Error("QuotientNode and SearchNode .spaceId values provided to TokenResultMapping constructor should be equal");
}
} else {
this.node = mappingBase.node;
this.spaceId = this.matchingSpace.spaceId;
}
}
get matchedResult(): Readonly<SearchNode> {
@ -68,6 +82,13 @@ export class TokenResultMapping implements CorrectionResultMapping<SearchNode> {
return this.node.resultKey;
}
get editCount(): number {
return this.node.editCount;
}
get isFullReplacement(): boolean {
return this.node.isFullReplacement;
}
/**
* Gets the number of Damerau-Levenshtein edits needed to reach the node's
* matchString from the output induced by the input sequence used to reach it.
@ -100,4 +121,16 @@ export class TokenResultMapping implements CorrectionResultMapping<SearchNode> {
get finalTraversal(): LexiconTraversal {
return this.node.currentTraversal;
}
buildInsertionEdges(): SearchNode[] {
return this.node.buildInsertionEdges();
}
buildDeletionEdges(dist: Distribution<Transform>, edgeId: number): SearchNode[] {
return this.node.buildDeletionEdges(dist, edgeId);
}
buildSubstitutionEdges(dist: Distribution<Transform>, edgeId: number): SearchNode[] {
return this.node.buildSubstitutionEdges(dist, edgeId);
}
}

View file

@ -394,7 +394,7 @@ export function determineSuggestionAlignment(
export function buildAndMapPredictions(
transition: ContextTransition,
tokenization: ContextTokenization,
match: TokenResultMapping
match: Readonly<TokenResultMapping>
): CorrectionPredictionTuple[] {
const model = transition.final.model;
const searchSpace = tokenization.tail.searchModule;
@ -537,7 +537,7 @@ export async function correctAndEnumerate(
const correctionPredictionMap: Record<string, Distribution<Suggestion>> = {};
for await(const match of getBestMatches<SearchNode, TokenResultMapping, SearchQuotientNode>(searchModules, timer, initTokenResultFilterer())) {
// Corrections obtained: now to predict from them!
const tokenization = tokenizations.find(t => t.spaceId == match.node.spaceId);
const tokenization = tokenizations.find(t => t.spaceId == match.spaceId);
// If our 'match' results in fully deleting the new token, reject it and try again.
if(match.matchSequence.length == 0 && match.inputSequence.length != 0) {
@ -549,7 +549,7 @@ export async function correctAndEnumerate(
continue;
}
if(match.node.editCount > 0 && !searchModules.find(s => s.correctionsEnabled)) {
if(match.editCount > 0 && !searchModules.find(s => s.correctionsEnabled)) {
continue;
}

View file

@ -57,7 +57,7 @@ export function analyzeQuotientNodeResults(
let result: PathResult<TokenResultMapping> = node.handleNextNode();
while(result.type != 'none') {
if(result.type == 'complete') {
const resultKey = result.mapping.node.resultKey;
const resultKey = result.mapping.matchString;
if(expectedResults.find((entry) => entry == resultKey)) {
matchMap.set(resultKey, (matchMap.get(resultKey) ?? 0) + 1);
}

View file

@ -21,7 +21,7 @@ function buildTestTimer() {
}
describe('getBestMatches', () => {
const checkRepeatableResults_teh = async (iter: AsyncGenerator<TokenResultMapping, any, any>) => {
const checkRepeatableResults_teh = async (iter: AsyncGenerator<Readonly<TokenResultMapping>, any, any>) => {
const firstIterResult = await iter.next(); // {value: <actual value>, done: <iteration complete?>}
assert.isFalse(firstIterResult.done);