From 0fbf9c028eb02a3dc8a0f44a6f792b61454d1c5d Mon Sep 17 00:00:00 2001 From: Joshua Horton Date: Thu, 4 Sep 2025 13:10:21 -0500 Subject: [PATCH] feat(web): support split, merge for edit-path construction Relates-to: #14679 The goal of this PR is to facilitate better handling for token splits and token merges. --- .../main/correction/classical-calculation.ts | 15 ++++- .../correction/segmentable-calculation.ts | 55 +++++++++++++++++-- .../segmentable-calculation.tests.ts | 24 ++++++++ 3 files changed, 85 insertions(+), 9 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 14a6befbc0..bb9d454987 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 @@ -120,7 +120,9 @@ export function visualizeCalculation(calc: ClassicalDistanceCalcu break; case 'substitute': case 'match': - const op = edit.op == 'substitute' ? '=>' : '=='; + case 'split': + case 'merge': + const op = edit.op == 'match' ? '==' : '=>'; tokenText = tokenText || `'${edit.input}' ${op} '${edit.match}'`; break; // transpose-start, transpose-end @@ -129,9 +131,16 @@ export function visualizeCalculation(calc: ClassicalDistanceCalcu } return `${edit.op}(${tokenText})`; } + + let lastEdit: string; do { - edits.push(printEdit(path.shift())); - } while(path.length > 0 && edits[edits.length-1].indexOf('insert') != -1); + if(lastEdit && lastEdit.indexOf('split') != -1 && path[0].op != 'split') { + break; + } + + lastEdit = printEdit(path.shift()); + edits.push(lastEdit); + } while(path.length > 0 && (lastEdit.indexOf('insert') != -1 || lastEdit.indexOf('split') != -1)); // If final row, dump the rest of the edit path into the current row. if(i == calc.inputSequence.length - 1) { // Capture final 'insert's! 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 576f0f21df..35d5d7a44f 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 @@ -1,4 +1,4 @@ -import { ClassicalDistanceCalculation, EditOperation, forNewIndices } from './classical-calculation.js'; +import { ClassicalDistanceCalculation, EditOperation, EditTuple, forNewIndices, PathBuilder } from './classical-calculation.js'; /** * The human-readable names for legal edit-operation edges on a merge-split @@ -83,18 +83,22 @@ export class SegmentableDistanceCalculation extends ClassicalDistanceCalculation return returnBuffer; } - public increaseMaxDistance(): ClassicalDistanceCalculation { + public increaseMaxDistance(): ClassicalDistanceCalculation { // TODO: diagonal expansion // But it's not particularly needed for our use cases. throw new Error("Not yet supported for this edit-distance calculation type."); } - // TODO: edit path with 'split', 'merge' handling // progress: infrastructure is likely prepped? - // TODO: visualization + protected _buildPath(pathBuilder?: PathBuilder): EditTuple[][] { + pathBuilder = pathBuilder ?? new PathBuilder(this, []); + pathBuilder.addEdgeFinder(findSplitMergeEdges); + super._buildPath(pathBuilder); // actually evaluates the edit-path. + return pathBuilder.validPaths; + } } -function getMergeSplitParent ( - buffer: ClassicalDistanceCalculation, +function getMergeSplitParent ( + buffer: ClassicalDistanceCalculation, r: number, c: number ): [number, number] { @@ -135,4 +139,43 @@ function getMergeSplitParent ( } return [lastMergeIndex, lastSplitIndex]; +} + +/** + * 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 findSplitMergeEdges( + 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.") + } + + const input = calc.inputSequence[row]; + const match = calc.matchSequence[col]; + + const [lastMergeIndex, lastSplitIndex] = getMergeSplitParent(calc, row, col); + if(lastMergeIndex != -1) { + const ops: EditTuple[] = []; + for(let r = lastMergeIndex; r <= row; r++) { + ops.push({ input: calc.inputSequence[r], match, op: 'merge' }); + } + pathBuilder.backtracePath(lastMergeIndex - 1, col - 1, ops); + } + + if(lastSplitIndex != -1) { + const ops: EditTuple[] = []; + for(let c = lastSplitIndex; c <= col; c++) { + ops.push({ input, match: calc.matchSequence[c], op: 'split' }); + } + pathBuilder.backtracePath(row - 1, lastSplitIndex - 1, ops); + } } \ No newline at end of file diff --git a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/segmentable-calculation.tests.ts b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/segmentable-calculation.tests.ts index 1f3d39c89a..d4f51be4d5 100644 --- a/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/segmentable-calculation.tests.ts +++ b/web/src/test/auto/headless/engine/predictive-text/worker-thread/correction-search/segmentable-calculation.tests.ts @@ -65,6 +65,18 @@ describe('Split/merge aware edit-distance calculation', () => { ['a', 'b', 'c', 'd', 'f', 'gh'].forEach(c => calc = calc.addMatchChar(c)); assert.equal(calc.getFinalCost(), 4); + + const editPaths = calc.editPath(); + assert.equal(editPaths.length, 1); + assert.sameDeepOrderedMembers(editPaths[0], [ + { op: 'split', input: 'ab', match: 'a' }, + { op: 'split', input: 'ab', match: 'b' }, + { op: 'transpose-start', input: 'd', match: 'c' }, + { op: 'transpose-end', input: 'c', match: 'd' }, + { op: 'substitute', input: 'e', match: 'f' }, + { op: 'merge', input: 'g', match: 'gh' }, + { op: 'merge', input: 'h', match: 'gh' } + ]); }); it("abc,d,f,g,h -> a,b,c,d,fgh = 2", () => { @@ -75,5 +87,17 @@ describe('Split/merge aware edit-distance calculation', () => { ['a', 'b', 'c', 'd', 'fgh'].forEach(c => calc = calc.addMatchChar(c)); assert.equal(calc.getFinalCost(), 2); + + const editPaths = calc.editPath(); + assert.equal(editPaths.length, 1); + assert.sameDeepOrderedMembers(editPaths[0], [ + { op: 'split', input: 'abc', match: 'a' }, + { op: 'split', input: 'abc', match: 'b' }, + { op: 'split', input: 'abc', match: 'c' }, + { op: 'match', input: 'd', match: 'd' }, + { op: 'merge', input: 'f', match: 'fgh' }, + { op: 'merge', input: 'g', match: 'fgh' }, + { op: 'merge', input: 'h', match: 'fgh' } + ]); }); }); \ No newline at end of file