Repository: classickit (difficulty: medium)

Issue:
osa_distance never credits transpositions; it behaves exactly like plain Levenshtein. Observed: osa_distance('ab', 'ba') returns 2, expected 1, since swapping two adjacent characters is a single operation. osa_distance('a cat', 'an act') returns 3, expected 2 (insert 'n', then transpose the adjacent 'c' and 'a'). In fact any pair of strings that differ by one adjacent swap of two distinct characters comes back as 2 instead of 1, and on every input I tried the result equals the plain Levenshtein distance, which the docstring says OSA must never exceed and should beat when a transposition helps. Cases not involving a transposition look right: osa_distance('ca', 'abc') is correctly 3 (OSA may not re-edit a transposed pair), identical strings give 0, and one empty side gives the other side's length.

Source files:
core.py: """Optimal string alignment (OSA) distance.

osa_distance(a, b) returns the minimum number of operations turning the
string a into the string b, where one operation inserts a character,
deletes a character, substitutes a character, or transposes two ADJACENT
characters. Unlike unrestricted Damerau-Levenshtein, OSA never edits a
substring twice, so osa_distance("ab", "ba") is 1 (a single
transposition) while osa_distance("ca", "abc") is 3 (the transposed pair
may not be edited again). The distance is 0 exactly for equal strings,
against the empty string it equals the other string's length, and it
never exceeds the plain Levenshtein distance (the same dynamic program
without the transposition rule).
"""


def osa_distance(a, b):
    """Return the optimal string alignment distance between a and b."""
    m, n = len(a), len(b)
    d = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(m + 1):
        d[i][0] = i
    for j in range(n + 1):
        d[0][j] = j
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            cost = 0 if a[i - 1] == b[j - 1] else 1
            d[i][j] = min(
                d[i - 1][j] + 1,
                d[i][j - 1] + 1,
                d[i - 1][j - 1] + cost,
            )
            if i > 1 and j > 1 and a[i - 1] == b[j - 1] and a[i - 2] == b[j - 2]:
                d[i][j] = min(d[i][j], d[i - 2][j - 2] + 1)
    return d[m][n]


Test files:
test_core.py: import random

from core import osa_distance


def levenshtein(a, b):
    """Plain Levenshtein DP (no transpositions), used as an upper bound."""
    m, n = len(a), len(b)
    d = [[0] * (n + 1) for _ in range(m + 1)]
    for i in range(m + 1):
        d[i][0] = i
    for j in range(n + 1):
        d[0][j] = j
    for i in range(1, m + 1):
        for j in range(1, n + 1):
            cost = 0 if a[i - 1] == b[j - 1] else 1
            d[i][j] = min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + cost)
    return d[m][n]


def test_pinned_classics():
    assert osa_distance("ab", "ba") == 1
    assert osa_distance("ca", "abc") == 3
    assert osa_distance("a cat", "an act") == 2
    assert osa_distance("", "") == 0
    assert osa_distance("same", "same") == 0
    assert osa_distance("", "abc") == 3
    assert osa_distance("xyz", "") == 3


def test_property_vs_levenshtein():
    rng = random.Random(20260)
    for _ in range(400):
        a = "".join(rng.choice("abc") for _ in range(rng.randint(0, 9)))
        b = "".join(rng.choice("abc") for _ in range(rng.randint(0, 9)))
        d = osa_distance(a, b)
        assert d <= levenshtein(a, b)
        if a == b:
            assert d == 0
        else:
            assert d >= 1
    rng = random.Random(20261)
    produced = 0
    while produced < 30:
        s = "".join(rng.choice("abcde") for _ in range(rng.randint(2, 10)))
        spots = [p for p in range(len(s) - 1) if s[p] != s[p + 1]]
        if not spots:
            continue
        p = rng.choice(spots)
        t = s[:p] + s[p + 1] + s[p] + s[p + 2:]
        produced += 1
        assert levenshtein(s, t) == 2
        assert osa_distance(s, t) == 1


Patch:
--- a/core.py
+++ b/core.py
@@ -29,6 +29,6 @@
                 d[i][j - 1] + 1,
                 d[i - 1][j - 1] + cost,
             )
-            if i > 1 and j > 1 and a[i - 1] == b[j - 1] and a[i - 2] == b[j - 2]:
+            if i > 1 and j > 1 and a[i - 1] == b[j - 2] and a[i - 2] == b[j - 1]:
                 d[i][j] = min(d[i][j], d[i - 2][j - 2] + 1)
     return d[m][n]