grammar-inference-engine/tests/test_scoring.py

364 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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
from bex.mdl import (
model_cost, data_cost, lang_size, lang_size_score,
mdl_score, score_grammar, _count_words_fast,
)
from bex.ensemble import infer_ensemble
from bex.crx import CRX
from bex.idregex import idregex
# ── 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):
"""Paper's example: a.(a+c+)? has m=3, n=7, |L≤7|=3."""
expr = 'a.(a+c+)?'
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."""
n = 7 # target n for a.b.c
specific = lang_size('a.b.c', n)
generic = lang_size('(a+b+c)+', n)
assert specific < generic, (
f"Specific ({specific}) should beat generic ({generic}) at n={n}"
)
def test_paper_same_n_correct_beats_overfit(self):
"""At same n, correct grammar and overfit tie (both accept 1 word)."""
n = 7
correct = lang_size('a.b.c', n)
overfit = lang_size('a.a.a', n)
assert correct == overfit == 1, (
f"Both should accept 1 word at n={n}, got {correct} and {overfit}"
)
def test_paper_per_candidate_n_generic_wins_unfairly(self):
"""Per-candidate n lets generic patterns win unfairly."""
# info+ has m=1, n=3 → counts words at lengths 0,1,2,3
# specific has m=5, n=11 → counts words at lengths 0..11
generic_n = 2 * model_cost('info+') + 1 # = 3
specific_n = 2 * model_cost('info.file.template.shell.service+') + 1 # = 11
generic_ls = lang_size('info+', generic_n)
specific_ls = lang_size('info.file.template.shell.service+', specific_n)
# Generic wins on paper (3 < 7) but this is wrong
assert generic_ls < specific_ls, (
f"Per-candidate n: generic ({generic_ls}) beats specific ({specific_ls}) — this is the bug"
)
def test_paper_alphabet_size_5_mdL_vs_langsize(self):
"""Paper's result: Language Size 98% vs MDL 21% on alphabet size 5."""
# At the same n, language size correctly differentiates
n = 7
specific = lang_size('a.b.c', n) # 1 word
generic = lang_size('(a+b+c)+', n) # 3,279 words
medium = lang_size('a.(b+c)?', n) # 3 words
assert specific < medium < generic, (
f"Order should be specific({specific}) < medium({medium}) < generic({generic})"
)
# ── 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):
"""With diverse lengths, specific grammar wins clearly."""
seqs = [['a', 'b', 'c'], ['a', 'b'], ['a', 'c'], ['b', 'c']]
specific = lang_size_score('a.b.c', seqs)
generic = lang_size_score('(a+b+c)+', seqs)
assert specific < generic, (
f"Specific ({specific}) should beat generic ({generic})"
)
def test_info_plus_vs_specific_identical_lengths(self):
"""With identical lengths, both accept 1 word — honest tie."""
seqs = [['info', 'file', 'template', 'shell', 'service']] * 5
generic = lang_size_score('info+', seqs)
specific = lang_size_score('info.file.template.shell.service+', seqs)
assert generic == specific == 5, (
f"Both should score 5 (1 word × 5 seqs), got generic={generic}, specific={specific}"
)
def test_generic_vs_more_generic(self):
"""(a+b+c)+ accepts more words than a+ at each length."""
seqs = [['a', 'b', 'c']] * 3
less_generic = lang_size_score('a+', seqs)
more_generic = lang_size_score('(a+b+c)+', seqs)
# a+ accepts 1 word at each length; (a+b+c)+ accepts 3^L
assert less_generic < more_generic, (
f"a+ ({less_generic}) should beat (a+b+c)+ ({more_generic})"
)
def test_single_sequence(self):
"""Single sequence — specific grammar wins."""
seqs = [['a', 'b', 'c']]
specific = lang_size_score('a.b.c', seqs)
generic = lang_size_score('(a+b+c)+', seqs)
assert specific < generic
def test_long_sequences(self):
"""Long sequences — specific grammar still wins."""
seqs = [['a', 'b', 'c', 'd', 'e']] * 3
specific = lang_size_score('a.b.c.d.e', seqs)
generic = lang_size_score('(a+b+c+d+e)+', seqs)
assert specific < generic
def test_empty_sequences(self):
"""Empty sequences — falls back to paper formula."""
score = lang_size_score('a.b.c', [])
expected = lang_size('a.b.c', 2 * model_cost('a.b.c') + 1)
assert score == expected
def test_ordered_vs_unordered(self):
"""Ordered a.b.c beats unordered (a+b+c)+ on ordered data."""
seqs = [['a', 'b', 'c'], ['a', 'b'], ['a', 'c']]
ordered = lang_size_score('a.b.c', seqs)
unordered = lang_size_score('(a+b+c)+', seqs)
assert ordered < unordered
def test_optional_beats_generic(self):
"""a.(b+c)? beats (a+b+c)+ on data where a is always first."""
seqs = [['a', 'b'], ['a', 'c'], ['a']]
optional = lang_size_score('a.(b+c)?', seqs)
generic = lang_size_score('(a+b+c)+', seqs)
assert optional < generic
def test_repeat_beats_concat(self):
"""a+ beats a.a.a on data with varying lengths."""
seqs = [['a'], ['a', 'a'], ['a', 'a', 'a']]
repeat = lang_size_score('a+', seqs)
concat = lang_size_score('a.a.a', seqs)
# a+ accepts 1 word at each length; a.a.a accepts 0 at lengths 1,2 and 1 at length 3
# Total: a+ = 3, a.a.a = 0+0+1 = 1
# a.a.a actually wins because it rejects shorter sequences!
assert concat < repeat, (
f"a.a.a ({concat}) should beat a+ ({repeat}) — a.a.a rejects short seqs"
)
# ── MDL Fallback ──
class TestMDLFallback:
"""Tests for the old MDL scoring method."""
def test_mdl_basic(self):
"""MDL = model_cost + data_cost."""
score = mdl_score('a.b.c', [['a', 'b', 'c']])
assert score == model_cost('a.b.c') + data_cost('a.b.c', [['a', 'b', 'c']])
def test_mdl_prefers_short_expressions(self):
"""MDL rewards short expressions — the info+ bug."""
seqs = [['info', 'file', 'template', 'shell', 'service']] * 5
generic = mdl_score('info+', seqs)
specific = mdl_score('info.file.template.shell.service+', seqs)
assert generic < specific, (
f"MDL should pick info+ ({generic}) over specific ({specific}) — this is the bug"
)
def test_score_grammar_method_switch(self):
"""score_grammar dispatches to the correct scorer."""
seqs = [['a', 'b', 'c']]
ls = score_grammar('a.b.c', seqs, method='langsize')
mdl = score_grammar('a.b.c', seqs, method='mdl')
assert isinstance(ls, (int, float))
assert isinstance(mdl, (int, float))
def test_score_grammar_invalid_method(self):
"""Invalid method raises ValueError."""
with pytest.raises(ValueError, match="Unknown scoring method"):
score_grammar('a.b.c', [['a']], method='bogus')
def test_langsize_beats_mdl_on_info_plus(self):
"""Language Size ties on info+ scenario; MDL picks info+."""
seqs = [['info', 'file', 'template', 'shell', 'service']] * 5
ls_generic = score_grammar('info+', seqs, method='langsize')
ls_specific = score_grammar('info.file.template.shell.service+', seqs, method='langsize')
mdl_generic = score_grammar('info+', seqs, method='mdl')
mdl_specific = score_grammar('info.file.template.shell.service+', seqs, method='mdl')
# Language Size: tie (honest)
assert ls_generic == ls_specific, "Language Size should tie"
# MDL: generic wins (the bug)
assert mdl_generic < mdl_specific, "MDL should pick generic (the bug)"
# ── Ensemble Integration ──
class TestEnsembleIntegration:
"""Tests for the ensemble with method parameter."""
def test_ensemble_accepts_method(self):
"""Ensemble accepts method= parameter."""
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):
"""Default method is langsize."""
seqs = [['a', 'b'], ['a', 'b', 'c']]
r = infer_ensemble(seqs)
assert r['best'] is not None
def test_ensemble_langsize_prefers_specific(self):
"""With diverse sequences, langsize picks the specific grammar."""
seqs = [['a', 'b', 'c'], ['a', 'b'], ['a', 'c'], ['b', 'c']]
r = infer_ensemble(seqs, method='langsize')
# Should pick a.b.c or a.(b+c)? — something specific
best = r['best']['grammar']
# The specific grammar should have a low score
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):
"""Method parameter is passed through to scoring."""
seqs = [['a', 'b', 'c'], ['a', 'b']]
r_ls = infer_ensemble(seqs, method='langsize')
r_mdl = infer_ensemble(seqs, method='mdl')
# Both should produce results
assert r_ls['best'] is not None
assert r_mdl['best'] is not None
# Scores may differ
# (not necessarily — depends on what the algorithms produce)
# ── _count_words_fast Correctness ──
class TestCountWordsFast:
"""Tests for the word counting function used by Language Size."""
def test_single_symbol(self):
"""Single symbol: 1 word of length 1, 0 otherwise."""
assert _count_words_fast('a', 1) == 1
assert _count_words_fast('a', 0) == 0
assert _count_words_fast('a', 2) == 0
def test_concatenation(self):
"""a.b.c: 1 word of length 3, 0 otherwise."""
assert _count_words_fast('a.b.c', 3) == 1
assert _count_words_fast('a.b.c', 2) == 0
assert _count_words_fast('a.b.c', 4) == 0
def test_plus_quantifier(self):
"""a+: 1 word of each length ≥ 1."""
for l in range(1, 6):
assert _count_words_fast('a+', l) == 1
assert _count_words_fast('a+', 0) == 0
def test_disjunction(self):
"""(a+b+c): 3 words of length 1, 0 otherwise."""
assert _count_words_fast('(a+b+c)', 1) == 3
assert _count_words_fast('(a+b+c)', 0) == 0
assert _count_words_fast('(a+b+c)', 2) == 0
def test_disjunction_plus(self):
"""(a+b+c)+: 3^L words of length L."""
assert _count_words_fast('(a+b+c)+', 1) == 3
assert _count_words_fast('(a+b+c)+', 2) == 9
assert _count_words_fast('(a+b+c)+', 3) == 27
def test_optional(self):
"""a?.(b+c): 2 words of length 2 (ab, ac), 2 words of length 1 (b, c)."""
assert _count_words_fast('a?.(b+c)', 0) == 0
assert _count_words_fast('a?.(b+c)', 1) == 2 # b, c (a? absent)
assert _count_words_fast('a?.(b+c)', 2) == 2 # ab, ac (a? present)
def test_epsilon(self):
"""ε: 1 word of length 0."""
assert _count_words_fast('ε', 0) == 1
assert _count_words_fast('ε', 1) == 0
def test_empty(self):
"""∅: 0 words at any length."""
assert _count_words_fast('', 0) == 0
assert _count_words_fast('', 1) == 0
def test_info_plus(self):
"""info+: 1 word of each length ≥ 1 (info repeated L times)."""
for l in range(1, 8):
assert _count_words_fast('info+', l) == 1
def test_info_dot_concat(self):
"""info.file.template: 1 word of length 3, 0 otherwise."""
assert _count_words_fast('info.file.template', 3) == 1
assert _count_words_fast('info.file.template', 2) == 0
assert _count_words_fast('info.file.template', 4) == 0
def test_mixed_disj_concat(self):
"""a.(b+c)+: a followed by 1+ of b or c."""
# length 2: ab, ac (2 words)
assert _count_words_fast('a.(b+c)+', 2) == 2
# length 3: abb, abc, acb, acc (4 words)
assert _count_words_fast('a.(b+c)+', 3) == 4
def test_optional_concat(self):
"""a?.b.(c+d): a optional, then b, then c or d."""
assert _count_words_fast('a?.b.(c+d)', 0) == 0
assert _count_words_fast('a?.b.(c+d)', 2) == 2 # bc, bd
assert _count_words_fast('a?.b.(c+d)', 3) == 2 # abc, abd
# ── Regression: info+ Problem ──
class TestInfoPlusRegression:
"""Regression tests for the concrete info+ problem from our codebase."""
def test_info_plus_not_preferred_over_specific(self):
"""info+ should not beat the specific grammar on diverse data."""
seqs = [['info', 'file', 'template', 'shell', 'service']] * 5
generic_score = lang_size_score('info+', seqs)
specific_score = lang_size_score('info.file.template.shell.service+', seqs)
# They tie — which is correct
assert generic_score == specific_score
def test_info_plus_loses_on_diverse_data(self):
"""info+ loses when sequences have different lengths."""
seqs = [
['info', 'file'],
['info', 'file', 'template'],
['info', 'file', 'template', 'shell'],
]
generic = lang_size_score('info+', seqs)
specific = lang_size_score('info.file.template+', seqs)
assert generic > specific, (
f"info+ ({generic}) should lose to specific ({specific}) on diverse data"
)
def test_crx_does_not_produce_info_plus(self):
"""CRX does not produce info+ for identical sequences."""
seqs = [['info', 'file', 'template', 'shell', 'service']] * 5
g = CRX().infer(seqs)
assert g != 'info+', f"CRX should not produce info+, got {g}"
def test_ensemble_does_not_pick_info_plus(self):
"""Ensemble does not pick info+ for5 identical sequences."""
seqs = [['info', 'file', 'template', 'shell', 'service']] * 5
r = infer_ensemble(seqs)
assert r['best']['grammar'] != 'info+', (
f"Ensemble should not pick info+, got {r['best']['grammar']}"
)