L* algorithm in Python
Learns a DFA from membership and equivalence queries using an observation table.
- File
lstar_algorithm.py, 97 lines- Level
- I0 not checked yet
- Toolchain
- Python 3.14, pytest 9
- Test framework
- pytest
- Run the tests
python3 -m pytest
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/lstar-algorithm/. The authors are listed in prov/2BJ5ME.jsonl.
from collections.abc import Callable
Word = tuple[int, ...]
DFA = tuple[list[list[int]], int, set[int]]
def _fill(
table: dict[Word, bool],
member: Callable[[Word], bool],
prefixes: list[Word],
suffixes: list[Word],
sigma: int,
) -> None:
"""Ask a membership query for each string of (S + S.Sigma).E that is not yet known."""
for s in prefixes:
for x in [s] + [s + (a,) for a in range(sigma)]:
for e in suffixes:
w = x + e
if w not in table:
table[w] = member(w)
def _row(table: dict[Word, bool], s: Word, suffixes: list[Word]) -> tuple[bool, ...]:
return tuple(table[s + e] for e in suffixes)
def lstar(
sigma: int,
member: Callable[[Word], bool],
equivalent: Callable[[DFA], Word | None],
) -> DFA:
"""Learn the minimal complete DFA of an unknown regular language over symbols 0..sigma-1.
member(w) answers whether w is in the language. equivalent(H) returns None if the
DFA H = (delta, start, finals) accepts the language, and otherwise a string on which
H is wrong. With n states in the minimal DFA and counterexamples of length at most
m, it asks at most n equivalence queries and at most (sigma + 1)(n + m(n - 1))n
distinct membership queries, and runs in O(sigma m n^3 (m + n)) time besides the
queries.
"""
S: list[Word] = [()] # @line L1
E: list[Word] = [()]
in_s: set[Word] = {()}
table: dict[Word, bool] = {}
_fill(table, member, S, E, sigma)
while True: # @line L2
rep: dict[tuple[bool, ...], Word] = {}
for s in S:
rep.setdefault(_row(table, s, E), s)
# consistency: strings of S with equal rows must have equal rows after each symbol
new_suffix: Word | None = None
for s in S: # @line L3
r = rep[_row(table, s, E)]
if r == s:
continue
for a in range(sigma):
for e in E:
if table[s + (a,) + e] != table[r + (a,) + e]:
new_suffix = (a,) + e
break
if new_suffix is not None:
break
if new_suffix is not None:
break
if new_suffix is not None:
E.append(new_suffix) # @line L4
_fill(table, member, S, [new_suffix], sigma)
continue
# closedness: the row of each one-symbol extension must be a row of S
gap: Word | None = None
for s in S: # @line L5
for a in range(sigma):
if _row(table, s + (a,), E) not in rep:
gap = s + (a,)
break
if gap is not None:
break
if gap is not None:
S.append(gap) # @line L6
in_s.add(gap)
_fill(table, member, [gap], E, sigma)
continue
rows = list(rep) # @line L7
index = {row: i for i, row in enumerate(rows)}
delta = [[index[_row(table, rep[row] + (a,), E)] for a in range(sigma)] for row in rows]
finals = {i for i, row in enumerate(rows) if row[0]}
hypothesis: DFA = (delta, index[_row(table, (), E)], finals)
c = equivalent(hypothesis) # @line L8
if c is None: # @line L9
return hypothesis
added = [c[:k] for k in range(len(c) + 1) if c[:k] not in in_s] # @line L10
S.extend(added)
in_s.update(added)
_fill(table, member, added, E, sigma)
test_lstar_algorithm.py 174 lines
# SPDX-License-Identifier: MIT
# Part of https://algorithm.tamnd.com/lstar-algorithm/. The authors are listed in prov/2BJ5ME.jsonl.
import itertools
import random
from collections import deque
from lstar_algorithm import lstar
def accepts(dfa, word) -> bool:
delta, q, finals = dfa
for a in word:
q = delta[q][a]
return q in finals
def words(sigma: int, max_len: int):
for n in range(max_len + 1):
yield from itertools.product(range(sigma), repeat=n)
def shortest_difference(target, hyp, sigma):
# breadth-first search of the product; returns a shortest string accepted by exactly one
start = (target[1], hyp[1])
parent = {start: None}
queue = deque([start])
while queue:
p, q = queue.popleft()
if (p in target[2]) != (q in hyp[2]):
word = []
node = (p, q)
while parent[node] is not None:
node, a = parent[node]
word.append(a)
return tuple(reversed(word))
for a in range(sigma):
nxt = (target[0][p][a], hyp[0][q][a])
if nxt not in parent:
parent[nxt] = ((p, q), a)
queue.append(nxt)
return None
class Teacher:
"""Answers queries about a target DFA and counts them."""
def __init__(self, target, sigma, rng=None, max_pad=0):
self.target, self.sigma, self.rng, self.max_pad = target, sigma, rng, max_pad
self.mq = 0
self.eq = 0
self.asked = set()
self.sizes = []
self.longest = 0
def member(self, w):
assert w not in self.asked, "a membership query was repeated"
self.asked.add(w)
self.mq += 1
return accepts(self.target, w)
def equivalent(self, hyp):
self.eq += 1
self.sizes.append(len(hyp[0]))
c = shortest_difference(self.target, hyp, self.sigma)
if c is not None and self.rng is not None and self.max_pad:
# a longer counterexample: pad with a random suffix and keep it if it still differs
for _ in range(10):
pad = tuple(self.rng.randrange(self.sigma) for _ in range(self.rng.randint(0, self.max_pad)))
pre = tuple(self.rng.randrange(self.sigma) for _ in range(self.rng.randint(0, self.max_pad)))
w = pre + pad
if accepts(self.target, w) != accepts(hyp, w):
c = w
break
if c is not None:
assert accepts(self.target, c) != accepts(hyp, c)
self.longest = max(self.longest, len(c))
return c
def brute_min_size(target, sigma) -> int:
# reachable states, grouped by their answers on all words of length <= n - 1
delta, start, finals = target
n = len(delta)
seen, stack = {start}, [start]
while stack:
q = stack.pop()
for a in range(sigma):
if delta[q][a] not in seen:
seen.add(delta[q][a])
stack.append(delta[q][a])
test = list(words(sigma, max(0, n - 1)))
sigs = set()
for q in seen:
sigs.add(tuple(accepts((delta, q, finals), w) for w in test))
return len(sigs)
def check(target, sigma, rng=None, max_pad=0):
teacher = Teacher(target, sigma, rng, max_pad)
hyp = lstar(sigma, teacher.member, teacher.equivalent)
n = brute_min_size(target, sigma)
# two DFAs with n1 and n2 states that differ also differ on a word of length <= n1 + n2 - 2;
# enumerate up to that bound when it is small, and search the product as well
bound = min(len(hyp[0]) + len(target[0]) - 2, 9 if sigma <= 2 else 7)
assert all(accepts(hyp, w) == accepts(target, w) for w in words(sigma, max(0, bound)))
assert shortest_difference(target, hyp, sigma) is None
assert len(hyp[0]) == n
assert teacher.eq <= n
assert all(x < y for x, y in zip(teacher.sizes, teacher.sizes[1:]))
m = teacher.longest
assert teacher.mq <= (sigma + 1) * (n + m * (n - 1)) * n
return hyp, teacher
def even_even():
# states (parity of a, parity of b): 0 = (0,0), 1 = (1,0), 2 = (0,1), 3 = (1,1)
return ([[1, 2], [0, 3], [3, 0], [2, 1]], 0, {0})
def test_example() -> None:
hyp, teacher = check(even_even(), 2)
assert len(hyp[0]) == 4
assert teacher.eq == 2
assert teacher.sizes == [2, 4]
assert teacher.mq == 19
assert accepts(hyp, (0, 0, 1, 1)) and not accepts(hyp, (1, 0))
def test_first_counterexample() -> None:
target = even_even()
first = []
def eq(h):
c = shortest_difference(target, h, 2)
first.append(c)
return c
lstar(2, lambda w: accepts(target, w), eq)
assert first == [(1, 0), None]
def test_edges() -> None:
empty = ([[0, 0]], 0, set())
full = ([[0, 0]], 0, {0})
assert len(check(empty, 2)[0][0]) == 1
assert len(check(full, 2)[0][0]) == 1
# one-letter alphabet: length divisible by 5
hyp, _ = check(([[(q + 1) % 5] for q in range(5)], 0, {0}), 1)
assert len(hyp[0]) == 5
# an unreachable accepting state and two equivalent dead states
hyp, _ = check(([[1, 2], [3, 3], [4, 4], [4, 3], [3, 4], [0, 0]], 0, {1, 5}), 2)
assert len(hyp[0]) == 3
# the n-th symbol from the end is a: 2^n states
for k in range(1, 5):
delta = [[((q << 1) | 1) & ((1 << k) - 1), (q << 1) & ((1 << k) - 1)] for q in range(1 << k)]
hyp, _ = check((delta, 0, {q for q in range(1 << k) if q >> (k - 1) & 1}), 2)
assert len(hyp[0]) == 1 << k
def random_dfa(rng, n, sigma):
delta = [[rng.randrange(n) for _ in range(sigma)] for _ in range(n)]
p = rng.choice([0.0, 0.3, 0.5, 1.0])
finals = {q for q in range(n) if rng.random() < p}
return delta, rng.randrange(n), finals
def test_random() -> None:
rng = random.Random(1987)
for t in range(700):
sigma = rng.randint(1, 3)
n = rng.randint(1, 6 if sigma <= 2 else 4)
target = random_dfa(rng, n, sigma)
check(target, sigma, rng, max_pad=rng.choice([0, 0, 4, 9]))
What the next level needs
- the tests did not run in this build