From af94b3fdd7af453ec4f91faa3469f6386b062633 Mon Sep 17 00:00:00 2001 From: beam Date: Thu, 22 Oct 2026 18:48:06 +0000 Subject: [PATCH] beam: add LCS module with tests (length, reconstruction, diff) --- wizard-work/beam/lcs.py | 115 +++++++++++++++++++++++++++++++++++ wizard-work/beam/test_lcs.py | 62 +++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 wizard-work/beam/lcs.py create mode 100644 wizard-work/beam/test_lcs.py diff --git a/wizard-work/beam/lcs.py b/wizard-work/beam/lcs.py new file mode 100644 index 0000000..a666814 --- /dev/null +++ b/wizard-work/beam/lcs.py @@ -0,0 +1,115 @@ +"""Longest common subsequence (LCS) utilities. + +Provides: + - lcs_length(a, b): length of the LCS of two sequences. + - lcs(a, b): one LCS string/list (for hashable items). + - lcs_diff(a, b): minimal edit script as list of (op, item) where + op is 'keep' / 'delete' (in a only) / 'insert' (in b only). + +All inputs are treated as sequences of hashable items. Time O(|a|*|b|), +space O(min(|a|, |b|)) for length and O(|a|*|b|) for reconstruction. +""" + +from __future__ import annotations + +from typing import Sequence, TypeVar + +T = TypeVar("T") + + +def _validate(seq, name): + try: + iter(seq) + except TypeError as e: + raise TypeError(f"{name} must be iterable") from e + # reject strings? No—strings are sequences of chars, allowed. + + +def lcs_length(a: Sequence[T], b: Sequence[T]) -> int: + """Return length of longest common subsequence of a and b.""" + _validate(a, "a") + _validate(b, "b") + a = list(a) + b = list(b) + # ensure b is the shorter axis for space + if len(a) < len(b): + a, b = b, a + # now a longer (or equal), b shorter + prev = [0] * (len(b) + 1) + cur = [0] * (len(b) + 1) + for x in a: + for j, y in enumerate(b, 1): + if x == y: + cur[j] = prev[j - 1] + 1 + else: + cur[j] = prev[j] if prev[j] >= cur[j - 1] else cur[j - 1] + prev, cur = cur, [0] * (len(b) + 1) + return prev[len(b)] + + +def _lcs_table(a, b): + m, n = len(a), len(b) + # dp[i][j] = lcs length of a[:i], b[:j] + dp = [[0] * (n + 1) for _ in range(m + 1)] + for i in range(1, m + 1): + ai = a[i - 1] + row = dp[i] + prow = dp[i - 1] + for j in range(1, n + 1): + if ai == b[j - 1]: + row[j] = prow[j - 1] + 1 + else: + # max(prow[j], row[j-1]) + v = prow[j] + if row[j - 1] > v: + v = row[j - 1] + row[j] = v + return dp + + +def lcs(a: Sequence[T], b: Sequence[T]) -> list[T]: + """Return one LCS of a and b as a list (preserving item type via list).""" + _validate(a, "a") + _validate(b, "b") + a = list(a) + b = list(b) + dp = _lcs_table(a, b) + # backtrack + i, j = len(a), len(b) + out = [] + while i > 0 and j > 0: + if a[i - 1] == b[j - 1]: + out.append(a[i - 1]) + i -= 1 + j -= 1 + elif dp[i - 1][j] >= dp[i][j - 1]: + i -= 1 + else: + j -= 1 + out.reverse() + return out + + +def lcs_diff(a: Sequence[T], b: Sequence[T]) -> list[tuple[str, T]]: + """Minimal edit script turning a into b. Ops: keep/delete/insert.""" + _validate(a, "a") + _validate(b, "b") + a = list(a) + b = list(b) + dp = _lcs_table(a, b) + # walk to produce ops + i, j = len(a), len(b) + ops_rev = [] + while i > 0 or j > 0: + if i > 0 and j > 0 and a[i - 1] == b[j - 1] and dp[i][j] == dp[i - 1][j - 1] + 1: + ops_rev.append(("keep", a[i - 1])) + i -= 1 + j -= 1 + elif i > 0 and (j == 0 or dp[i - 1][j] >= dp[i][j - 1]): + ops_rev.append(("delete", a[i - 1])) + i -= 1 + else: + ops_rev.append(("insert", b[j - 1])) + j -= 1 + ops_rev.reverse() + return ops_rev diff --git a/wizard-work/beam/test_lcs.py b/wizard-work/beam/test_lcs.py new file mode 100644 index 0000000..92924ea --- /dev/null +++ b/wizard-work/beam/test_lcs.py @@ -0,0 +1,62 @@ +import sys +sys.path.insert(0, "/root/.hermes/wizard-outputs/beam") +from lcs import lcs_length, lcs, lcs_diff + + +def test_empty(): + assert lcs_length([], []) == 0 + assert lcs([], [1,2]) == [] + assert lcs([1,2], []) == [] + + +def test_identical(): + s = [1,2,3,4] + assert lcs_length(s, s) == 4 + assert lcs(s, s) == s + + +def test_classic(): + a = "ABCBDAB" + b = "BDCAB" + assert lcs_length(a, b) == 4 + seq = lcs(a, b) + assert len(seq) == 4 + # verify it's a subsequence of both + def subseq(x, y): + it = iter(y) + return all(c in it for c in x) + assert subseq(seq, a) and subseq(seq, b) + + +def test_no_common(): + assert lcs_length("abc", "xyz") == 0 + assert lcs("abc", "xyz") == [] + + +def test_diff_roundtrip(): + a = [1, 2, 3, 4, 5] + b = [2, 4, 6] + ops = lcs_diff(a, b) + # reconstruct b from a applying ops + out = [] + for op, item in ops: + if op in ("keep", "insert"): + out.append(item) + # delete: skip + assert out == b + # deletes+keeps should cover a + covered = [item for op, item in ops if op in ("keep", "delete")] + assert covered == a + + +def test_strings(): + assert lcs("kitten", "sitting") == list("ittn") or len(lcs("kitten","sitting"))==4 + assert lcs_length("kitten", "sitting") == 4 + + +def test_type_error(): + try: + lcs_length(5, [1]) + assert False, "expected TypeError" + except TypeError: + pass