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.
This commit is contained in:
Joshua Horton 2025-09-04 13:10:21 -05:00
parent 01d73079a9
commit 0fbf9c028e
3 changed files with 85 additions and 9 deletions

View file

@ -120,7 +120,9 @@ export function visualizeCalculation<TUnit, TOpSet>(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<TUnit, TOpSet>(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!

View file

@ -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<string> {
public increaseMaxDistance(): ClassicalDistanceCalculation<string, ExtendedEditOperation> {
// 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<string, ExtendedEditOperation>): EditTuple<string, ExtendedEditOperation>[][] {
pathBuilder = pathBuilder ?? new PathBuilder<string, ExtendedEditOperation>(this, []);
pathBuilder.addEdgeFinder(findSplitMergeEdges);
super._buildPath(pathBuilder); // actually evaluates the edit-path.
return pathBuilder.validPaths;
}
}
function getMergeSplitParent<TOpSet> (
buffer: ClassicalDistanceCalculation<string, TOpSet>,
function getMergeSplitParent<TOpEdit> (
buffer: ClassicalDistanceCalculation<string, TOpEdit>,
r: number,
c: number
): [number, number] {
@ -135,4 +139,43 @@ function getMergeSplitParent<TOpSet> (
}
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<TOpSet>(
pathBuilder: PathBuilder<string, TOpSet | ExtendedEditOperation>,
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<string, ExtendedEditOperation>[] = [];
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<string, ExtendedEditOperation>[] = [];
for(let c = lastSplitIndex; c <= col; c++) {
ops.push({ input, match: calc.matchSequence[c], op: 'split' });
}
pathBuilder.backtracePath(row - 1, lastSplitIndex - 1, ops);
}
}

View file

@ -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' }
]);
});
});