From 88393c515d86fd620c8ec6be05b7e0f72ca9803d Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Thu, 4 Sep 2025 11:25:59 -0500 Subject: [PATCH 1/2] refactor(web): refactor edit-path construction to support extensions from subclasses --- .../main/correction/classical-calculation.ts | 418 ++++++++++-------- .../correction/segmentable-calculation.ts | 2 +- 2 files changed, 232 insertions(+), 188 deletions(-) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/classical-calculation.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/classical-calculation.ts index 152c9cf2cf..683e0a19ff 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/classical-calculation.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/classical-calculation.ts @@ -9,9 +9,9 @@ export type EditOperation = 'insert' | 'delete' | 'match' | 'substitute' | 'tran * Represents individual nodes on calculated edit paths and the relevant * edited value(s) at each step. */ -export interface EditTuple { +export interface EditTuple { /** The edit operation taking place at this position in the edit path */ - op: EditOperation, + op: TOpSet | EditOperation, /** The value for the `input` source at this position in the edit path */ input?: TUnit, /** The value for the `match` source at this position in the edit path */ @@ -53,7 +53,7 @@ export interface EditTuple { * visualization * @returns */ -export function visualizeCalculation(calc: ClassicalDistanceCalculation, path?: EditTuple[]) { +export function visualizeCalculation(calc: ClassicalDistanceCalculation, path?: EditTuple[]) { path = (path ?? calc.editPath()[0]).slice(); const inputs = calc.inputSequence.map(i => '' + i); @@ -107,7 +107,7 @@ export function visualizeCalculation(calc: ClassicalDistanceCalculation 0 ? sparseCount : 1); let edits: string[] = []; - const printEdit = (edit: EditTuple) => { + const printEdit = (edit: EditTuple) => { let tokenText: string; switch(edit.op) { case 'delete': @@ -168,7 +168,7 @@ export function visualizeCalculation(calc: ClassicalDistanceCalculation { +export class ClassicalDistanceCalculation { /** * Stores ONLY the computed diagonal elements, nothing else. * @@ -213,8 +213,8 @@ export class ClassicalDistanceCalculation { * Clones an already-existing instance, aliasing old data only where safe. * @param other */ - constructor(other: ClassicalDistanceCalculation); - constructor(other?: ClassicalDistanceCalculation) { + constructor(other: ClassicalDistanceCalculation); + constructor(other?: ClassicalDistanceCalculation) { if(other) { // Clone class properties. let rowCount = other.resolvedDistances.length; @@ -270,7 +270,7 @@ export class ClassicalDistanceCalculation { * Does not actually mutate the instance. */ getFinalCost(): number { - let buffer = this as ClassicalDistanceCalculation; + let buffer = this as ClassicalDistanceCalculation; let val = buffer.getHeuristicFinalCost(); while(val > buffer.diagonalWidth) { @@ -297,7 +297,7 @@ export class ClassicalDistanceCalculation { * @param threshold */ hasFinalCostWithin(threshold: number): boolean { - let buffer = this as ClassicalDistanceCalculation; + let buffer = this as ClassicalDistanceCalculation; let val = buffer.getHeuristicFinalCost(); let guaranteedBound = this.diagonalWidth; @@ -328,8 +328,8 @@ export class ClassicalDistanceCalculation { * @param row * @param col */ - public editPath(): EditTuple[][] { - const results = this._editPath(); + public editPath(): EditTuple[][] { + const results = this._buildPath(); if(results.length <= 1) { return results; @@ -364,7 +364,7 @@ export class ClassicalDistanceCalculation { maxMS = Math.max(maxMS, ms); ms = 0; - if(edit.op.indexOf('transpose') > -1) { + if((edit.op as string).indexOf('transpose') > -1) { mst++; continue; } @@ -406,170 +406,21 @@ export class ClassicalDistanceCalculation { * @param row * @param col */ - private _editPath( - row: number = this.inputSequence.length - 1, - col: number = this.matchSequence.length - 1 - ): EditTuple[][] { - const currentCost = this.getCostAt(row, col); - if(currentCost == Number.MAX_VALUE) { - // We're too far off the main diagonal - a proper edit distance is not viable! - throw new Error("Cannot find path - diagonal width is not large enough.") - } - - const validPaths: EditTuple[][] = []; - - const tryPath = (row: number, col: number, ops: EditTuple[]) => { - // Recursively build the edit path. - let results: EditTuple[][]; - if(row >= 0 && col >= 0) { - results = this._editPath(row, col); - } else { - results = [[]]; - const result = results[0]; - for(let r = 0; r <= row; r++) { - // There are initial deletions. - result.push({ - op: 'delete', - input: this.inputSequence[r] - }); - } - for(let c = 0; c <= col; c++) { - // There are initial insertions. - result.push({ - op: 'insert', - match: this.matchSequence[c] - }); - } - } - - // If null, there must not be any valid results - if(results) { - // ... also, if the array's empty. - results.forEach(r => validPaths.push(r.concat(ops))); - } - } - - const [lastInputIndex, lastMatchIndex] = ClassicalDistanceCalculation.getTransposeParent(this, row, col); - if(lastInputIndex >= 0 && lastMatchIndex >= 0) { - // OK, a transposition source is quite possible. Still need to do more vetting, to be sure. - let expectedCost = 1; - - // This transposition includes either 'transpose-insert' or 'transpose-delete' operations. - let i = row; - let m = col; - let ops: EditTuple[] = []; - - if(lastInputIndex != row-1) { - let count = row - lastInputIndex; - ops.push({ - op: 'transpose-start', - input: this.inputSequence[i-count], - match: this.matchSequence[lastMatchIndex] - }); - // Intentional fallthrough on 0 - index 0 is covered by 'transpose-end' - // after the if-else. - for(let x=count-1; x > 0; x--) { - ops.push({ - op: 'transpose-delete', - input: this.inputSequence[i-x] - }); - } - expectedCost += count-1; - } else { - let count = col - lastMatchIndex; - ops.push({ - op: 'transpose-start', - input: this.inputSequence[lastInputIndex], - match: this.matchSequence[m-count] - }); - // Intentional fallthrough on 0 - index 0 is covered by 'transpose-end' - // after the if-else. - for(let y=count-1; y > 0; y--) { - ops.push({ - op: 'transpose-insert', - match: this.matchSequence[m-y] - }); - } - expectedCost += count - 1; - } - - ops.push({ - op: 'transpose-end', - input: this.inputSequence[i], - match: this.matchSequence[m] - }); - - // Double-check our expectations. - if(this.getCostAt(lastInputIndex-1, lastMatchIndex-1) == currentCost - expectedCost) { - tryPath(lastInputIndex - 1, lastMatchIndex -1, ops); - } - } - - // Could rework to evaluate whether the selected path actually resolves... - // But there would likely be edge cases that still wouldn't be handled - // properly. - const input = this.inputSequence[row]; - const match = this.matchSequence[col]; - - const insertParentCost = this.getCostAt(row, col-1); - if(insertParentCost == currentCost - 1) { - tryPath(row, col-1, [{op: 'insert', match}]); - } - - const deleteParentCost = this.getCostAt(row-1, col); - if(deleteParentCost == currentCost - 1) { - tryPath(row-1, col, [{op: 'delete', input}]); - } - - const substitutionParentCost = this.getCostAt(row-1, col-1); - if(substitutionParentCost == currentCost - 1) { - tryPath(row-1, col-1, [{op: 'substitute', input, match}]); - // VERY IMPORTANT: validate the match. The path can go "off the rails" if - // we don't validate this! - } else if(substitutionParentCost == currentCost && input == match) { - tryPath(row-1, col-1, [{op: 'match', input, match}]); - } - - return validPaths; + protected _buildPath(pathBuilder?: PathBuilder): EditTuple[][] { + pathBuilder = pathBuilder ?? new PathBuilder(this, []); + pathBuilder.addEdgeFinder(findBaseEdges); + pathBuilder.addEdgeFinder(findTransposeEdges); + pathBuilder.backtracePath(this.inputSequence.length - 1, this.matchSequence.length - 1, []); + return pathBuilder.validPaths; } - private static getTransposeParent( - buffer: ClassicalDistanceCalculation, - r: number, - c: number - ): [number, number] { - // Block any transpositions where the tokens are identical. - // Other operations will be cheaper. Also, block cases where 'parents' are impossible. - if(r < 0 || c < 0 || buffer.inputSequence[r] == buffer.matchSequence[c]) { - return [-1, -1]; - } - - // Transposition checks - let lastInputIndex = -1; - for(let i = r-1; i >= 0; i--) { - if(buffer.inputSequence[i] == buffer.matchSequence[c]) { - lastInputIndex = i; - break; - } - } - - let lastMatchIndex = -1; - for(let i = c-1; i >= 0; i--) { - if(buffer.matchSequence[i] == buffer.inputSequence[r]) { - lastMatchIndex = i; - break; - } - } - - return [lastInputIndex, lastMatchIndex]; - } - - private static initialCostAt( - buffer: ClassicalDistanceCalculation, - r: number, - c: number, - insertCost?: number, - deleteCost?: number) { + private static initialCostAt( + buffer: ClassicalDistanceCalculation, + r: number, + c: number, + insertCost?: number, + deleteCost?: number + ) { var baseSubstitutionCost = buffer.inputSequence[r] == buffer.matchSequence[c] ? 0 : 1; var substitutionCost: number = buffer.getCostAt(r-1, c-1) + baseSubstitutionCost; var insertionCost: number = insertCost || buffer.getCostAt(r, c-1) + 1; // If set meaningfully, will never equal zero. @@ -577,15 +428,15 @@ export class ClassicalDistanceCalculation { var transpositionCost: number = Number.MAX_VALUE if(r > 0 && c > 0) { // bypass when transpositions are known to be impossible. - let [lastInputIndex, lastMatchIndex] = ClassicalDistanceCalculation.getTransposeParent(buffer, r, c); + let [lastInputIndex, lastMatchIndex] = getTransposeParent(buffer, r, c); transpositionCost = buffer.getCostAt(lastInputIndex-1, lastMatchIndex-1) + (r - lastInputIndex - 1) + 1 + (c - lastMatchIndex - 1); } return Math.min(substitutionCost, deletionCost, insertionCost, transpositionCost); } - getSubset(inputLength: number, matchLength: number): ClassicalDistanceCalculation { - let trimmedInstance = new ClassicalDistanceCalculation(this); + getSubset(inputLength: number, matchLength: number): ClassicalDistanceCalculation { + let trimmedInstance = new ClassicalDistanceCalculation(this); if(inputLength > this.inputSequence.length || matchLength > this.matchSequence.length) { throw "Invalid dimensions specified for trim operation"; @@ -621,8 +472,8 @@ export class ClassicalDistanceCalculation { // Inputs add an extra row / first index entry. // Inputs add an extra row / first index entry. - addInputChar(token: TUnit): ClassicalDistanceCalculation { - const returnBuffer = new ClassicalDistanceCalculation(this); + addInputChar(token: TUnit): ClassicalDistanceCalculation { + const returnBuffer = new ClassicalDistanceCalculation(this); returnBuffer._addInputChar(token); return returnBuffer; } @@ -646,8 +497,8 @@ export class ClassicalDistanceCalculation { return; } - addMatchChar(token: TUnit): ClassicalDistanceCalculation { - let returnBuffer = new ClassicalDistanceCalculation(this); + addMatchChar(token: TUnit): ClassicalDistanceCalculation { + let returnBuffer = new ClassicalDistanceCalculation(this); returnBuffer._addMatchChar(token); return returnBuffer; } @@ -669,8 +520,8 @@ export class ClassicalDistanceCalculation { return; } - public increaseMaxDistance(): ClassicalDistanceCalculation { - let returnBuffer = new ClassicalDistanceCalculation(this); + public increaseMaxDistance(): ClassicalDistanceCalculation { + let returnBuffer = new ClassicalDistanceCalculation(this); returnBuffer.diagonalWidth++; if(returnBuffer.inputSequence.length < 1 || returnBuffer.matchSequence.length < 1) { @@ -770,8 +621,8 @@ export class ClassicalDistanceCalculation { return returnBuffer; } - private static propagateUpdateFrom( - buffer: ClassicalDistanceCalculation, + private static propagateUpdateFrom( + buffer: ClassicalDistanceCalculation, r: number, c: number, value: number, @@ -891,8 +742,8 @@ export class ClassicalDistanceCalculation { * @param extendingIsRow Set to true if the extending axis is the row. * @param closure */ -export function forNewIndices( - calc: ClassicalDistanceCalculation, +export function forNewIndices( + calc: ClassicalDistanceCalculation, extendingIsRow: boolean, /** * The closure will be called with two values for indexing @@ -918,4 +769,197 @@ export function forNewIndices( closure(startOffset + diagonalIndex, extendingAxisCap, 2 * diagonalWidth - diagonalIndex); } } +} + +function getTransposeParent( + buffer: ClassicalDistanceCalculation, + r: number, + c: number +): [number, number] { + // Block any transpositions where the tokens are identical. + // Other operations will be cheaper. Also, block cases where 'parents' are impossible. + if(r < 0 || c < 0 || buffer.inputSequence[r] == buffer.matchSequence[c]) { + return [-1, -1]; + } + + // Transposition checks + let lastInputIndex = -1; + for(let i = r-1; i >= 0; i--) { + if(buffer.inputSequence[i] == buffer.matchSequence[c]) { + lastInputIndex = i; + break; + } + } + + let lastMatchIndex = -1; + for(let i = c-1; i >= 0; i--) { + if(buffer.matchSequence[i] == buffer.inputSequence[r]) { + lastMatchIndex = i; + break; + } + } + + return [lastInputIndex, lastMatchIndex]; +} + +export function findTransposeEdges( + pathBuilder: PathBuilder, + row: number, + col: number +): void { + const calc = pathBuilder.calc; + const currentCost = calc.getCostAt(row, col); + const [lastInputIndex, lastMatchIndex] = getTransposeParent(calc, row, col); + if(lastInputIndex >= 0 && lastMatchIndex >= 0) { + // OK, a transposition source is quite possible. Still need to do more vetting, to be sure. + let expectedCost = 1; + + // This transposition includes either 'transpose-insert' or 'transpose-delete' operations. + let i = row; + let m = col; + let ops: EditTuple[] = []; + + if(lastInputIndex != row-1) { + let count = row - lastInputIndex; + ops.push({ + op: 'transpose-start', + input: calc.inputSequence[i-count], + match: calc.matchSequence[lastMatchIndex] + }); + // Intentional fallthrough on 0 - index 0 is covered by 'transpose-end' + // after the if-else. + for(let x=count-1; x > 0; x--) { + ops.push({ + op: 'transpose-delete', + input: calc.inputSequence[i-x] + }); + } + expectedCost += count-1; + } else { + let count = col - lastMatchIndex; + ops.push({ + op: 'transpose-start', + input: calc.inputSequence[lastInputIndex], + match: calc.matchSequence[m-count] + }); + // Intentional fallthrough on 0 - index 0 is covered by 'transpose-end' + // after the if-else. + for(let y=count-1; y > 0; y--) { + ops.push({ + op: 'transpose-insert', + match: calc.matchSequence[m-y] + }); + } + expectedCost += count - 1; + } + + ops.push({ + op: 'transpose-end', + input: calc.inputSequence[i], + match: calc.matchSequence[m] + }); + + // Double-check our expectations. + if(calc.getCostAt(lastInputIndex-1, lastMatchIndex-1) == currentCost - expectedCost) { + pathBuilder.backtracePath(lastInputIndex - 1, lastMatchIndex -1, ops); + } + } +} + +/** + * Determines the edit path used to obtain the optimal cost, distinguishing between zero-cost + * substitutions ('match' operations) and actual substitutions. + * @param row + * @param col + */ +export function findBaseEdges( + pathBuilder: PathBuilder, + row: number, + col: number +): void { + const calc = pathBuilder.calc; + const currentCost = calc.getCostAt(row, col); + if(currentCost == Number.MAX_VALUE) { + // We're too far off the main diagonal - a proper edit distance is not viable! + throw new Error("Cannot find path - diagonal width is not large enough.") + } + + // Could rework to evaluate whether the selected path actually resolves... + // But there would likely be edge cases that still wouldn't be handled + // properly. + const input = calc.inputSequence[row]; + const match = calc.matchSequence[col]; + + const insertParentCost = calc.getCostAt(row, col-1); + if(insertParentCost == currentCost - 1) { + pathBuilder.backtracePath(row, col-1, [{op: 'insert', match}]); + } + + const deleteParentCost = calc.getCostAt(row-1, col); + if(deleteParentCost == currentCost - 1) { + pathBuilder.backtracePath(row-1, col, [{op: 'delete', input}]); + } + + const substitutionParentCost = calc.getCostAt(row-1, col-1); + if(substitutionParentCost == currentCost - 1) { + pathBuilder.backtracePath(row-1, col-1, [{op: 'substitute', input, match}]); + // VERY IMPORTANT: validate the match. The path can go "off the rails" if + // we don't validate this! + } else if(substitutionParentCost == currentCost && input == match) { + pathBuilder.backtracePath(row-1, col-1, [{op: 'match', input, match}]); + } +} + +export class PathBuilder { + readonly calc: ClassicalDistanceCalculation; + readonly edgeFinders: (typeof findBaseEdges)[]; + readonly validPaths: EditTuple[][] = []; + + constructor(calc: ClassicalDistanceCalculation, edgeFinders: (typeof findBaseEdges)[]) { + this.calc = calc; + this.edgeFinders = edgeFinders; + } + + addEdgeFinder(finder: (typeof findBaseEdges)) { + this.edgeFinders.push(finder); + } + + backtracePath(row: number, col: number, recentEdge: EditTuple[]) { + const calc = this.calc; + + // Recursively build the edit path. + let results: EditTuple[][]; + if(row >= 0 && col >= 0) { + const parentBuilder = new PathBuilder(calc, this.edgeFinders); + if(calc.getCostAt(row, col) == Number.MAX_VALUE) { + // We're too far off the main diagonal - a proper edit distance is not viable! + throw new Error("Cannot find path - diagonal width is not large enough.") + } + this.edgeFinders.forEach(finder => finder(parentBuilder, row, col)); + results = parentBuilder.validPaths; + } else { + results = [[]]; + const result = results[0]; + for(let r = 0; r <= row; r++) { + // There are initial deletions. + result.push({ + op: 'delete', + input: calc.inputSequence[r] + }); + } + for(let c = 0; c <= col; c++) { + // There are initial insertions. + result.push({ + op: 'insert', + match: calc.matchSequence[c] + }); + } + } + + // If null, there must not be any valid results + if(results) { + // ... also, if the array's empty. + results.forEach(r => this.validPaths.push(r.concat(recentEdge))); + } + } } \ No newline at end of file diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/segmentable-calculation.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/segmentable-calculation.ts index 0f9772278f..c1b6a18a53 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/segmentable-calculation.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/segmentable-calculation.ts @@ -15,7 +15,7 @@ export type ExtendedEditOperation = 'merge' | 'split' | EditOperation; * - 'split' allows one input token to be directly split, with no further edits, into * two or more match tokens. */ -export class SegmentableDistanceCalculation extends ClassicalDistanceCalculation { +export class SegmentableDistanceCalculation extends ClassicalDistanceCalculation { /** * Constructs a new calculation object instance. */ From 01d73079a9a15fd17cb99369a4ff804e039fcdf7 Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Thu, 4 Sep 2025 13:03:04 -0500 Subject: [PATCH 2/2] refactor(web): better edge-finder typing, cleanup --- .../main/correction/classical-calculation.ts | 21 +++++++++---------- .../correction/segmentable-calculation.ts | 4 ++-- 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/classical-calculation.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/classical-calculation.ts index 683e0a19ff..14a6befbc0 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/classical-calculation.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/classical-calculation.ts @@ -726,7 +726,7 @@ export class ClassicalDistanceCalculation[]) { + // public visualize(path?: EditTuple[]) { // return visualizeCalculation(this, path); // } } @@ -866,6 +866,12 @@ export function findTransposeEdges( } } +type EdgeFinder = ( + pathBuilder: PathBuilder, + row: number, + col: number +) => void + /** * Determines the edit path used to obtain the optimal cost, distinguishing between zero-cost * substitutions ('match' operations) and actual substitutions. @@ -879,14 +885,7 @@ export function findBaseEdges( ): void { const calc = pathBuilder.calc; const currentCost = calc.getCostAt(row, col); - if(currentCost == Number.MAX_VALUE) { - // We're too far off the main diagonal - a proper edit distance is not viable! - throw new Error("Cannot find path - diagonal width is not large enough.") - } - // Could rework to evaluate whether the selected path actually resolves... - // But there would likely be edge cases that still wouldn't be handled - // properly. const input = calc.inputSequence[row]; const match = calc.matchSequence[col]; @@ -912,15 +911,15 @@ export function findBaseEdges( export class PathBuilder { readonly calc: ClassicalDistanceCalculation; - readonly edgeFinders: (typeof findBaseEdges)[]; + readonly edgeFinders: (EdgeFinder)[]; readonly validPaths: EditTuple[][] = []; - constructor(calc: ClassicalDistanceCalculation, edgeFinders: (typeof findBaseEdges)[]) { + constructor(calc: ClassicalDistanceCalculation, edgeFinders: (EdgeFinder)[]) { this.calc = calc; this.edgeFinders = edgeFinders; } - addEdgeFinder(finder: (typeof findBaseEdges)) { + addEdgeFinder(finder: EdgeFinder) { this.edgeFinders.push(finder); } diff --git a/web/src/engine/predictive-text/worker-thread/src/main/correction/segmentable-calculation.ts b/web/src/engine/predictive-text/worker-thread/src/main/correction/segmentable-calculation.ts index c1b6a18a53..576f0f21df 100644 --- a/web/src/engine/predictive-text/worker-thread/src/main/correction/segmentable-calculation.ts +++ b/web/src/engine/predictive-text/worker-thread/src/main/correction/segmentable-calculation.ts @@ -93,8 +93,8 @@ export class SegmentableDistanceCalculation extends ClassicalDistanceCalculation // TODO: visualization } -function getMergeSplitParent ( - buffer: ClassicalDistanceCalculation, +function getMergeSplitParent ( + buffer: ClassicalDistanceCalculation, r: number, c: number ): [number, number] {