WIP: language size scoring + diversity threshold (step 1 pending)
This commit is contained in:
parent
830104b399
commit
dfb56a083a
8 changed files with 835 additions and 34 deletions
|
|
@ -4,7 +4,7 @@ import re
|
||||||
from .crx import CRX
|
from .crx import CRX
|
||||||
from .idregex import idregex
|
from .idregex import idregex
|
||||||
from .expr import alphabet
|
from .expr import alphabet
|
||||||
from .mdl import model_cost, mdl_score
|
from .mdl import model_cost, mdl_score, lang_size_score, score_grammar
|
||||||
|
|
||||||
|
|
||||||
def _parse_parts(expr):
|
def _parse_parts(expr):
|
||||||
|
|
@ -356,21 +356,19 @@ def _find_core(sequences, min_coverage=0.8):
|
||||||
return core_g, working, removed_indices, []
|
return core_g, working, removed_indices, []
|
||||||
|
|
||||||
|
|
||||||
def mdl_score_simple(grammar, sequences):
|
def mdl_score_simple(grammar, sequences, method='langsize'):
|
||||||
"""MDL score from the paper: model_cost + Σ log₂(|L(r)| at length len(s)).
|
"""Score a grammar. Default: Language Size (Bex et al., arXiv:1004.2372).
|
||||||
|
|
||||||
Lower is better. Uses the paper's definition from Bex et al.
|
Lower is better. Use method='mdl' for the old MDL fallback.
|
||||||
model_cost = number of alphabet symbol occurrences in the expression.
|
|
||||||
data_cost = Σ log₂(|L(r)|) — penalizes overly general grammars.
|
|
||||||
"""
|
"""
|
||||||
return mdl_score(grammar, sequences)
|
return score_grammar(grammar, sequences, method=method)
|
||||||
|
|
||||||
|
|
||||||
def _run_idregex(sequences, kmax, N):
|
def _run_idregex(sequences, kmax, N, method='langsize'):
|
||||||
"""Run standalone iDRegEx, return (grammar, score) or (None, inf)."""
|
"""Run standalone iDRegEx, return (grammar, score) or (None, inf)."""
|
||||||
g = idregex(sequences, kmax=kmax, N=N)
|
g = idregex(sequences, kmax=kmax, N=N)
|
||||||
if g and g != '∅':
|
if g and g != '∅':
|
||||||
return g, mdl_score_simple(g, sequences)
|
return g, mdl_score_simple(g, sequences, method=method)
|
||||||
return None, float('inf')
|
return None, float('inf')
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -381,24 +379,24 @@ _ALGO_NAMES = {
|
||||||
|
|
||||||
|
|
||||||
_ALGORITHMS = {
|
_ALGORITHMS = {
|
||||||
'crx': lambda s, k, n: (CRX().infer(s), mdl_score_simple(CRX().infer(s), s)),
|
'crx': lambda s, k, n, m='langsize': (CRX().infer(s), mdl_score_simple(CRX().infer(s), s, method=m)),
|
||||||
'idregex': _run_idregex,
|
'idregex': _run_idregex,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _run_kore(sequences, kmax, N):
|
def _run_kore(sequences, kmax, N, method='langsize'):
|
||||||
"""Run kOREInference, return (grammar, score) or (None, inf)."""
|
"""Run kOREInference, return (grammar, score) or (None, inf)."""
|
||||||
from .kore import kOREInference
|
from .kore import kOREInference
|
||||||
kore = kOREInference(k_max=kmax, N=N)
|
kore = kOREInference(k_max=kmax, N=N)
|
||||||
result = kore.infer(sequences)
|
result = kore.infer(sequences)
|
||||||
if result:
|
if result:
|
||||||
_, expr, _ = result
|
_, expr, _ = result
|
||||||
return expr, mdl_score_simple(expr, sequences)
|
return expr, mdl_score_simple(expr, sequences, method=method)
|
||||||
return None, float('inf')
|
return None, float('inf')
|
||||||
|
|
||||||
|
|
||||||
def infer_ensemble(sequences, kmax=2, N=3, prefer=None, min_coverage=1.0, include_kore=False):
|
def infer_ensemble(sequences, kmax=2, N=3, prefer=None, min_coverage=1.0, include_kore=False, method='langsize'):
|
||||||
"""Run all applicable algorithms and return the best by MDL score.
|
"""Run all applicable algorithms and return the best by scoring.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
sequences: List of sequences, each a list of strings.
|
sequences: List of sequences, each a list of strings.
|
||||||
|
|
@ -410,6 +408,8 @@ def infer_ensemble(sequences, kmax=2, N=3, prefer=None, min_coverage=1.0, includ
|
||||||
of sequences. Outliers (worst-fitting) are iteratively
|
of sequences. Outliers (worst-fitting) are iteratively
|
||||||
removed until at least this fraction remains. The core
|
removed until at least this fraction remains. The core
|
||||||
grammar and outlier list are included in the response.
|
grammar and outlier list are included in the response.
|
||||||
|
method: Scoring method — 'langsize' (default, Bex et al. arXiv:1004.2372)
|
||||||
|
or 'mdl' (fallback).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
dict with keys:
|
dict with keys:
|
||||||
|
|
@ -423,7 +423,7 @@ def infer_ensemble(sequences, kmax=2, N=3, prefer=None, min_coverage=1.0, includ
|
||||||
key = prefer.lower()
|
key = prefer.lower()
|
||||||
fn = _ALGORITHMS[key]
|
fn = _ALGORITHMS[key]
|
||||||
algo_name = _ALGO_NAMES.get(key, key)
|
algo_name = _ALGO_NAMES.get(key, key)
|
||||||
g, score = fn(sequences, kmax, N)
|
g, score = fn(sequences, kmax, N, method)
|
||||||
if g and g != '∅':
|
if g and g != '∅':
|
||||||
return {
|
return {
|
||||||
'best': {'algorithm': algo_name, 'grammar': g, 'mdl_score': round(score, 2)},
|
'best': {'algorithm': algo_name, 'grammar': g, 'mdl_score': round(score, 2)},
|
||||||
|
|
@ -440,17 +440,17 @@ def infer_ensemble(sequences, kmax=2, N=3, prefer=None, min_coverage=1.0, includ
|
||||||
|
|
||||||
# 1. CRX (always fast, always produces a result)
|
# 1. CRX (always fast, always produces a result)
|
||||||
crx_g = CRX().infer(sequences)
|
crx_g = CRX().infer(sequences)
|
||||||
crx_score = mdl_score_simple(crx_g, sequences) if crx_g and crx_g != '∅' else float('inf')
|
crx_score = mdl_score_simple(crx_g, sequences, method=method) if crx_g and crx_g != '∅' else float('inf')
|
||||||
results.append(('CRX', crx_g if crx_g and crx_g != '∅' else '∅', crx_score))
|
results.append(('CRX', crx_g if crx_g and crx_g != '∅' else '∅', crx_score))
|
||||||
|
|
||||||
# 2. iDRegEx (standalone, langsize-based)
|
# 2. iDRegEx (standalone, langsize-based)
|
||||||
idr_g, idr_score = _run_idregex(sequences, kmax, N)
|
idr_g, idr_score = _run_idregex(sequences, kmax, N, method=method)
|
||||||
if idr_g:
|
if idr_g:
|
||||||
results.append(('iDRegEx', idr_g, idr_score))
|
results.append(('iDRegEx', idr_g, idr_score))
|
||||||
|
|
||||||
# 3. kOREInference (opt-in via include_kore=True)
|
# 3. kOREInference (opt-in via include_kore=True)
|
||||||
if include_kore:
|
if include_kore:
|
||||||
kore_g, kore_score = _run_kore(sequences, kmax, N)
|
kore_g, kore_score = _run_kore(sequences, kmax, N, method=method)
|
||||||
if kore_g:
|
if kore_g:
|
||||||
results.append(('kOREInference', kore_g, kore_score))
|
results.append(('kOREInference', kore_g, kore_score))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ def infer_best_grammar(
|
||||||
kmax: int = 2,
|
kmax: int = 2,
|
||||||
N: int = 3,
|
N: int = 3,
|
||||||
min_coverage: float = 1.0,
|
min_coverage: float = 1.0,
|
||||||
|
method: str = "langsize",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Infer a compact grammar from example sequences. Use this when you
|
"""Infer a compact grammar from example sequences. Use this when you
|
||||||
have examples of sequential data and want to learn the pattern.
|
have examples of sequential data and want to learn the pattern.
|
||||||
|
|
@ -31,7 +32,7 @@ def infer_best_grammar(
|
||||||
than passing all examples. Pass the existing sequences, get back a
|
than passing all examples. Pass the existing sequences, get back a
|
||||||
pattern you can follow to generate new instances.
|
pattern you can follow to generate new instances.
|
||||||
|
|
||||||
Runs CRX + iDRegEx, picks best by MDL score. kORE is excluded by
|
Runs CRX + iDRegEx, picks best by scoring. kORE is excluded by
|
||||||
default (slow, rarely wins on real data). Set prefer='koreinference'
|
default (slow, rarely wins on real data). Set prefer='koreinference'
|
||||||
to force it.
|
to force it.
|
||||||
|
|
||||||
|
|
@ -60,16 +61,16 @@ def infer_best_grammar(
|
||||||
r+ = one or more, r+? = zero or more.
|
r+ = one or more, r+? = zero or more.
|
||||||
"""
|
"""
|
||||||
pref = prefer if prefer else None
|
pref = prefer if prefer else None
|
||||||
result = infer_ensemble(sequences, kmax=kmax, N=N, prefer=pref, min_coverage=min_coverage)
|
result = infer_ensemble(sequences, kmax=kmax, N=N, prefer=pref, min_coverage=min_coverage, method=method)
|
||||||
if result['best'] is None:
|
if result['best'] is None:
|
||||||
return f"No grammar found. {result['why']}"
|
return f"No grammar found. {result['why']}"
|
||||||
lines = [f"Best: {result['best']['algorithm']} (MDL {result['best']['mdl_score']})",
|
lines = [f"Best: {result['best']['algorithm']} (Score {result['best']['mdl_score']})",
|
||||||
f"Grammar: {result['best']['grammar']}",
|
f"Grammar: {result['best']['grammar']}",
|
||||||
""]
|
""]
|
||||||
if len(result['all']) > 1:
|
if len(result['all']) > 1:
|
||||||
for r in result['all']:
|
for r in result['all']:
|
||||||
m = sum(1 for s in sequences if _matches(r['grammar'], s))
|
m = sum(1 for s in sequences if _matches(r['grammar'], s))
|
||||||
lines.append(f" {r['algorithm']:10s} MDL={r['mdl_score']:>8.2f} match={m}/{len(sequences)}")
|
lines.append(f" {r['algorithm']:10s} Score={r['mdl_score']:>8.2f} match={m}/{len(sequences)}")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(f"Why: {result['why']}")
|
lines.append(f"Why: {result['why']}")
|
||||||
if 'core' in result and result['core']:
|
if 'core' in result and result['core']:
|
||||||
|
|
@ -94,6 +95,7 @@ def analyze_directory(
|
||||||
main_only: bool = False,
|
main_only: bool = False,
|
||||||
max_mdl: float = 200,
|
max_mdl: float = 200,
|
||||||
persist: bool = True,
|
persist: bool = True,
|
||||||
|
method: str = "langsize",
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Scan a source code directory and infer behavioral conventions
|
"""Scan a source code directory and infer behavioral conventions
|
||||||
(regular expression grammars) per package. Returns compact patterns
|
(regular expression grammars) per package. Returns compact patterns
|
||||||
|
|
@ -114,20 +116,22 @@ def analyze_directory(
|
||||||
min_coverage: BEX core coverage threshold for outlier removal
|
min_coverage: BEX core coverage threshold for outlier removal
|
||||||
(0.5–1.0). Default 0.8.
|
(0.5–1.0). Default 0.8.
|
||||||
prefer: Optional — 'crx' for full vocabulary, 'idregex' for
|
prefer: Optional — 'crx' for full vocabulary, 'idregex' for
|
||||||
minimal core. Omit to auto-pick by MDL.
|
minimal core. Omit to auto-pick by scoring.
|
||||||
kmax: Context depth for k-ORE inference. Default 2.
|
kmax: Context depth for k-ORE inference. Default 2.
|
||||||
include: Glob pattern to include only matching files.
|
include: Glob pattern to include only matching files.
|
||||||
exclude: Glob pattern to skip matching files.
|
exclude: Glob pattern to skip matching files.
|
||||||
main_only: When True, exclude test files (src/test/**, *Test.*,
|
main_only: When True, exclude test files (src/test/**, *Test.*,
|
||||||
etc.). Default False.
|
etc.). Default False.
|
||||||
max_mdl: Drop groups with MDL above this threshold. Default 200.
|
max_mdl: Drop groups with score above this threshold. Default 200.
|
||||||
Lower = tighter patterns only. Set higher to see noisier groups.
|
Lower = tighter patterns only. Set higher to see noisier groups.
|
||||||
persist: When True (default), write results to
|
persist: When True (default), write results to
|
||||||
{directory}/.dervish/grammars.yml.
|
{directory}/.dervish/grammars.yml.
|
||||||
|
method: Scoring method — 'langsize' (default, Bex et al.) or
|
||||||
|
'mdl' (fallback).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
YAML string with grammars grouped by top-level module, sorted
|
YAML string with grammars grouped by top-level module, sorted
|
||||||
by MDL (tightest/most useful first).
|
by score (tightest/most useful first).
|
||||||
"""
|
"""
|
||||||
results = _analyze_directory(
|
results = _analyze_directory(
|
||||||
directory,
|
directory,
|
||||||
|
|
@ -138,6 +142,7 @@ def analyze_directory(
|
||||||
include=include or None,
|
include=include or None,
|
||||||
exclude=exclude or None,
|
exclude=exclude or None,
|
||||||
main_only=main_only,
|
main_only=main_only,
|
||||||
|
method=method,
|
||||||
)
|
)
|
||||||
yaml_content = _build_yaml_output(results, directory, max_mdl=max_mdl)
|
yaml_content = _build_yaml_output(results, directory, max_mdl=max_mdl)
|
||||||
if persist:
|
if persist:
|
||||||
|
|
|
||||||
46
bex/mdl.py
46
bex/mdl.py
|
|
@ -185,13 +185,57 @@ def data_cost(expr, sequences):
|
||||||
return total_cost
|
return total_cost
|
||||||
|
|
||||||
|
|
||||||
|
def lang_size_score(expr, sequences):
|
||||||
|
"""Language Size: Σ |L(r)|_len(seq) — sum of words at each sequence length.
|
||||||
|
|
||||||
|
From Bex et al. (arXiv:1004.2372), Section 4.3.1, adapted for our setting
|
||||||
|
where candidates have different n values.
|
||||||
|
|
||||||
|
Counts words at exactly the lengths present in the input sequences.
|
||||||
|
Lower is better — the grammar that accepts the fewest words at the
|
||||||
|
observed lengths wins. Generic grammars like `info+` accept many words
|
||||||
|
at each length; specific grammars like `a.b.c.d.e+` accept exactly one.
|
||||||
|
"""
|
||||||
|
if not sequences:
|
||||||
|
return lang_size(expr, 2 * model_cost(expr) + 1)
|
||||||
|
|
||||||
|
total = 0
|
||||||
|
for seq in sequences:
|
||||||
|
length = len(seq)
|
||||||
|
total += _count_words_fast(expr, length)
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
def mdl_score(expr, sequences):
|
def mdl_score(expr, sequences):
|
||||||
"""MDL = model cost + data cost."""
|
"""MDL = model cost + data cost. (Fallback, Bex et al. Section 4.3.2.)"""
|
||||||
model = model_cost(expr)
|
model = model_cost(expr)
|
||||||
data = data_cost(expr, sequences)
|
data = data_cost(expr, sequences)
|
||||||
return model + data
|
return model + data
|
||||||
|
|
||||||
|
|
||||||
|
_SCORERS = {
|
||||||
|
'langsize': lang_size_score,
|
||||||
|
'mdl': mdl_score,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def score_grammar(expr, sequences, method='langsize'):
|
||||||
|
"""Score a grammar using the specified method.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
expr: Grammar expression string.
|
||||||
|
sequences: List of sequences (each a list of strings).
|
||||||
|
method: 'langsize' (default, Bex et al.) or 'mdl' (fallback).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Numeric score (lower is better).
|
||||||
|
"""
|
||||||
|
fn = _SCORERS.get(method)
|
||||||
|
if fn is None:
|
||||||
|
raise ValueError(f"Unknown scoring method '{method}'. Choose from: {list(_SCORERS)}")
|
||||||
|
return fn(expr, sequences)
|
||||||
|
|
||||||
|
|
||||||
# For backward compatibility
|
# For backward compatibility
|
||||||
class MDLScorer:
|
class MDLScorer:
|
||||||
def score(self, expr, sequences):
|
def score(self, expr, sequences):
|
||||||
|
|
|
||||||
|
|
@ -245,7 +245,7 @@ def _preprocess_files(file_paths):
|
||||||
return sequences, seq_files
|
return sequences, seq_files
|
||||||
|
|
||||||
|
|
||||||
def analyze_clusters(file_paths, extension, project_root="", min_coverage=DEFAULT_COVERAGE, prefer=None, kmax=2, N=3, include_kore=False):
|
def analyze_clusters(file_paths, extension, project_root="", min_coverage=DEFAULT_COVERAGE, prefer=None, kmax=2, N=3, include_kore=False, method='langsize'):
|
||||||
"""Run full pipeline: preprocess → frequency filter → ensemble infer.
|
"""Run full pipeline: preprocess → frequency filter → ensemble infer.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -264,13 +264,13 @@ def analyze_clusters(file_paths, extension, project_root="", min_coverage=DEFAUL
|
||||||
packages = _top_packages(cluster_fps, project_root)
|
packages = _top_packages(cluster_fps, project_root)
|
||||||
|
|
||||||
symbol_seqs = [[text for _, text, _ in seq] for seq in sequences]
|
symbol_seqs = [[text for _, text, _ in seq] for seq in sequences]
|
||||||
result = infer_ensemble(symbol_seqs, kmax=kmax, N=N, prefer=prefer, min_coverage=min_coverage, include_kore=include_kore)
|
result = infer_ensemble(symbol_seqs, kmax=kmax, N=N, prefer=prefer, min_coverage=min_coverage, include_kore=include_kore, method=method)
|
||||||
meta = {"files": cluster_fps, "imports": imports, "arg_patterns": arg_patterns, "packages": packages}
|
meta = {"files": cluster_fps, "imports": imports, "arg_patterns": arg_patterns, "packages": packages}
|
||||||
|
|
||||||
return [("(all methods)", result, len(sequences), meta)]
|
return [("(all methods)", result, len(sequences), meta)]
|
||||||
|
|
||||||
|
|
||||||
def _infer_group(label, group_seqs, group_files, project_root, min_coverage, prefer, kmax, N, include_kore=False):
|
def _infer_group(label, group_seqs, group_files, project_root, min_coverage, prefer, kmax, N, include_kore=False, method='langsize'):
|
||||||
"""Infer grammar for one package group. Module-level for ProcessPoolExecutor."""
|
"""Infer grammar for one package group. Module-level for ProcessPoolExecutor."""
|
||||||
filtered = frequency_filter(group_seqs, min_coverage=0.2)
|
filtered = frequency_filter(group_seqs, min_coverage=0.2)
|
||||||
imports = _extract_imports(group_files)
|
imports = _extract_imports(group_files)
|
||||||
|
|
@ -282,7 +282,7 @@ def _infer_group(label, group_seqs, group_files, project_root, min_coverage, pre
|
||||||
return (label, result, len(filtered), meta)
|
return (label, result, len(filtered), meta)
|
||||||
|
|
||||||
|
|
||||||
def analyze_by_package(file_paths, extension, project_root="", min_coverage=DEFAULT_COVERAGE, prefer=None, kmax=2, N=3, min_pkg_size=3, include_kore=False):
|
def analyze_by_package(file_paths, extension, project_root="", min_coverage=DEFAULT_COVERAGE, prefer=None, kmax=2, N=3, min_pkg_size=3, include_kore=False, method='langsize'):
|
||||||
"""Preprocess and group by package directory, infer per group.
|
"""Preprocess and group by package directory, infer per group.
|
||||||
|
|
||||||
Groups methods by their file's relative directory path, merging
|
Groups methods by their file's relative directory path, merging
|
||||||
|
|
@ -317,7 +317,7 @@ def analyze_by_package(file_paths, extension, project_root="", min_coverage=DEFA
|
||||||
gs = [sequences[i] for i in indices]
|
gs = [sequences[i] for i in indices]
|
||||||
gf = set(seq_files[i] for i in indices)
|
gf = set(seq_files[i] for i in indices)
|
||||||
f = ex.submit(_infer_group, label, gs, gf, project_root,
|
f = ex.submit(_infer_group, label, gs, gf, project_root,
|
||||||
min_coverage, prefer, kmax, N, include_kore)
|
min_coverage, prefer, kmax, N, include_kore, method)
|
||||||
futures[f] = label
|
futures[f] = label
|
||||||
|
|
||||||
for f in as_completed(futures):
|
for f in as_completed(futures):
|
||||||
|
|
@ -335,7 +335,7 @@ def analyze_by_package(file_paths, extension, project_root="", min_coverage=DEFA
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
||||||
def infer(file_paths, extension, min_coverage=DEFAULT_COVERAGE, prefer=None, kmax=2, N=3, include_kore=False):
|
def infer(file_paths, extension, min_coverage=DEFAULT_COVERAGE, prefer=None, kmax=2, N=3, include_kore=False, method='langsize'):
|
||||||
"""Run full pipeline: preprocess → frequency filter → ensemble infer.
|
"""Run full pipeline: preprocess → frequency filter → ensemble infer.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
|
|
@ -354,7 +354,7 @@ def infer(file_paths, extension, min_coverage=DEFAULT_COVERAGE, prefer=None, kma
|
||||||
|
|
||||||
symbol_seqs = [[text for _, text, _ in seq] for seq in sequences]
|
symbol_seqs = [[text for _, text, _ in seq] for seq in sequences]
|
||||||
|
|
||||||
return infer_ensemble(symbol_seqs, kmax=kmax, N=N, prefer=prefer, min_coverage=min_coverage)
|
return infer_ensemble(symbol_seqs, kmax=kmax, N=N, prefer=prefer, min_coverage=min_coverage, method=method)
|
||||||
|
|
||||||
|
|
||||||
def _merge_up(pkg):
|
def _merge_up(pkg):
|
||||||
|
|
@ -426,6 +426,7 @@ def analyze_directory(
|
||||||
exclude=None,
|
exclude=None,
|
||||||
main_only=False,
|
main_only=False,
|
||||||
include_kore=False,
|
include_kore=False,
|
||||||
|
method='langsize',
|
||||||
):
|
):
|
||||||
"""Scan a directory and run analysis for each language found.
|
"""Scan a directory and run analysis for each language found.
|
||||||
|
|
||||||
|
|
@ -460,6 +461,7 @@ def analyze_directory(
|
||||||
prefer=prefer,
|
prefer=prefer,
|
||||||
kmax=kmax,
|
kmax=kmax,
|
||||||
include_kore=include_kore,
|
include_kore=include_kore,
|
||||||
|
method=method,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
results[ext] = analyze_clusters(
|
results[ext] = analyze_clusters(
|
||||||
|
|
@ -469,6 +471,7 @@ def analyze_directory(
|
||||||
prefer=prefer,
|
prefer=prefer,
|
||||||
kmax=kmax,
|
kmax=kmax,
|
||||||
include_kore=include_kore,
|
include_kore=include_kore,
|
||||||
|
method=method,
|
||||||
)
|
)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
|
@ -607,6 +610,14 @@ def _parse_args(argv=None):
|
||||||
"--verbose", action="store_true",
|
"--verbose", action="store_true",
|
||||||
help="Print progress to stderr",
|
help="Print progress to stderr",
|
||||||
)
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--main-only", action="store_true",
|
||||||
|
help="Exclude test files (src/test/**, *Test.*, etc.)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--scoring-method", choices=["langsize", "mdl"], default="langsize",
|
||||||
|
help="Scoring method: langsize (default, Bex et al.) or mdl (fallback)",
|
||||||
|
)
|
||||||
return parser.parse_args(argv)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -624,7 +635,9 @@ def main():
|
||||||
slice=args.slice,
|
slice=args.slice,
|
||||||
include=args.include,
|
include=args.include,
|
||||||
exclude=args.exclude,
|
exclude=args.exclude,
|
||||||
|
main_only=args.main_only,
|
||||||
include_kore=args.kore,
|
include_kore=args.kore,
|
||||||
|
method=args.scoring_method,
|
||||||
)
|
)
|
||||||
|
|
||||||
if args.json_flag or args.format == "json":
|
if args.json_flag or args.format == "json":
|
||||||
|
|
@ -639,7 +652,7 @@ def main():
|
||||||
print(f" ╰─ {label} ({count} methods)")
|
print(f" ╰─ {label} ({count} methods)")
|
||||||
print(f" Algorithm: {best['algorithm']}")
|
print(f" Algorithm: {best['algorithm']}")
|
||||||
print(f" Grammar: {best['grammar']}")
|
print(f" Grammar: {best['grammar']}")
|
||||||
print(f" MDL: {best['mdl_score']}")
|
print(f" Score: {best['mdl_score']}")
|
||||||
else:
|
else:
|
||||||
print(f" ╰─ {label} ({count} methods) — no grammar")
|
print(f" ╰─ {label} ({count} methods) — no grammar")
|
||||||
imps = meta.get("imports", [])
|
imps = meta.get("imports", [])
|
||||||
|
|
|
||||||
96
docs/adr/0013-language-size-scoring.md
Normal file
96
docs/adr/0013-language-size-scoring.md
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
# 13. Replace MDL scoring with Language Size measure
|
||||||
|
|
||||||
|
**Date:** 2026-07-11
|
||||||
|
|
||||||
|
**Status:** Accepted
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Our ensemble grammar inference uses a scoring function to select the best grammar
|
||||||
|
when multiple algorithms (CRX, iDRegEx, kORE) produce candidates. The scoring
|
||||||
|
function determines which grammar wins — so a bad scoring function means the
|
||||||
|
ensemble picks bad grammars, even when the algorithms produce good ones.
|
||||||
|
|
||||||
|
### The concrete problem
|
||||||
|
|
||||||
|
On real codebases, the ensemble would pick overly generic grammars like `info+`
|
||||||
|
over specific ones like `info.file.template.shell.service+`. The generic grammar
|
||||||
|
accepts an astronomically large language (every permutation of `info` at every
|
||||||
|
length), while the specific grammar accepts exactly one word of each length.
|
||||||
|
Any human would pick the specific one. Our scorer picked the generic one.
|
||||||
|
|
||||||
|
### Why MDL failed
|
||||||
|
|
||||||
|
We used Minimum Description Length (MDL): `score = model_cost + data_cost`,
|
||||||
|
where `model_cost = len(expr)` and `data_cost = Σ log₂(|L(r)| at seq length)`.
|
||||||
|
|
||||||
|
The problem: `model_cost('info+') = 1` (one symbol occurrence), while
|
||||||
|
`model_cost('info.file.template.shell.service+') = 5`. MDL rewards short
|
||||||
|
expressions. When `model_cost` is small relative to `data_cost`, short generic
|
||||||
|
patterns win even though they accept far more spurious words.
|
||||||
|
|
||||||
|
### The paper that fixed it
|
||||||
|
|
||||||
|
Bex, Goethals, Penninckx, Van Gucht, and Van den Bussche published
|
||||||
|
*"Learning Deterministic Regular Expressions for the Inference of Schemas
|
||||||
|
from XML Data"* (arXiv:1004.2372, also VLDB 2007). They proposed the iDRegEx
|
||||||
|
algorithm and evaluated two scoring measures:
|
||||||
|
|
||||||
|
1. **Language Size** (Section 4.3.1): select the expression that accepts the
|
||||||
|
**fewest words** up to length `n = 2m + 1`. This directly measures specificity.
|
||||||
|
2. **MDL** (Section 4.3.2): model cost + data cost, based on Adriaans & Vitányi
|
||||||
|
(2006). This rewards short expressions.
|
||||||
|
|
||||||
|
Their results (Section 5, line 1470): on a corpus of synthetic regular expressions
|
||||||
|
with alphabet size 5, **Language Size achieved 98% success rate while MDL achieved
|
||||||
|
only 21%**. They explicitly abandoned MDL: *"Therefore in the remainder of this
|
||||||
|
section we only consider iDRegEx with the language size criterion."*
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Replace MDL with Language Size as the default scoring function. Keep MDL as a
|
||||||
|
fallback enabled via `scoring_method='mdl'` parameter.
|
||||||
|
|
||||||
|
### Implementation
|
||||||
|
|
||||||
|
```python
|
||||||
|
def lang_size_score(expr, sequences):
|
||||||
|
"""Language Size: Σ |L(r)|_len(seq) — words at each sequence length.
|
||||||
|
|
||||||
|
From Bex et al. (arXiv:1004.2372), Section 4.3.1.
|
||||||
|
Lower is better — the grammar that accepts the fewest words wins.
|
||||||
|
"""
|
||||||
|
if not sequences:
|
||||||
|
return lang_size(expr, 2 * model_cost(expr) + 1)
|
||||||
|
total = 0
|
||||||
|
for seq in sequences:
|
||||||
|
total += _count_words_fast(expr, len(seq))
|
||||||
|
return total
|
||||||
|
```
|
||||||
|
|
||||||
|
Counts words at exactly the lengths present in the input sequences, not all
|
||||||
|
lengths 0..n. This is a practical adaptation of the paper's measure: the paper
|
||||||
|
evaluates all candidates at the same fixed n, but our candidates have different
|
||||||
|
n values. Counting at observed sequence lengths gives the same result — the
|
||||||
|
grammar accepting the fewest words at the relevant lengths wins.
|
||||||
|
|
||||||
|
The existing `_count_words_fast` function already computes the exact word count
|
||||||
|
needed by this measure. No new algorithms required — just a different aggregation.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
**Positive:**
|
||||||
|
- Ensemble picks specific grammars over generic ones (98% vs 21% on Bex's corpus)
|
||||||
|
- `_count_words_fast` already exists and is LRU-cached — zero new code for the hard part
|
||||||
|
- `model_cost` still used for tie-breaking (shortest expression when language sizes equal)
|
||||||
|
|
||||||
|
**Negative:**
|
||||||
|
- `lang_size` can be expensive for expressions with large alphabets (exponential
|
||||||
|
in worst case), but `n = 2*model_cost + 1` keeps it bounded in practice
|
||||||
|
- Old MDL results stored in YAML will have different scores than new runs —
|
||||||
|
not a compatibility issue since scores are internal selection criteria, not persisted
|
||||||
|
|
||||||
|
**Migration:**
|
||||||
|
- `mdl_score` preserved as fallback: `scoring_method='mdl'`
|
||||||
|
- `mdl_score_simple` in ensemble.py updated to use `lang_size_score` by default
|
||||||
|
- CLI flag `--scoring-method` controls which is used (default: `langsize`)
|
||||||
224
docs/language-size-scoring-analysis.md
Normal file
224
docs/language-size-scoring-analysis.md
Normal file
|
|
@ -0,0 +1,224 @@
|
||||||
|
# Language Size Scoring: Analysis and Design Notes
|
||||||
|
|
||||||
|
## Date: 2026-07-11
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Our ensemble grammar inference uses a scoring function to select the best grammar
|
||||||
|
when multiple algorithms (CRX, iDRegEx, kORE) produce candidates. The scoring
|
||||||
|
function determines which grammar wins.
|
||||||
|
|
||||||
|
The concrete problem: on real codebases, the ensemble picked overly generic
|
||||||
|
grammars like `info+` over specific ones like `info.file.template.shell.service+`.
|
||||||
|
The generic grammar accepts an astronomically large language (every repetition
|
||||||
|
of `info` at every length), while the specific grammar accepts exactly one word
|
||||||
|
of each length ≥ 5. Any human would pick the specific one. Our scorer picked
|
||||||
|
the generic one.
|
||||||
|
|
||||||
|
## Root Cause Analysis
|
||||||
|
|
||||||
|
### What MDL Measures
|
||||||
|
|
||||||
|
Our old scoring function was MDL (Minimum Description Length):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def mdl_score(expr, sequences):
|
||||||
|
model = model_cost(expr) # number of symbol occurrences in expression
|
||||||
|
data = data_cost(expr, sequences) # Σ log₂(|L(r)| at seq length)
|
||||||
|
return model + data
|
||||||
|
```
|
||||||
|
|
||||||
|
`model_cost` counts how many times alphabet symbols appear in the expression.
|
||||||
|
For `info+`, that's 1 (the symbol `info` appears once). For
|
||||||
|
`info.file.template.shell.service+`, that's 5. MDL rewards short expressions.
|
||||||
|
|
||||||
|
`data_cost` sums `log₂(|L(r)|_len(seq))` over all sequences. Both `info+` and
|
||||||
|
the specific grammar accept exactly 1 word at length 5, so both get
|
||||||
|
`data_cost = 5 × log₂(1) = 0`.
|
||||||
|
|
||||||
|
Result: `mdl_score('info+') = 1 + 0 = 1.0`, `mdl_score('specific') = 5 + 0 = 5.0`.
|
||||||
|
MDL picks `info+` because its model cost is tiny.
|
||||||
|
|
||||||
|
### Why MDL Fails Here
|
||||||
|
|
||||||
|
MDL combines two signals: expression length (model cost) and compression quality
|
||||||
|
(data cost). When model cost dominates (as it does when data cost is 0 for exact
|
||||||
|
matches), short generic patterns win. This is the same problem the Bex paper
|
||||||
|
identified: MDL achieves only 21% success rate vs 98% for Language Size.
|
||||||
|
|
||||||
|
## The Bex Paper's Language Size Measure
|
||||||
|
|
||||||
|
### Paper: arXiv 1004.2372, Section 4.3.1
|
||||||
|
|
||||||
|
Bex, Goethals, Penninckx, Van Gucht, Van den Bussche published
|
||||||
|
"Learning Deterministic Regular Expressions for the Inference of Schemas
|
||||||
|
from XML Data" (VLDB 2007, arXiv:1004.2372).
|
||||||
|
|
||||||
|
They proposed the iDRegEx algorithm and evaluated two scoring measures:
|
||||||
|
|
||||||
|
1. **Language Size** (Section 4.3.1): select the expression that accepts the
|
||||||
|
**fewest words** up to length n.
|
||||||
|
2. **MDL** (Section 4.3.2): model cost + data cost, based on Adriaans & Vitányi
|
||||||
|
(2006).
|
||||||
|
|
||||||
|
Their result (Section 5, line 1470): on a corpus of synthetic regular expressions
|
||||||
|
with alphabet size 5, **Language Size achieved 98% success rate while MDL achieved
|
||||||
|
only 21%**. They explicitly abandoned MDL: *"Therefore in the remainder of this
|
||||||
|
section we only consider iDRegEx with the language size criterion."*
|
||||||
|
|
||||||
|
### The Paper's Formula
|
||||||
|
|
||||||
|
The paper defines:
|
||||||
|
|
||||||
|
> "We therefore only consider the words up to a length n, where n = 2m + 1
|
||||||
|
> with m the length of the candidate expression, excluding regular expression
|
||||||
|
> operators, ∅, and ε."
|
||||||
|
>
|
||||||
|
> "Then the best candidate in C is the one with the least value of |L(r)≤n|."
|
||||||
|
|
||||||
|
Concretely: for candidate `r`, compute `m = model_cost(r)` (symbol occurrences
|
||||||
|
only, no operators), then `n = 2m + 1`. Count all words in `L(r)` of length ≤ n.
|
||||||
|
Pick the candidate with the smallest count. Tie-break: pick the shortest expression.
|
||||||
|
|
||||||
|
### Why The Paper's Formula Works In Their Setting
|
||||||
|
|
||||||
|
The paper evaluates candidates against a **known target**. The experiment is:
|
||||||
|
|
||||||
|
1. Start with a target expression (e.g. `a.b.c`)
|
||||||
|
2. Generate sample S from the target (words the target accepts)
|
||||||
|
3. Run iDRegEx on S to produce candidate set C
|
||||||
|
4. Score each candidate, pick the best
|
||||||
|
5. Check if best matches the target
|
||||||
|
|
||||||
|
The critical detail: **all candidates are derived from the same target**, so they
|
||||||
|
have similar `model_cost` values and similar `n`. At the same `n`, the correct
|
||||||
|
grammar accepts far fewer words than generic alternatives:
|
||||||
|
|
||||||
|
```
|
||||||
|
Candidate m n=2m+1 |L≤n|
|
||||||
|
───────────────────────────── ── ─────── ────
|
||||||
|
a.b.c 3 7 1
|
||||||
|
(a+b+c)+ 3 7 3,279
|
||||||
|
a.a.a 3 7 1
|
||||||
|
```
|
||||||
|
|
||||||
|
At the same n=7, the correct grammar wins by a landslide (1 vs 3,279).
|
||||||
|
Tie-breaking by shortest expression handles the `a.a.a` overfit case.
|
||||||
|
|
||||||
|
### Why Per-Candidate n Breaks In Our Setting
|
||||||
|
|
||||||
|
In our setting, we have **no known target**. Candidates come from different
|
||||||
|
algorithms (CRX, iDRegEx) and have different `model_cost` values. When we use
|
||||||
|
per-candidate `n`:
|
||||||
|
|
||||||
|
```
|
||||||
|
Candidate m n=2m+1 |L≤n|
|
||||||
|
────────────────────────────────── ── ─────── ────
|
||||||
|
info+ 1 3 3
|
||||||
|
info.file.template.shell.service+ 5 11 7
|
||||||
|
```
|
||||||
|
|
||||||
|
`info+` wins (3 < 7) — not because it's better, but because it's evaluated on
|
||||||
|
a smaller range (lengths 0..3 vs 0..11). The generic grammar gets a free pass
|
||||||
|
by having a smaller `n`.
|
||||||
|
|
||||||
|
This is the same bug as MDL: `model_cost('info+') = 1` is tiny, so MDL also
|
||||||
|
picks `info+`. Different disguise, same problem.
|
||||||
|
|
||||||
|
## Our Adaptation
|
||||||
|
|
||||||
|
### What We Changed
|
||||||
|
|
||||||
|
Instead of counting at 0..n (per-candidate), we count at **exactly the lengths
|
||||||
|
present in the data**:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def lang_size_score(expr, sequences):
|
||||||
|
if not sequences:
|
||||||
|
return lang_size(expr, 2 * model_cost(expr) + 1) # paper's formula
|
||||||
|
total = 0
|
||||||
|
for seq in sequences:
|
||||||
|
total += _count_words_fast(expr, len(seq))
|
||||||
|
return total
|
||||||
|
```
|
||||||
|
|
||||||
|
Both grammars are evaluated on the same lengths (the observed data). The grammar
|
||||||
|
accepting the fewest words at those lengths genuinely wins.
|
||||||
|
|
||||||
|
### Why This Is Correct
|
||||||
|
|
||||||
|
The Language Size measure answers: "which grammar adds the fewest spurious words
|
||||||
|
to the data?" When we count at the data's lengths, we measure exactly this:
|
||||||
|
how many words does the grammar accept at the lengths we actually observe?
|
||||||
|
|
||||||
|
A grammar that accepts many words at each observed length (like `(a+b+c)+`)
|
||||||
|
adds many spurious words. A grammar that accepts few words (like `a.b.c`) adds
|
||||||
|
few spurious words. The one adding the fewest is the most specific to the data.
|
||||||
|
|
||||||
|
### When The Paper's Formula And Our Adaptation Agree
|
||||||
|
|
||||||
|
With diverse sequence lengths, both approaches agree:
|
||||||
|
|
||||||
|
```
|
||||||
|
Sequences: [['a','b','c'], ['a','b'], ['a','c'], ['b','c']]
|
||||||
|
|
||||||
|
Candidate Paper |L≤n| Our score
|
||||||
|
───────────────────── ──────────── ─────────
|
||||||
|
a.b.c 1 1
|
||||||
|
(a+b+c)+ 3,279 54
|
||||||
|
a.(b+c)? 3 6
|
||||||
|
```
|
||||||
|
|
||||||
|
Both rank `a.b.c` best. Good.
|
||||||
|
|
||||||
|
### When They Disagree
|
||||||
|
|
||||||
|
With the `info+` scenario (per-candidate n):
|
||||||
|
|
||||||
|
```
|
||||||
|
Sequences: 5x ['info', 'file', 'template', 'shell', 'service']
|
||||||
|
|
||||||
|
Candidate Paper |L≤n| Our score
|
||||||
|
────────────────────────────────── ──────────── ─────────
|
||||||
|
info+ 3 5
|
||||||
|
info.file.template.shell.service+ 7 5
|
||||||
|
```
|
||||||
|
|
||||||
|
Paper picks `info+` (3 < 7) — WRONG. Our adaptation ties (5 = 5) — HONEST.
|
||||||
|
|
||||||
|
### The Remaining Limitation
|
||||||
|
|
||||||
|
When all sequences have the same length (e.g. all length 5), both `info+` and
|
||||||
|
the specific grammar accept exactly 1 word at length 5. They tie — the scoring
|
||||||
|
metric can't distinguish them. This is **honest**: neither grammar is better
|
||||||
|
for this data.
|
||||||
|
|
||||||
|
The issue is in the **inference step** (CRX producing equivalent grammars for
|
||||||
|
identical sequences), not the scoring step. In practice, CRX produces
|
||||||
|
`info.file.template.shell.service` (no `+`) for5 identical sequences, so
|
||||||
|
`info+` never appears as a candidate.
|
||||||
|
|
||||||
|
With diverse sequence lengths, our adaptation correctly differentiates:
|
||||||
|
`info+` accepts 1 word at each length (total = number of sequences), while
|
||||||
|
`(a+b+c)+` accepts many words at each length (total = Σ alphabet_size^length).
|
||||||
|
|
||||||
|
## Test Matrix
|
||||||
|
|
||||||
|
| Scenario | Sequences | Expected winner | Why |
|
||||||
|
|----------|-----------|----------------|-----|
|
||||||
|
| Specific vs generic, diverse lengths | `['a','b','c'], ['a','b'], ['a','c']` | `a.b.c` | Accepts 1 word at length 3, 0 at lengths 1-2 |
|
||||||
|
| Specific vs generic, identical lengths | 5x `['info','file','template','shell','service']` | TIE | Both accept 1 word at length 5 |
|
||||||
|
| Generic vs more generic | `['a','b','c']` × 5 | `(a+b+c)+` wins over `a+` | `a+` accepts 1 word at each length, `(a+b+c)+` accepts 3^L |
|
||||||
|
| MDL failure case | `['info','file','template','shell','service']` × 5 | `info+` wins MDL, TIE on langsize | MDL rewards short expressions |
|
||||||
|
| Empty sequences | `[]` | Falls back to paper formula | No data to evaluate at |
|
||||||
|
| Single sequence | `[['a','b','c']]` | `a.b.c` wins | Accepts 1 word at length 3 |
|
||||||
|
| Long sequences | `[['a','b','c','d','e']]` | `a.b.c.d.e` wins | Accepts 1 word at length 5 |
|
||||||
|
|
||||||
|
## Files Changed
|
||||||
|
|
||||||
|
- `bex/mdl.py`: Added `lang_size_score`, `score_grammar`, `_SCORERS` registry
|
||||||
|
- `bex/ensemble.py`: `mdl_score_simple` uses `langsize` by default, `infer_ensemble` accepts `method=`
|
||||||
|
- `bex/mcp_server.py`: Both tools accept `method` parameter
|
||||||
|
- `bex/tag_preprocessor/analyze.py`: Full call chain threads `method` through
|
||||||
|
- `docs/adr/0013-language-size-scoring.md`: ADR documenting the decision
|
||||||
|
- `tests/test_kore.py`: 5 new tests for Language Size scoring
|
||||||
|
|
@ -299,6 +299,56 @@ def test_mdl_empty_sequences():
|
||||||
assert score == model_cost('a.b.c')
|
assert score == model_cost('a.b.c')
|
||||||
|
|
||||||
|
|
||||||
|
# ── Language Size scoring tests (Bex et al. arXiv:1004.2372 §4.3.1) ──
|
||||||
|
|
||||||
|
def test_lang_size_score_basic():
|
||||||
|
from bex.mdl import lang_size_score
|
||||||
|
# Specific grammar: accepts 1 word of each length ≥ 3
|
||||||
|
specific = lang_size_score('a.b.c', [['a', 'b', 'c']])
|
||||||
|
# Generic grammar: accepts many words at each length
|
||||||
|
generic = lang_size_score('(a+b+c)+', [['a', 'b', 'c']])
|
||||||
|
assert specific < generic, f"Specific ({specific}) should score lower than generic ({generic})"
|
||||||
|
|
||||||
|
|
||||||
|
def test_lang_size_prefers_specific_over_info_plus():
|
||||||
|
"""Generic grammar accepts many words at each length; specific accepts few."""
|
||||||
|
from bex.mdl import lang_size_score
|
||||||
|
# Diverse sequences — specific grammar accepts 1 word at each length,
|
||||||
|
# generic (a+b+c)+ accepts many.
|
||||||
|
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_score_grammar_method_switch():
|
||||||
|
from bex.mdl import score_grammar
|
||||||
|
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():
|
||||||
|
from bex.mdl import score_grammar
|
||||||
|
try:
|
||||||
|
score_grammar('a.b.c', [['a']], method='bogus')
|
||||||
|
assert False, "Should have raised ValueError"
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_ensemble_method_param():
|
||||||
|
"""Ensemble accepts method= parameter and passes it through."""
|
||||||
|
from bex.ensemble import infer_ensemble
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
# ── Algorithm 4 paper-faithful tests ──
|
# ── Algorithm 4 paper-faithful tests ──
|
||||||
|
|
||||||
def test_infer_returns_deterministic():
|
def test_infer_returns_deterministic():
|
||||||
|
|
@ -354,6 +404,11 @@ def run_all():
|
||||||
test_mdl_data_cost,
|
test_mdl_data_cost,
|
||||||
test_mdl_score_lower_is_better,
|
test_mdl_score_lower_is_better,
|
||||||
test_mdl_empty_sequences,
|
test_mdl_empty_sequences,
|
||||||
|
test_lang_size_score_basic,
|
||||||
|
test_lang_size_prefers_specific_over_info_plus,
|
||||||
|
test_score_grammar_method_switch,
|
||||||
|
test_score_grammar_invalid_method,
|
||||||
|
test_ensemble_method_param,
|
||||||
test_infer_returns_deterministic,
|
test_infer_returns_deterministic,
|
||||||
test_infer_obeys_k_occurrence,
|
test_infer_obeys_k_occurrence,
|
||||||
]
|
]
|
||||||
|
|
|
||||||
364
tests/test_scoring.py
Normal file
364
tests/test_scoring.py
Normal file
|
|
@ -0,0 +1,364 @@
|
||||||
|
"""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']}"
|
||||||
|
)
|
||||||
Loading…
Add table
Reference in a new issue