2026-07-12 23:46:25 +02:00
|
|
|
"""Tests for grammar AST, matching, counting, and GBNF rendering."""
|
2026-07-12 20:59:53 +02:00
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from bex.grammar import (
|
|
|
|
|
Symbol, Concat, Alt, Plus, Optional, Star, Epsilon, Empty,
|
2026-07-12 23:46:25 +02:00
|
|
|
alphabet, count_words, lang_size, model_cost, match,
|
2026-07-12 20:59:53 +02:00
|
|
|
)
|
2026-07-12 23:46:25 +02:00
|
|
|
from bex.gbnf import to_gbnf, to_gbnf_with_rules, grammar_structure_score
|
2026-07-12 20:59:53 +02:00
|
|
|
|
|
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
# ── AST Construction ──
|
2026-07-12 20:59:53 +02:00
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
class TestASTConstruction:
|
|
|
|
|
def test_symbol(self):
|
2026-07-12 20:59:53 +02:00
|
|
|
assert Symbol('a') == Symbol('a')
|
|
|
|
|
assert Symbol('a') != Symbol('b')
|
|
|
|
|
|
|
|
|
|
def test_concat(self):
|
2026-07-12 23:46:25 +02:00
|
|
|
c = Concat([Symbol('a'), Symbol('b')])
|
|
|
|
|
assert c.parts == [Symbol('a'), Symbol('b')]
|
2026-07-12 20:59:53 +02:00
|
|
|
|
|
|
|
|
def test_alt(self):
|
2026-07-12 23:46:25 +02:00
|
|
|
a = Alt([Symbol('a'), Symbol('b')])
|
|
|
|
|
assert a.parts == [Symbol('a'), Symbol('b')]
|
2026-07-12 20:59:53 +02:00
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
def test_plus(self):
|
|
|
|
|
p = Plus(Symbol('a'))
|
|
|
|
|
assert p.child == Symbol('a')
|
2026-07-12 20:59:53 +02:00
|
|
|
|
|
|
|
|
def test_optional(self):
|
2026-07-12 23:46:25 +02:00
|
|
|
o = Optional(Symbol('a'))
|
|
|
|
|
assert o.child == Symbol('a')
|
2026-07-12 20:59:53 +02:00
|
|
|
|
|
|
|
|
def test_star(self):
|
2026-07-12 23:46:25 +02:00
|
|
|
s = Star(Symbol('a'))
|
|
|
|
|
assert s.child == Symbol('a')
|
2026-07-12 20:59:53 +02:00
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
def test_epsilon(self):
|
|
|
|
|
assert Epsilon() == Epsilon()
|
2026-07-12 20:59:53 +02:00
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
def test_empty(self):
|
|
|
|
|
assert Empty() == Empty()
|
2026-07-12 20:59:53 +02:00
|
|
|
|
|
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
# ── Alphabet ──
|
2026-07-12 20:59:53 +02:00
|
|
|
|
|
|
|
|
class TestAlphabet:
|
|
|
|
|
def test_symbol(self):
|
|
|
|
|
assert alphabet(Symbol('a')) == {'a'}
|
|
|
|
|
|
|
|
|
|
def test_concat(self):
|
|
|
|
|
assert alphabet(Concat([Symbol('a'), Symbol('b')])) == {'a', 'b'}
|
|
|
|
|
|
|
|
|
|
def test_nested(self):
|
2026-07-12 23:46:25 +02:00
|
|
|
g = Concat([Symbol('a'), Plus(Alt([Symbol('b'), Symbol('c')]))])
|
2026-07-12 20:59:53 +02:00
|
|
|
assert alphabet(g) == {'a', 'b', 'c'}
|
|
|
|
|
|
|
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
# ── Matching ──
|
2026-07-12 20:59:53 +02:00
|
|
|
|
|
|
|
|
class TestMatch:
|
2026-07-12 23:46:25 +02:00
|
|
|
def test_symbol(self):
|
2026-07-12 20:59:53 +02:00
|
|
|
assert match(Symbol('a'), ['a'])
|
|
|
|
|
assert not match(Symbol('a'), ['b'])
|
|
|
|
|
assert not match(Symbol('a'), [])
|
|
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
def test_concat(self):
|
2026-07-12 20:59:53 +02:00
|
|
|
g = Concat([Symbol('a'), Symbol('b'), Symbol('c')])
|
|
|
|
|
assert match(g, ['a', 'b', 'c'])
|
|
|
|
|
assert not match(g, ['a', 'b'])
|
|
|
|
|
assert not match(g, ['a', 'b', 'c', 'd'])
|
|
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
def test_alt(self):
|
2026-07-12 20:59:53 +02:00
|
|
|
g = Alt([Symbol('a'), Symbol('b')])
|
|
|
|
|
assert match(g, ['a'])
|
|
|
|
|
assert match(g, ['b'])
|
|
|
|
|
assert not match(g, ['c'])
|
|
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
def test_plus(self):
|
2026-07-12 20:59:53 +02:00
|
|
|
g = Plus(Symbol('a'))
|
|
|
|
|
assert match(g, ['a'])
|
|
|
|
|
assert match(g, ['a', 'a'])
|
|
|
|
|
assert match(g, ['a', 'a', 'a'])
|
|
|
|
|
assert not match(g, [])
|
|
|
|
|
assert not match(g, ['b'])
|
|
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
def test_optional(self):
|
2026-07-12 20:59:53 +02:00
|
|
|
g = Optional(Symbol('a'))
|
|
|
|
|
assert match(g, ['a'])
|
|
|
|
|
assert match(g, [])
|
|
|
|
|
assert not match(g, ['b'])
|
|
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
def test_star(self):
|
2026-07-12 20:59:53 +02:00
|
|
|
g = Star(Symbol('a'))
|
|
|
|
|
assert match(g, [])
|
|
|
|
|
assert match(g, ['a'])
|
2026-07-12 23:46:25 +02:00
|
|
|
assert match(g, ['a', 'a'])
|
2026-07-12 20:59:53 +02:00
|
|
|
assert not match(g, ['b'])
|
|
|
|
|
|
|
|
|
|
def test_complex_grammar(self):
|
2026-07-12 23:46:25 +02:00
|
|
|
g = Concat([Plus(Symbol('init')), Plus(Symbol('capability')),
|
|
|
|
|
Concat([Plus(Symbol('invoke')), Symbol('request')])])
|
2026-07-12 20:59:53 +02:00
|
|
|
assert match(g, ['init', 'capability', 'invoke', 'request'])
|
2026-07-12 23:46:25 +02:00
|
|
|
assert match(g, ['init', 'init', 'capability', 'capability', 'invoke', 'request'])
|
|
|
|
|
assert not match(g, ['init'])
|
2026-07-12 20:59:53 +02:00
|
|
|
|
|
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
# ── Counting ──
|
2026-07-12 20:59:53 +02:00
|
|
|
|
|
|
|
|
class TestCountWords:
|
|
|
|
|
def test_symbol(self):
|
|
|
|
|
assert count_words(Symbol('a'), 1) == 1
|
2026-07-12 23:46:25 +02:00
|
|
|
assert count_words(Symbol('a'), 0) == 0
|
|
|
|
|
|
|
|
|
|
def test_epsilon(self):
|
|
|
|
|
assert count_words(Epsilon(), 0) == 1
|
|
|
|
|
assert count_words(Epsilon(), 1) == 0
|
|
|
|
|
|
|
|
|
|
def test_empty(self):
|
|
|
|
|
assert count_words(Empty(), 0) == 0
|
2026-07-12 20:59:53 +02:00
|
|
|
|
|
|
|
|
def test_concat(self):
|
2026-07-12 23:46:25 +02:00
|
|
|
g = Concat([Symbol('a'), Symbol('b'), Symbol('c')])
|
|
|
|
|
assert count_words(g, 3) == 1
|
|
|
|
|
assert count_words(g, 2) == 0
|
2026-07-12 20:59:53 +02:00
|
|
|
|
|
|
|
|
def test_alt(self):
|
2026-07-12 23:46:25 +02:00
|
|
|
g = Alt([Symbol('a'), Symbol('b'), Symbol('c')])
|
|
|
|
|
assert count_words(g, 1) == 3
|
2026-07-12 20:59:53 +02:00
|
|
|
|
|
|
|
|
def test_plus(self):
|
|
|
|
|
g = Plus(Symbol('a'))
|
|
|
|
|
assert count_words(g, 1) == 1
|
|
|
|
|
assert count_words(g, 2) == 1
|
2026-07-12 23:46:25 +02:00
|
|
|
assert count_words(g, 0) == 0
|
2026-07-12 20:59:53 +02:00
|
|
|
|
|
|
|
|
def test_optional(self):
|
|
|
|
|
g = Optional(Symbol('a'))
|
|
|
|
|
assert count_words(g, 0) == 1
|
|
|
|
|
assert count_words(g, 1) == 1
|
|
|
|
|
|
|
|
|
|
def test_star(self):
|
|
|
|
|
g = Star(Symbol('a'))
|
|
|
|
|
assert count_words(g, 0) == 1
|
|
|
|
|
assert count_words(g, 1) == 1
|
|
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
def test_disjunction_plus(self):
|
|
|
|
|
g = Plus(Alt([Symbol('a'), Symbol('b'), Symbol('c')]))
|
|
|
|
|
assert count_words(g, 1) == 3
|
|
|
|
|
assert count_words(g, 2) == 9
|
2026-07-12 20:59:53 +02:00
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
def test_info_plus(self):
|
|
|
|
|
g = Plus(Symbol('info'))
|
|
|
|
|
for l in range(1, 8):
|
|
|
|
|
assert count_words(g, l) == 1
|
2026-07-12 20:59:53 +02:00
|
|
|
|
|
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
# ── Model cost ──
|
2026-07-12 20:59:53 +02:00
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
class TestModelCost:
|
|
|
|
|
def test_symbol(self):
|
2026-07-12 20:59:53 +02:00
|
|
|
assert model_cost(Symbol('a')) == 1
|
|
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
def test_concat(self):
|
|
|
|
|
assert model_cost(Concat([Symbol('a'), Symbol('b'), Symbol('c')])) == 3
|
2026-07-12 20:59:53 +02:00
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
def test_plus(self):
|
2026-07-12 20:59:53 +02:00
|
|
|
assert model_cost(Plus(Symbol('a'))) == 1
|
|
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
def test_nested(self):
|
|
|
|
|
g = Concat([Symbol('a'), Plus(Alt([Symbol('b'), Symbol('c')]))])
|
|
|
|
|
assert model_cost(g) == 3
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── GBNF Rendering ──
|
|
|
|
|
|
|
|
|
|
def test_to_gbnf():
|
|
|
|
|
g = Plus(Symbol('init'))
|
|
|
|
|
result = to_gbnf(g)
|
|
|
|
|
assert result == 'root ::= "init"+'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_to_gbnf_with_rules():
|
|
|
|
|
g = Alt([Symbol('a'), Symbol('b')])
|
|
|
|
|
result = to_gbnf_with_rules(g, name='choice')
|
|
|
|
|
assert result == 'choice ::= "a" | "b"'
|
2026-07-12 20:59:53 +02:00
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
|
2026-07-13 00:03:40 +02:00
|
|
|
def test_count_words_concat_is_memoized():
|
|
|
|
|
"""Regression: _count_concat must be memoized.
|
|
|
|
|
|
|
|
|
|
An uncached implementation revisits (remaining_parts, length) states
|
|
|
|
|
exponentially; a long sequence over a multi-part Concat explodes into
|
|
|
|
|
millions of calls. With memoization it completes instantly.
|
|
|
|
|
"""
|
|
|
|
|
import time
|
|
|
|
|
# length-L string of (a|b) alternatives -> exactly 2^L words of length L
|
|
|
|
|
L = 20
|
|
|
|
|
g = Concat([Alt([Symbol('a'), Symbol('b')]) for _ in range(L)])
|
|
|
|
|
t0 = time.time()
|
|
|
|
|
n = count_words(g, L)
|
|
|
|
|
dt = time.time() - t0
|
|
|
|
|
assert dt < 2.0, f"count_words was too slow ({dt:.2f}s) — memoization lost"
|
|
|
|
|
assert n == 2 ** L, f"expected 2^{L}={2**L} words, got {n}"
|
|
|
|
|
|
|
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
# ── Structure score ──
|
|
|
|
|
|
|
|
|
|
class TestStructureScore:
|
|
|
|
|
def test_empty(self):
|
|
|
|
|
assert grammar_structure_score(Empty()) == 0.0
|
|
|
|
|
|
|
|
|
|
def test_symbol(self):
|
|
|
|
|
assert grammar_structure_score(Symbol('a')) == 0.0
|
|
|
|
|
|
|
|
|
|
def test_concat(self):
|
|
|
|
|
g = Concat([Symbol('a'), Symbol('b'), Symbol('c')])
|
|
|
|
|
assert grammar_structure_score(g) > 0.0
|
|
|
|
|
|
|
|
|
|
def test_plus(self):
|
|
|
|
|
g = Plus(Symbol('a'))
|
|
|
|
|
assert grammar_structure_score(g) > 0.0
|