2026-07-11 22:56:42 +02:00
|
|
|
"""Comprehensive tests for Language Size scoring (Bex et al. arXiv:1004.2372).
|
|
|
|
|
|
|
|
|
|
Tests cover:
|
|
|
|
|
1. Paper's Language Size measure (Section 4.3.1)
|
|
|
|
|
2. Our adaptation (counting at exact sequence lengths)
|
|
|
|
|
3. Edge cases: ties, empty sequences, single sequences, long sequences
|
|
|
|
|
4. MDL fallback behavior
|
|
|
|
|
5. Ensemble integration with method parameter
|
|
|
|
|
6. The concrete info+ problem from our codebase
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
import pytest
|
2026-07-12 20:59:53 +02:00
|
|
|
from bex.grammar import (
|
|
|
|
|
Symbol, Concat, Alt, Plus, Optional, Star, Epsilon, Empty,
|
2026-07-12 23:46:25 +02:00
|
|
|
count_words, lang_size, model_cost, alphabet,
|
2026-07-12 20:59:53 +02:00
|
|
|
)
|
2026-07-11 22:56:42 +02:00
|
|
|
from bex.mdl import (
|
2026-07-12 20:59:53 +02:00
|
|
|
model_cost as mdl_model_cost, data_cost, lang_size_score,
|
|
|
|
|
mdl_score, score_grammar,
|
2026-07-11 22:56:42 +02:00
|
|
|
)
|
|
|
|
|
from bex.ensemble import infer_ensemble
|
|
|
|
|
from bex.crx import CRX
|
2026-07-12 20:59:53 +02:00
|
|
|
|
|
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
# ── AST builders (replacing old parse_sore helper) ──
|
|
|
|
|
|
|
|
|
|
_a = Symbol('a')
|
|
|
|
|
_b = Symbol('b')
|
|
|
|
|
_c = Symbol('c')
|
|
|
|
|
_d = Symbol('d')
|
|
|
|
|
_e = Symbol('e')
|
|
|
|
|
_info = Symbol('info')
|
|
|
|
|
_file = Symbol('file')
|
|
|
|
|
_template = Symbol('template')
|
|
|
|
|
_shell = Symbol('shell')
|
|
|
|
|
_service = Symbol('service')
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Paper's Language Size: cumulative |L(r)≤n| ──
|
|
|
|
|
|
|
|
|
|
class TestPaperLanguageSize:
|
|
|
|
|
"""Tests for the paper's original cumulative measure."""
|
|
|
|
|
|
|
|
|
|
def test_paper_example_a_dot_a_c_plus(self):
|
2026-07-12 23:46:25 +02:00
|
|
|
"""Paper's example: a.(a+c+)? → a concat optional(a|c), m=3, n=7, |L≤7|=3."""
|
|
|
|
|
expr = Concat([_a, Optional(Alt([_a, _c]))])
|
2026-07-11 22:56:42 +02:00
|
|
|
m = model_cost(expr)
|
|
|
|
|
n = 2 * m + 1
|
|
|
|
|
assert m == 3, f"model_cost should be 3, got {m}"
|
|
|
|
|
assert n == 7, f"n should be 7, got {n}"
|
|
|
|
|
ls = lang_size(expr, n)
|
|
|
|
|
assert ls == 3, f"|L≤7| should be 3, got {ls}"
|
|
|
|
|
|
|
|
|
|
def test_paper_same_n_specific_wins(self):
|
|
|
|
|
"""At same n, specific grammar beats generic."""
|
2026-07-12 20:59:53 +02:00
|
|
|
n = 7
|
2026-07-12 23:46:25 +02:00
|
|
|
specific = lang_size(Concat([_a, _b, _c]), n)
|
|
|
|
|
generic = lang_size(Plus(Alt([_a, _b, _c])), n)
|
2026-07-12 20:59:53 +02:00
|
|
|
assert specific < generic
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
def test_paper_same_n_correct_beats_overfit(self):
|
|
|
|
|
"""At same n, correct grammar and overfit tie (both accept 1 word)."""
|
|
|
|
|
n = 7
|
2026-07-12 23:46:25 +02:00
|
|
|
correct = lang_size(Concat([_a, _b, _c]), n)
|
|
|
|
|
overfit = lang_size(Concat([_a, _a, _a]), n)
|
2026-07-12 20:59:53 +02:00
|
|
|
assert correct == overfit == 1
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
def test_paper_per_candidate_n_generic_wins_unfairly(self):
|
|
|
|
|
"""Per-candidate n lets generic patterns win unfairly."""
|
2026-07-12 23:46:25 +02:00
|
|
|
generic_n = 2 * model_cost(Plus(_info)) + 1
|
|
|
|
|
specific_n = 2 * model_cost(Concat([Plus(_info), _file, _template, _shell, Plus(_service)])) + 1
|
|
|
|
|
generic_ls = lang_size(Plus(_info), generic_n)
|
|
|
|
|
specific_ls = lang_size(Concat([Plus(_info), _file, _template, _shell, Plus(_service)]), specific_n)
|
2026-07-12 20:59:53 +02:00
|
|
|
assert generic_ls < specific_ls
|
2026-07-11 22:56:42 +02:00
|
|
|
|
2026-07-12 23:46:25 +02:00
|
|
|
def test_paper_alphabet_size_5_langsize_vs_mdl(self):
|
2026-07-11 22:56:42 +02:00
|
|
|
"""Paper's result: Language Size 98% vs MDL 21% on alphabet size 5."""
|
|
|
|
|
n = 7
|
2026-07-12 23:46:25 +02:00
|
|
|
specific = lang_size(Concat([_a, _b, _c]), n)
|
|
|
|
|
generic = lang_size(Plus(Alt([_a, _b, _c])), n)
|
|
|
|
|
medium = lang_size(Concat([_a, Optional(Alt([_b, _c]))]), n)
|
2026-07-12 20:59:53 +02:00
|
|
|
assert specific < medium < generic
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Our Adaptation: words at exact sequence lengths ──
|
|
|
|
|
|
|
|
|
|
class TestAdaptedLanguageSize:
|
|
|
|
|
"""Tests for our adaptation (counting at exact sequence lengths)."""
|
|
|
|
|
|
|
|
|
|
def test_specific_vs_generic_diverse_lengths(self):
|
|
|
|
|
seqs = [['a', 'b', 'c'], ['a', 'b'], ['a', 'c'], ['b', 'c']]
|
2026-07-12 23:46:25 +02:00
|
|
|
specific = lang_size_score(Concat([_a, _b, _c]), seqs)
|
|
|
|
|
generic = lang_size_score(Plus(Alt([_a, _b, _c])), seqs)
|
2026-07-12 20:59:53 +02:00
|
|
|
assert specific < generic
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
def test_info_plus_vs_specific_identical_lengths(self):
|
|
|
|
|
seqs = [['info', 'file', 'template', 'shell', 'service']] * 5
|
2026-07-12 23:46:25 +02:00
|
|
|
generic = lang_size_score(Plus(_info), seqs)
|
|
|
|
|
specific = lang_size_score(Concat([_info, _file, _template, _shell, Plus(_service)]), seqs)
|
2026-07-12 20:59:53 +02:00
|
|
|
assert generic == specific == 5
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
def test_generic_vs_more_generic(self):
|
|
|
|
|
seqs = [['a', 'b', 'c']] * 3
|
2026-07-12 23:46:25 +02:00
|
|
|
less_generic = lang_size_score(Plus(_a), seqs)
|
|
|
|
|
more_generic = lang_size_score(Plus(Alt([_a, _b, _c])), seqs)
|
2026-07-12 20:59:53 +02:00
|
|
|
assert less_generic < more_generic
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
def test_single_sequence(self):
|
|
|
|
|
seqs = [['a', 'b', 'c']]
|
2026-07-12 23:46:25 +02:00
|
|
|
specific = lang_size_score(Concat([_a, _b, _c]), seqs)
|
|
|
|
|
generic = lang_size_score(Plus(Alt([_a, _b, _c])), seqs)
|
2026-07-11 22:56:42 +02:00
|
|
|
assert specific < generic
|
|
|
|
|
|
|
|
|
|
def test_long_sequences(self):
|
|
|
|
|
seqs = [['a', 'b', 'c', 'd', 'e']] * 3
|
2026-07-12 23:46:25 +02:00
|
|
|
specific = lang_size_score(Concat([_a, _b, _c, _d, _e]), seqs)
|
|
|
|
|
generic = lang_size_score(Plus(Alt([_a, _b, _c, _d, _e])), seqs)
|
2026-07-11 22:56:42 +02:00
|
|
|
assert specific < generic
|
|
|
|
|
|
|
|
|
|
def test_empty_sequences(self):
|
2026-07-12 23:46:25 +02:00
|
|
|
g = Concat([_a, _b, _c])
|
|
|
|
|
score = lang_size_score(g, [])
|
|
|
|
|
expected = lang_size(g, 2 * model_cost(g) + 1)
|
2026-07-11 22:56:42 +02:00
|
|
|
assert score == expected
|
|
|
|
|
|
|
|
|
|
def test_ordered_vs_unordered(self):
|
|
|
|
|
seqs = [['a', 'b', 'c'], ['a', 'b'], ['a', 'c']]
|
2026-07-12 23:46:25 +02:00
|
|
|
ordered = lang_size_score(Concat([_a, _b, _c]), seqs)
|
|
|
|
|
unordered = lang_size_score(Plus(Alt([_a, _b, _c])), seqs)
|
2026-07-11 22:56:42 +02:00
|
|
|
assert ordered < unordered
|
|
|
|
|
|
|
|
|
|
def test_optional_beats_generic(self):
|
|
|
|
|
seqs = [['a', 'b'], ['a', 'c'], ['a']]
|
2026-07-12 23:46:25 +02:00
|
|
|
optional = lang_size_score(Concat([_a, Optional(Alt([_b, _c]))]), seqs)
|
|
|
|
|
generic = lang_size_score(Plus(Alt([_a, _b, _c])), seqs)
|
2026-07-11 22:56:42 +02:00
|
|
|
assert optional < generic
|
|
|
|
|
|
|
|
|
|
def test_repeat_beats_concat(self):
|
|
|
|
|
seqs = [['a'], ['a', 'a'], ['a', 'a', 'a']]
|
2026-07-12 23:46:25 +02:00
|
|
|
repeat = lang_size_score(Plus(_a), seqs)
|
|
|
|
|
concat = lang_size_score(Concat([_a, _a, _a]), seqs)
|
2026-07-12 20:59:53 +02:00
|
|
|
assert concat < repeat
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── MDL Fallback ──
|
|
|
|
|
|
|
|
|
|
class TestMDLFallback:
|
|
|
|
|
def test_mdl_basic(self):
|
2026-07-12 23:46:25 +02:00
|
|
|
g = Concat([_a, _b, _c])
|
|
|
|
|
score = mdl_score(g, [['a', 'b', 'c']])
|
|
|
|
|
assert score == mdl_model_cost(g) + data_cost(g, [['a', 'b', 'c']])
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
def test_mdl_prefers_short_expressions(self):
|
|
|
|
|
seqs = [['info', 'file', 'template', 'shell', 'service']] * 5
|
2026-07-12 23:46:25 +02:00
|
|
|
generic = mdl_score(Plus(_info), seqs)
|
|
|
|
|
specific = mdl_score(Concat([_info, _file, _template, _shell, Plus(_service)]), seqs)
|
2026-07-12 20:59:53 +02:00
|
|
|
assert generic < specific
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
def test_score_grammar_method_switch(self):
|
2026-07-12 23:46:25 +02:00
|
|
|
g = Concat([_a, _b, _c])
|
|
|
|
|
ls = score_grammar(g, [['a', 'b', 'c']], method='langsize')
|
|
|
|
|
mdl = score_grammar(g, [['a', 'b', 'c']], method='mdl')
|
2026-07-11 22:56:42 +02:00
|
|
|
assert isinstance(ls, (int, float))
|
|
|
|
|
assert isinstance(mdl, (int, float))
|
|
|
|
|
|
|
|
|
|
def test_score_grammar_invalid_method(self):
|
|
|
|
|
with pytest.raises(ValueError, match="Unknown scoring method"):
|
2026-07-12 23:46:25 +02:00
|
|
|
score_grammar(_a, [['a']], method='bogus')
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
def test_langsize_beats_mdl_on_info_plus(self):
|
|
|
|
|
seqs = [['info', 'file', 'template', 'shell', 'service']] * 5
|
2026-07-12 23:46:25 +02:00
|
|
|
g_generic = Plus(_info)
|
|
|
|
|
g_specific = Concat([_info, _file, _template, _shell, Plus(_service)])
|
|
|
|
|
ls_generic = score_grammar(g_generic, seqs, method='langsize')
|
|
|
|
|
ls_specific = score_grammar(g_specific, seqs, method='langsize')
|
|
|
|
|
mdl_generic = score_grammar(g_generic, seqs, method='mdl')
|
|
|
|
|
mdl_specific = score_grammar(g_specific, seqs, method='mdl')
|
2026-07-11 22:56:42 +02:00
|
|
|
assert ls_generic == ls_specific, "Language Size should tie"
|
|
|
|
|
assert mdl_generic < mdl_specific, "MDL should pick generic (the bug)"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Ensemble Integration ──
|
|
|
|
|
|
|
|
|
|
class TestEnsembleIntegration:
|
|
|
|
|
def test_ensemble_accepts_method(self):
|
|
|
|
|
seqs = [['a', 'b'], ['a', 'b', 'c']]
|
|
|
|
|
r_ls = infer_ensemble(seqs, method='langsize')
|
|
|
|
|
r_mdl = infer_ensemble(seqs, method='mdl')
|
|
|
|
|
assert r_ls['best'] is not None
|
|
|
|
|
assert r_mdl['best'] is not None
|
|
|
|
|
|
|
|
|
|
def test_ensemble_default_is_langsize(self):
|
|
|
|
|
seqs = [['a', 'b'], ['a', 'b', 'c']]
|
|
|
|
|
r = infer_ensemble(seqs)
|
|
|
|
|
assert r['best'] is not None
|
|
|
|
|
|
|
|
|
|
def test_ensemble_langsize_prefers_specific(self):
|
|
|
|
|
seqs = [['a', 'b', 'c'], ['a', 'b'], ['a', 'c'], ['b', 'c']]
|
|
|
|
|
r = infer_ensemble(seqs, method='langsize')
|
|
|
|
|
score = r['best']['mdl_score']
|
|
|
|
|
assert score < 100, f"Score should be low for specific grammar, got {score}"
|
|
|
|
|
|
|
|
|
|
def test_ensemble_method_threaded_to_algorithms(self):
|
|
|
|
|
seqs = [['a', 'b', 'c'], ['a', 'b']]
|
|
|
|
|
r_ls = infer_ensemble(seqs, method='langsize')
|
|
|
|
|
r_mdl = infer_ensemble(seqs, method='mdl')
|
|
|
|
|
assert r_ls['best'] is not None
|
|
|
|
|
assert r_mdl['best'] is not None
|
|
|
|
|
|
|
|
|
|
|
2026-07-12 20:59:53 +02:00
|
|
|
# ── count_words Correctness ──
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
class TestCountWordsFast:
|
|
|
|
|
def test_single_symbol(self):
|
2026-07-12 23:46:25 +02:00
|
|
|
assert count_words(_a, 1) == 1
|
|
|
|
|
assert count_words(_a, 0) == 0
|
|
|
|
|
assert count_words(_a, 2) == 0
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
def test_concatenation(self):
|
2026-07-12 23:46:25 +02:00
|
|
|
assert count_words(Concat([_a, _b, _c]), 3) == 1
|
|
|
|
|
assert count_words(Concat([_a, _b, _c]), 2) == 0
|
|
|
|
|
assert count_words(Concat([_a, _b, _c]), 4) == 0
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
def test_plus_quantifier(self):
|
|
|
|
|
for l in range(1, 6):
|
2026-07-12 23:46:25 +02:00
|
|
|
assert count_words(Plus(_a), l) == 1
|
|
|
|
|
assert count_words(Plus(_a), 0) == 0
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
def test_disjunction(self):
|
2026-07-12 23:46:25 +02:00
|
|
|
assert count_words(Alt([_a, _b, _c]), 1) == 3
|
|
|
|
|
assert count_words(Alt([_a, _b, _c]), 0) == 0
|
|
|
|
|
assert count_words(Alt([_a, _b, _c]), 2) == 0
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
def test_disjunction_plus(self):
|
2026-07-12 23:46:25 +02:00
|
|
|
assert count_words(Plus(Alt([_a, _b, _c])), 1) == 3
|
|
|
|
|
assert count_words(Plus(Alt([_a, _b, _c])), 2) == 9
|
|
|
|
|
assert count_words(Plus(Alt([_a, _b, _c])), 3) == 27
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
def test_optional(self):
|
2026-07-12 23:46:25 +02:00
|
|
|
assert count_words(Concat([Optional(_a), Alt([_b, _c])]), 0) == 0
|
|
|
|
|
assert count_words(Concat([Optional(_a), Alt([_b, _c])]), 1) == 2
|
|
|
|
|
assert count_words(Concat([Optional(_a), Alt([_b, _c])]), 2) == 2
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
def test_epsilon(self):
|
2026-07-12 20:59:53 +02:00
|
|
|
assert count_words(Epsilon(), 0) == 1
|
|
|
|
|
assert count_words(Epsilon(), 1) == 0
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
def test_empty(self):
|
2026-07-12 20:59:53 +02:00
|
|
|
assert count_words(Empty(), 0) == 0
|
|
|
|
|
assert count_words(Empty(), 1) == 0
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
def test_info_plus(self):
|
|
|
|
|
for l in range(1, 8):
|
2026-07-12 23:46:25 +02:00
|
|
|
assert count_words(Plus(_info), l) == 1
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
def test_info_dot_concat(self):
|
2026-07-12 23:46:25 +02:00
|
|
|
assert count_words(Concat([_info, _file, _template]), 3) == 1
|
|
|
|
|
assert count_words(Concat([_info, _file, _template]), 2) == 0
|
|
|
|
|
assert count_words(Concat([_info, _file, _template]), 4) == 0
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
def test_mixed_disj_concat(self):
|
2026-07-12 23:46:25 +02:00
|
|
|
assert count_words(Concat([_a, Plus(Alt([_b, _c]))]), 2) == 2
|
|
|
|
|
assert count_words(Concat([_a, Plus(Alt([_b, _c]))]), 3) == 4
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
def test_optional_concat(self):
|
2026-07-12 23:46:25 +02:00
|
|
|
assert count_words(Concat([Optional(_a), _b, Alt([_c, _d])]), 0) == 0
|
|
|
|
|
assert count_words(Concat([Optional(_a), _b, Alt([_c, _d])]), 2) == 2
|
|
|
|
|
assert count_words(Concat([Optional(_a), _b, Alt([_c, _d])]), 3) == 2
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Regression: info+ Problem ──
|
|
|
|
|
|
|
|
|
|
class TestInfoPlusRegression:
|
|
|
|
|
def test_info_plus_not_preferred_over_specific(self):
|
|
|
|
|
seqs = [['info', 'file', 'template', 'shell', 'service']] * 5
|
2026-07-12 23:46:25 +02:00
|
|
|
g_generic = Plus(_info)
|
|
|
|
|
g_specific = Concat([_info, _file, _template, _shell, Plus(_service)])
|
|
|
|
|
generic_score = lang_size_score(g_generic, seqs)
|
|
|
|
|
specific_score = lang_size_score(g_specific, seqs)
|
2026-07-11 22:56:42 +02:00
|
|
|
assert generic_score == specific_score
|
|
|
|
|
|
|
|
|
|
def test_info_plus_loses_on_diverse_data(self):
|
|
|
|
|
seqs = [
|
|
|
|
|
['info', 'file'],
|
|
|
|
|
['info', 'file', 'template'],
|
|
|
|
|
['info', 'file', 'template', 'shell'],
|
|
|
|
|
]
|
2026-07-12 23:46:25 +02:00
|
|
|
generic = lang_size_score(Plus(_info), seqs)
|
|
|
|
|
specific = lang_size_score(Concat([_info, _file, Plus(_template)]), seqs)
|
2026-07-12 20:59:53 +02:00
|
|
|
assert generic > specific
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
def test_crx_does_not_produce_info_plus(self):
|
|
|
|
|
seqs = [['info', 'file', 'template', 'shell', 'service']] * 5
|
|
|
|
|
g = CRX().infer(seqs)
|
2026-07-12 20:59:53 +02:00
|
|
|
alpha = alphabet(g)
|
2026-07-12 23:46:25 +02:00
|
|
|
assert not (len(alpha) == 1 and _info in alpha)
|
2026-07-11 22:56:42 +02:00
|
|
|
|
|
|
|
|
def test_ensemble_does_not_pick_info_plus(self):
|
|
|
|
|
seqs = [['info', 'file', 'template', 'shell', 'service']] * 5
|
|
|
|
|
r = infer_ensemble(seqs)
|
2026-07-12 20:59:53 +02:00
|
|
|
best = r['best']['grammar']
|
|
|
|
|
alpha = alphabet(best)
|
2026-07-12 23:46:25 +02:00
|
|
|
assert not (len(alpha) == 1 and _info in alpha)
|