feat(web): determine all equal-cost edit-paths & rank them by quality

After digging through repo history, I couldn't find an actual reference for .editPath(), the method producing a sequence of edits represented by the edit-distance calculation.

After further thought and investigation, I realized that there can be more than one possible edit sequence... and that the best approach for future stability is to calculate and return _all_ of them.  The trick is to rank them based on the qualities we most look for - and for predictive-text's sliding context window, what we want is a long, unbroken sequence of 'match'-ed tokens, followed by substitutions on the edges.

Previously, I'd finagled the editPath() function to check certain edit types before others, but it always felt hacky and brittle.  This new approach seems like a much more robust way forward, even if it probably is a bit more costly to perform.
This commit is contained in:
Joshua Horton 2025-08-27 14:20:57 -05:00
parent f409e5ff13
commit e5a816d7bc
5 changed files with 453 additions and 142 deletions

View file

@ -121,7 +121,7 @@ export function isSubstitutionAlignable(
// Use max length in case the word is actually already partly out of
// the sliding context window.
Math.max(incomingToken.length, matchingToken.length)
).editPath();
).editPath()[0].map(t => t.op);
const firstInsert = subEditPath.indexOf('insert');
const firstDelete = subEditPath.indexOf('delete');
@ -236,7 +236,7 @@ export function computeAlignment(
3
);
let editPath = mapping.editPath();
let editPath = mapping.editPath()[0].map(t => t.op);
const failure: ContextStateAlignment = {
canAlign: false,

View file

@ -1,7 +1,23 @@
import { SENTINEL_CODE_UNIT } from '@keymanapp/models-templates';
/**
* The human-readable names for legal edit-operation edges on the edit path.
*/
export type EditOperation = 'insert' | 'delete' | 'match' | 'substitute' | 'transpose-start' | 'transpose-end' | 'transpose-insert' | 'transpose-delete';
/**
* Represents individual nodes on calculated edit paths and the relevant
* edited value(s) at each step.
*/
export interface EditTuple<TUnit> {
/** The edit operation taking place at this position in the edit path */
op: 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 */
match?: TUnit
}
/**
* Represents the lowest-level unit for comparison during edit-distance calculations.
*/
@ -11,8 +27,41 @@ export interface EditToken<TUnit> {
// Implemented externally from the class so that it may be more easily
// disconnected from the class; it's not the smallest method.
export function visualizeCalculation<TUnit>(calc: ClassicalDistanceCalculation<TUnit>) {
const path = calc.editPath();
/**
* Produces a visualization of a ClassicalDistanceCalculation instance,
* displaying its calculated values, how they align to the input and match
* sources, and what steps were taken on the 'best' edit path to align the input
* and match sources.
*
* Example case:
*
* - Input sequence: ['quick', 'brown', 'fox', 'jumped', 'ove']
* - Match sequence: ['uick', 'brown', 'fox', 'jumped', 'over', 't']
*
* ```
* j
* b u
* ur mo
* iofpv
* cwoee
* knxdrt
*
* 0|123
* --------
* quick 1|1234 substitute('quick' => 'uick')
* brown 2|21234 match('brown')
* fox 3|321234 match('fox')
* jumped |432123 match('jumped')
* ove | 43223 substitute('ove' => 'over'), insert('t')
* ```
*
* @param calc The distance calculation object to visualize
* @param path If set, uses this path instead of the 'best' edit path in the
* visualization
* @returns
*/
export function visualizeCalculation<TUnit>(calc: ClassicalDistanceCalculation<TUnit>, path?: EditTuple<TUnit>[]) {
path = (path ?? calc.editPath()[0]).slice();
const inputs = calc.inputSequence.map(i => '' + i.key);
const matches = calc.matchSequence.map(i => '' + i.key);
@ -39,11 +88,6 @@ export function visualizeCalculation<TUnit>(calc: ClassicalDistanceCalculation<T
rowStrs.push(topRow);
rowStrs.push(noInputPadding + '-'.repeat(matches.length + 2));
// Used for looking up the original input and match entries at each stage
// in the visualized path
let inputIndex = 0;
let matchIndex = 0;
// Main visualization loop
for(let i=0; i < inputs.length; i++) {
let needRowPadding = true;
@ -70,34 +114,27 @@ export function visualizeCalculation<TUnit>(calc: ClassicalDistanceCalculation<T
rowStr += " ".repeat(sparseCount > 0 ? sparseCount : 1);
let edits: string[] = [];
const printEdit = (edit: EditOperation) => {
const printEdit = (edit: EditTuple<TUnit>) => {
let tokenText: string;
switch(edit) {
switch(edit.op) {
case 'delete':
case 'transpose-delete':
tokenText = `'${inputs[inputIndex]}'`;
inputIndex++;
tokenText = `'${edit.input}'`;
break;
case 'insert':
case 'transpose-insert':
tokenText = `'${matches[matchIndex]}'`;
matchIndex++;
tokenText = `'${edit.match}'`;
break;
case 'substitute':
tokenText = `'${inputs[inputIndex]}' => '${matches[matchIndex]}'`;
// fallthrough
case 'match':
tokenText = tokenText || `'${inputs[inputIndex]}'`;
inputIndex++;
matchIndex++;
const op = edit.op == 'substitute' ? '=>' : '==';
tokenText = tokenText || `'${edit.input}' ${op} '${edit.match}'`;
break;
// transpose-start, transpose-end: may not actually be correct, though.
// transpose-start, transpose-end
default:
tokenText = `'${inputs[inputIndex]}'`;
inputIndex++;
matchIndex++;
tokenText = `'${edit.input}' vs '${edit.match}'`;
}
return `${edit}(${tokenText})`;
return `${edit.op}(${tokenText})`;
}
do {
edits.push(printEdit(path.shift()));
@ -116,20 +153,28 @@ export function visualizeCalculation<TUnit>(calc: ClassicalDistanceCalculation<T
return '\n' + rowStrs.join('\n');
}
// A semi-optimized 'online'/iterative Damerau-Levenshtein calculator with the following features:
// - may add new character to the 'input' string or to the 'match' string, reusing all old calculations efficiently.
// - allows a 'focused' evaluation that seeks if the edit distance is within a specific range. Designed for use in match-searching,
// where we want to find the 'closest' matching strings in a lexicon.
// - towards such a match-searching algorithm/heuristic: should nothing be found within that range, all prior calculations may be reused
// to search across the lexicon with an incremented edit distance.
// - minimized memory footprint: O(m) memory footprint (where m = length of 'input' string), rather than O(mn) (where n = length of 'match' string)
// - guaranteed to use a smaller footprint than DiagonalizedIterativeDamerauLevenshteinCalculation.
//
// In short: Used to optimize calculations for low edit-distance checks, then expanded if/as necessary
// if a greater edit distance is requested.
//
// Reference: https://en.wikipedia.org/wiki/Wagner%E2%80%93Fischer_algorithm#Possible_modifications
// - Motivating statement: "if we are only interested in the distance if it is smaller than a threshold..."
/**
* A semi-optimized 'online'/iterative Damerau-Levenshtein calculator with the
* following features:
* - may add new character to the 'input' string or to the 'match' string,
* reusing all old calculations efficiently.
* - allows a 'focused' evaluation that seeks if the edit distance is within a
* specific range. Designed for use in match-searching, where we want to find
* the 'closest' matching strings in a lexicon.
* - towards such a match-searching algorithm/heuristic: should nothing be found
* within that range, all prior calculations may be reused to search across
* the lexicon with an incremented edit distance.
* - minimized memory footprint: O(m) memory footprint (where m = length of
* 'input' string), rather than O(mn) (where n = length of 'match' string)
*
* In short: Used to optimize calculations for low edit-distance checks, then
* expanded if/as necessary if a greater edit distance is requested.
*
* Reference:
* https://en.wikipedia.org/wiki/Wagner%E2%80%93Fischer_algorithm#Possible_modifications
* - Motivating statement: "if we are only interested in the distance if it
* is smaller than a threshold..."
*/
export class ClassicalDistanceCalculation<TUnit = string, TInput extends EditToken<TUnit> = EditToken<TUnit>, TMatch extends EditToken<TUnit> = EditToken<TUnit>> {
/**
* Stores ONLY the computed diagonal elements, nothing else.
@ -167,7 +212,14 @@ export class ClassicalDistanceCalculation<TUnit = string, TInput extends EditTok
inputSequence: TInput[] = [];
matchSequence: TMatch[] = [];
/**
* Constructs a new calculation object instance.
*/
constructor();
/**
* Clones an already-existing instance, aliasing old data only where safe.
* @param other
*/
constructor(other: ClassicalDistanceCalculation<TUnit, TInput, TMatch>);
constructor(other?: ClassicalDistanceCalculation<TUnit, TInput, TMatch>) {
if(other) {
@ -275,75 +327,207 @@ export class ClassicalDistanceCalculation<TUnit = string, TInput extends EditTok
/**
* Determines the edit path used to obtain the optimal cost, distinguishing between zero-cost
* substitutions ('match' operations) and actual substitutions.
*
* If multiple edit paths are valid, this method will also sort them in a manner favoring, in order:
* - long, contiguous stretches of 'match' entries
* - long, contiguous stretches of 'match' + 'substitute' entries
* - long, contiguous stretches of 'match' + 'substitute' + 'transpose' entries
* @param row
* @param col
*/
public editPath(row: number = this.inputSequence.length - 1, col: number = this.matchSequence.length - 1): EditOperation[] {
let currentCost = this.getCostAt(row, col);
let ops: EditOperation[] = null;
let parent: [number, number] = null;
public editPath(): EditTuple<TUnit>[][] {
const results = this._editPath();
let insertParentCost = this.getCostAt(row, col-1);
let deleteParentCost = this.getCostAt(row-1, col);
let substitutionParentCost = this.getCostAt(row-1, col-1);
let [lastInputIndex, lastMatchIndex] = ClassicalDistanceCalculation.getTransposeParent(this, row, col);
if(results.length <= 1) {
return results;
}
const properties = results.map((result) => {
let maxM = 0; // includes matches
let maxMS = 0; // includes above + substitutions
let maxMST = 0; // includes above + transposes
let m = 0; // same as above, but running counter
let ms = 0;
let mst = 0;
for(let edit of result) {
if(edit.op == 'match') {
m++;
ms++;
mst++;
continue;
}
maxM = Math.max(maxM, m);
m = 0;
if(edit.op == 'substitute') {
ms++;
mst++;
continue;
}
maxMS = Math.max(maxMS, ms);
ms = 0;
if(edit.op.indexOf('transpose') > -1) {
mst++;
continue;
}
maxMST = Math.max(maxMST, mst);
mst = 0;
}
maxM = Math.max(maxM, m);
maxMS = Math.max(maxMS, ms);
return {
result,
maxM,
maxMS,
maxMST
}
});
properties.sort((a, b) => {
const tier0 = b.maxM - a.maxM;
if(tier0 != 0) {
return tier0;
}
const tier1 = b.maxMS - a.maxMS;
if(tier1 != 0) {
return tier1;
}
return b.maxMST - a.maxMST;
});
return properties.map((p) => p.result);
};
/**
* Determines the edit path used to obtain the optimal cost, distinguishing between zero-cost
* substitutions ('match' operations) and actual substitutions.
* @param row
* @param col
*/
private _editPath(
row: number = this.inputSequence.length - 1,
col: number = this.matchSequence.length - 1
): EditTuple<TUnit>[][] {
const currentCost = this.getCostAt(row, col);
const validPaths: EditTuple<TUnit>[][] = [];
const tryPath = (row: number, col: number, ops: EditTuple<TUnit>[]) => {
// Recursively build the edit path.
let results: EditTuple<TUnit>[][];
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].key
});
}
for(let c = 0; c <= col; c++) {
// There are initial insertions.
result.push({
op: 'insert',
match: this.matchSequence[c].key
});
}
}
// 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.
ops = ['transpose-start']; // always needs a 'start'.
let i = row;
let m = col;
let ops: EditTuple<TUnit>[] = [];
if(lastInputIndex != row-1) {
let count = row - lastInputIndex - 1;
ops = ops.concat( Array(count).fill('transpose-delete') );
expectedCost += count;
let count = row - lastInputIndex;
ops.push({
op: 'transpose-start',
input: this.inputSequence[i-count]?.key,
match: this.matchSequence[lastMatchIndex]?.key
});
for(let x=count-1; x > 0; x--) {
ops.push({
op: 'transpose-delete',
input: this.inputSequence[i-x]?.key
});
}
expectedCost += count-1;
} else {
let count = col - lastMatchIndex - 1;
ops = ops.concat( Array(count).fill('transpose-insert') );
expectedCost += count;
let count = col - lastMatchIndex;
ops.push({
op: 'transpose-start',
input: this.inputSequence[lastInputIndex]?.key,
match: this.matchSequence[m-count]?.key
});
for(let y=count-1; y > 0; y--) {
ops.push({
op: 'transpose-insert',
match: this.matchSequence[m-y]?.key
});
}
expectedCost += count - 1;
}
ops.push('transpose-end');
ops.push({
op: 'transpose-end',
input: this.inputSequence[i]?.key,
match: this.matchSequence[m]?.key
});
// Double-check our expectations.
if(this.getCostAt(lastInputIndex-1, lastMatchIndex-1) != currentCost - expectedCost) {
ops = null;
}
parent = [lastInputIndex-1, lastMatchIndex-1];
}
if(ops) {
// bypass the ladder.
} else if(insertParentCost == currentCost - 1) {
// check 'insert' BEFORE 'substitute' - we categorize the path in reverse,
// and we want 'insert's to appear after 'substitute's at the path's tail,
// the part that matters most.
ops = ['insert'];
parent = [row, col-1];
} else if(substitutionParentCost == currentCost - 1) {
ops = ['substitute'];
parent = [row-1, col-1];
} else if(deleteParentCost == currentCost - 1) {
ops = ['delete'];
parent = [row-1, col];
} else { //if(substitutionParentCost == currentCost) {
ops = ['match'];
parent = [row-1, col-1];
}
// Recursively build the edit path.
if(parent[0] >= 0 && parent[1] >= 0) {
return this.editPath(parent[0], parent[1]).concat(ops);
} else {
if(parent[0] > -1) {
// There are initial deletions.
return Array(parent[0]+1).fill('delete').concat(ops);
} else if(parent[1] > -1) {
// There are initial insertions.
return Array(parent[1]+1).fill('insert').concat(ops);
} else {
return ops;
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]?.key;
const match = this.matchSequence[col]?.key;
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;
}
private static getTransposeParent<TUnit, TInput extends EditToken<TUnit>, TMatch extends EditToken<TUnit>>(
@ -588,11 +772,12 @@ export class ClassicalDistanceCalculation<TUnit = string, TInput extends EditTok
}
private static propagateUpdateFrom<TUnit, TInput extends EditToken<TUnit>, TMatch extends EditToken<TUnit>>(
buffer: ClassicalDistanceCalculation<TUnit, TInput, TMatch>,
r: number,
c: number,
value: number,
diagonalIndex: number) {
buffer: ClassicalDistanceCalculation<TUnit, TInput, TMatch>,
r: number,
c: number,
value: number,
diagonalIndex: number
) {
// Note: this function does not actually need the `c` parameter!
// That said, it's very useful when tracing stack traces & debugging.
if(value < buffer.resolvedDistances[r][diagonalIndex]) {
@ -680,7 +865,18 @@ export class ClassicalDistanceCalculation<TUnit = string, TInput extends EditTok
return buffer;
}
// public get visualization() {
// return visualizeCalculation(this);
// /**
// * Produces a string-based visualization of this instance, the edit-distance
// * calculation it represents, and the corresponding best-ranked edit path.
// *
// * This property should never be included within production builds, as it is
// * not used there and is not a small function. The helper function it calls
// * is safe thanks to tree-shaking.
// *
// * @param path
// * @returns
// */
// public visualize(path?: EditTuple<TUnit>[]) {
// return visualizeCalculation(this, path);
// }
}

View file

@ -212,6 +212,9 @@ export class ContextState {
);
if(!resultTokenization) {
if(console && console.error) {
console.error(`Transition to alignable tokenization failed: alignment properties ${JSON.stringify(alignmentResults)}`);
}
return null;
}

View file

@ -1,10 +1,9 @@
export { ClassicalDistanceCalculation } from './correction/classical-calculation.js';
export { ClassicalDistanceCalculation, EditOperation, EditTuple } from './correction/classical-calculation.js';
export { ContextState } from './correction/context-state.js';
export { ContextToken } from './correction/context-token.js';
export { ContextTokenization } from './correction/context-tokenization.js';
export { ContextTracker } from './correction/context-tracker.js';
export { ContextTransition } from './correction/context-transition.js';
export { EditOperation } from './correction/classical-calculation.js';
export * from './correction/alignment-helpers.js';
export * as correction from './correction/index.js';
export * from './model-helpers.js';

View file

@ -1,5 +1,5 @@
import { assert } from 'chai';
import { ClassicalDistanceCalculation } from '@keymanapp/lm-worker/test-index';
import { ClassicalDistanceCalculation, EditTuple } from '@keymanapp/lm-worker/test-index';
// Very useful for diagnosing unit test issues when path inspection is needed.
// Not currently used ('cause that's noisy during test runs).
@ -459,86 +459,199 @@ describe('Classical Damerau-Levenshtein edit-distance calculation', function() {
});
describe("edit path construction", function() {
let op = {
s: 'substitute',
m: 'match',
i: 'insert',
d: 'delete',
ts: 'transpose-start',
te: 'transpose-end',
ti: 'transpose-insert',
td: 'transpose-delete'
}
it("'accomodate' -> 'accommodate'", function() {
let buffer = compute("accomodate", "accommodate", "InputThenMatch");
let editSequence = [
// a c c o m
op.m, op.m, op.m, op.m, op.m,
// +m o d a t e
op.i, op.m, op.m, op.m, op.m, op.m
];
// While the two 'm's are interchangable, the path will favor
// a result with the longest contiguous set of matches.
let editSequences: EditTuple<string>[][] = [[
{ op: 'match', input: 'a', match: 'a'},
{ op: 'match', input: 'c', match: 'c'},
{ op: 'match', input: 'c', match: 'c'},
{ op: 'match', input: 'o', match: 'o'},
{ op: 'insert', match: 'm'}, // insert is earlier
{ op: 'match', input: 'm', match: 'm'}, // longer contiguous match sequence
{ op: 'match', input: 'o', match: 'o'},
{ op: 'match', input: 'd', match: 'd'},
{ op: 'match', input: 'a', match: 'a'},
{ op: 'match', input: 't', match: 't'},
{ op: 'match', input: 'e', match: 'e'},
], [
{ op: 'match', input: 'a', match: 'a'},
{ op: 'match', input: 'c', match: 'c'},
{ op: 'match', input: 'c', match: 'c'},
{ op: 'match', input: 'o', match: 'o'},
{ op: 'match', input: 'm', match: 'm'},
{ op: 'insert', match: 'm'}, // now inserting later.
{ op: 'match', input: 'o', match: 'o'},
{ op: 'match', input: 'd', match: 'd'},
{ op: 'match', input: 'a', match: 'a'},
{ op: 'match', input: 't', match: 't'},
{ op: 'match', input: 'e', match: 'e'},
]];
assert.deepEqual(buffer.editPath(), editSequence);
assert.sameDeepOrderedMembers(buffer.editPath(), editSequences);
});
it("'harras' -> 'harass'", function() {
let buffer = compute("harras", "harass", "InputThenMatch");
let editSequence = [
// h a r -r a s +s
op.m, op.m, op.m, op.d, op.m, op.m, op.i
let bestEditSequence: EditTuple<string>[] = [
{ op: 'match', input: 'h', match: 'h'},
{ op: 'match', input: 'a', match: 'a'},
{ op: 'match', input: 'r', match: 'r'},
{ op: 'substitute', input: 'r', match: 'a'},
{ op: 'substitute', input: 'a', match: 's'},
{ op: 'match', input: 's', match: 's'}
];
assert.deepEqual(buffer.editPath(), editSequence);
let altSequence: EditTuple<string>[] = [
{ op: 'match', input: 'h', match: 'h'},
{ op: 'match', input: 'a', match: 'a'},
{ op: 'match', input: 'r', match: 'r'},
{ op: 'delete', input: 'r'},
{ op: 'match', input: 'a', match: 'a'},
{ op: 'match', input: 's', match: 's'},
{ op: 'insert', match: 's'}
];
const viablePaths = buffer.editPath();
// Note that the position of the 'delete' and the 'insert' in
// `altSequence` may be repositioned +1 way each, making 2x2 = 4 ways in
// total... then +1 for the substitute approach.
assert.equal(viablePaths.length, 5);
assert.deepEqual(viablePaths[0], bestEditSequence);
assert.includeDeepMembers(viablePaths, [altSequence]);
});
it("'access' -> 'assess'", function() {
let buffer = compute("access", "assess", "InputThenMatch");
let editSequence = [
// a c/s c/s e s s
op.m, op.s, op.s, op.m, op.m, op.m
let editSequence: EditTuple<string>[] = [
{ op: 'match', input: 'a', match: 'a'},
{ op: 'substitute', input: 'c', match: 's'},
{ op: 'substitute', input: 'c', match: 's'},
{ op: 'match', input: 'e', match: 'e'},
{ op: 'match', input: 's', match: 's'},
{ op: 'match', input: 's', match: 's'}
];
assert.deepEqual(buffer.editPath(), editSequence);
const viablePaths = buffer.editPath();
assert.equal(viablePaths.length, 1);
assert.deepEqual(viablePaths[0], editSequence);
});
it("'ifhs' -> 'fish'", function() {
let buffer = compute("ifhs", "fish");
let editSequence = [
op.ts, op.te, op.ts, op.te
let editSequence: EditTuple<string>[] = [
{ op: 'transpose-start', input: 'i', match: 'f'},
{ op: 'transpose-end', input: 'f', match: 'i'},
{ op: 'transpose-start', input: 'h', match: 's'},
{ op: 'transpose-end', input: 's', match: 'h'}
];
assert.deepEqual(buffer.editPath(), editSequence);
const viablePaths = buffer.editPath();
assert.equal(viablePaths.length, 1);
assert.deepEqual(viablePaths[0], editSequence);
});
it("'then' -> 'their'", function() {
let buffer = compute("then", "their");
let editSequence = [
op.m, op.m, op.m, op.s, op.i
let editSequence: EditTuple<string>[] = [
{ op: 'match', input: 't', match: 't'},
{ op: 'match', input: 'h', match: 'h'}, // best sequence: continguous matches
{ op: 'match', input: 'e', match: 'e'}, // + adjacent substitutes
{ op: 'substitute', input: 'n', match: 'i'},
{ op: 'insert', match: 'r'},
];
assert.deepEqual(buffer.editPath(), editSequence);
let altSequence: EditTuple<string>[] = [
{ op: 'match', input: 't', match: 't'},
{ op: 'match', input: 'h', match: 'h'},
{ op: 'match', input: 'e', match: 'e'},
{ op: 'insert', match: 'i'},
{ op: 'substitute', input: 'n', match: 'r'},
];
const viablePaths = buffer.editPath();
assert.equal(viablePaths.length, 2);
assert.deepEqual(viablePaths[0], editSequence);
assert.deepEqual(viablePaths[1], altSequence);
});
// Two transpositions: abc -> ca, ig <- ghi. Also, one deletion: 'd'.
it("'abczdefig' -> 'cazefghi'", function() {
let buffer = compute("abczdefig", "cazefghi", "InputThenMatch", 2);
let editSequence = [
// --- abc -> ca ----
// a/c b/_ c/a z -d e f
op.ts, op.td, op.te, op.m, op.d, op.m, op.m,
// --- ig -> ghi ----
// i/g _/h g/i
op.ts, op.ti, op.te
let editSequence: EditTuple<string>[] = [
{ op: 'transpose-start', input: 'a', match: 'c'},
{ op: 'transpose-delete', input: 'b'},
{ op: 'transpose-end', input: 'c', match: 'a'},
{ op: 'match', input: 'z', match: 'z'},
{ op: 'delete', input: 'd'},
{ op: 'match', input: 'e', match: 'e'},
{ op: 'match', input: 'f', match: 'f'},
{ op: 'transpose-start', input: 'i', match: 'g'},
{ op: 'transpose-insert', match: 'h'},
{ op: 'transpose-end', input: 'g', match: 'i'}
];
assert.deepEqual(buffer.editPath(), editSequence);
const viablePaths = buffer.editPath();
assert.equal(viablePaths.length, 1);
assert.deepEqual(viablePaths[0], editSequence);
});
it('works well for whitespaced-context analogues', () => {
// Corresponds closely to an actual sliding-context window scenario that
// once occurred. I did tweak the end ('r' was actually a 'p') for a bit
// more rigor.
const src = "a b c d?;e f g, h i j k l m n o p"
const dst = "q c d?;e f g, h i j k l m n o r"
const buffer = compute(src, dst, "InputThenMatch", 4);
let editSequence: EditTuple<string>[] = [
{ op: 'delete', input: 'a'},
{ op: 'delete', input: ' '},
{ op: 'substitute', input: 'b', match: 'q'},
{ op: 'match', input: ' ', match: ' '},
{ op: 'match', input: 'c', match: 'c'},
{ op: 'match', input: ' ', match: ' '},
{ op: 'match', input: 'd', match: 'd'},
{ op: 'match', input: '?', match: '?'},
{ op: 'match', input: ';', match: ';'},
{ op: 'match', input: 'e', match: 'e'},
{ op: 'match', input: ' ', match: ' '},
{ op: 'match', input: 'f', match: 'f'},
{ op: 'match', input: ' ', match: ' '},
{ op: 'match', input: 'g', match: 'g'},
{ op: 'match', input: ',', match: ','},
{ op: 'match', input: ' ', match: ' '},
{ op: 'match', input: 'h', match: 'h'},
{ op: 'match', input: ' ', match: ' '},
{ op: 'match', input: 'i', match: 'i'},
{ op: 'match', input: ' ', match: ' '},
{ op: 'match', input: 'j', match: 'j'},
{ op: 'match', input: ' ', match: ' '},
{ op: 'match', input: 'k', match: 'k'},
{ op: 'match', input: ' ', match: ' '},
{ op: 'match', input: 'l', match: 'l'},
{ op: 'match', input: ' ', match: ' '},
{ op: 'match', input: 'm', match: 'm'},
{ op: 'match', input: ' ', match: ' '},
{ op: 'match', input: 'n', match: 'n'},
{ op: 'match', input: ' ', match: ' '},
{ op: 'match', input: 'o', match: 'o'},
{ op: 'match', input: ' ', match: ' '},
{ op: 'substitute', input: 'p', match: 'r'},
];
const viablePaths = buffer.editPath();
// There are alternate variations for the first four tokens, but only one
// puts the substitute next to the long stretch of matches.
assert.equal(viablePaths.length, 4);
assert.deepEqual(viablePaths[0], editSequence);
});
})
});