feat: grammar_structure_score + min_structure filter

Quantifies how structured a SORE is (0.0=flat bag, 1.0=fully structured).
Flat bags are CRX over-approximations — they list symbols without ordering.

With min_structure=0.2:
  Flask:   2 kept (was 5),  9 dropped
  RAGSAK: 10 kept (was 19), 114 dropped
  FastAPI: 47 kept (was 106), 95 dropped
  Total: 59 useful grammars, 218 noise removed

CLI: --min-structure 0.2 (default: 0, keep all)
This commit is contained in:
tobjend 2026-07-12 03:02:50 +02:00
parent 92af932e9d
commit e62fffc6e0
5 changed files with 669 additions and 411 deletions

View file

@ -383,3 +383,48 @@ def validate_sore(sore):
return True, None
except ValueError as e:
return False, str(e)
def grammar_structure_score(sore):
"""Quantify how structured a SORE is (0.0 = flat bag, 1.0 = fully structured).
Structured grammars have ordering (concatenation, optional, repetition)
that tells you the SEQUENCE things happen. Flat bags just list symbols.
"""
import re
if not sore or sore in ('', 'ε'):
return 0.0
depth = 0
dots = 0
pluses_outside = 0
questions = 0
stars = 0
for ch in sore:
if ch == '(':
depth += 1
elif ch == ')':
depth -= 1
elif ch == '.' and depth == 0:
dots += 1
elif ch == '+' and depth == 0:
pluses_outside += 1
elif ch == '?' and depth == 0:
questions += 1
elif ch == '*' and depth == 0:
stars += 1
disj_parts = 0
for m in re.finditer(r'\(([^)]+)\)', sore):
inner = m.group(1)
if '+' in inner:
disj_parts = max(disj_parts, inner.count('+') + 1)
symbols_only = re.sub(r'[.?*+()]', '', sore)
sym_len = len(symbols_only)
if sym_len == 0:
return 0.0
struct_ops = dots + questions + stars + pluses_outside
struct_ratio = struct_ops / max(sym_len, 1)
disj_ratio = disj_parts / max(sym_len, 1)
score = min(1.0, struct_ratio * 3)
if disj_ratio > 0.5 and dots == 0:
score *= 0.3
return score

View file

@ -20,7 +20,7 @@ import pathspec
from .code import preprocess_by_method, extract_arg_info, _summarize_arg_info
from bex.ensemble import infer_ensemble
from bex.gbnf import validate_sore
from bex.gbnf import validate_sore, grammar_structure_score
SUPPORTED_EXTENSIONS = {
".py", ".js", ".ts", ".kt", ".rb", ".go", ".rs", ".java", ".c", ".cpp",
@ -283,7 +283,7 @@ def analyze_clusters(file_paths, extension, project_root="", min_coverage=DEFAUL
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, include_idregex=False, method='langsize', min_methods=5, crx_method='standard'):
def _infer_group(label, group_seqs, group_files, project_root, min_coverage, prefer, kmax, N, include_kore=False, include_idregex=False, method='langsize', min_methods=5, crx_method='standard', min_structure=0.0):
"""Infer grammar for one package group. Module-level for ProcessPoolExecutor."""
filtered = frequency_filter(group_seqs, min_coverage=min_coverage)
imports = _extract_imports(group_files)
@ -316,16 +316,20 @@ def _infer_group(label, group_seqs, group_files, project_root, min_coverage, pre
# Validate grammar is parseable
if result and result.get('best') and result['best'].get('grammar'):
ok, err = validate_sore(result['best']['grammar'])
grammar = result['best']['grammar']
ok, err = validate_sore(grammar)
if not ok:
meta = {"files": group_files, "imports": imports, "arg_patterns": arg_patterns, "packages": packages, "skip_reason": "malformed_grammar", "skip_detail": err}
return (label, None, len(filtered), meta)
if min_structure > 0 and grammar_structure_score(grammar) < min_structure:
meta = {"files": group_files, "imports": imports, "arg_patterns": arg_patterns, "packages": packages, "skip_reason": "low_structure", "structure_score": grammar_structure_score(grammar)}
return (label, None, len(filtered), meta)
meta = {"files": group_files, "imports": imports, "arg_patterns": arg_patterns, "packages": packages}
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, include_idregex=False, method='langsize', min_methods=5, crx_method='standard'):
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, include_idregex=False, method='langsize', min_methods=5, crx_method='standard', min_structure=0.0):
"""Preprocess and group by package directory, infer per group.
Groups methods by their file's relative directory path, merging
@ -360,7 +364,7 @@ def analyze_by_package(file_paths, extension, project_root="", min_coverage=DEFA
gs = [sequences[i] for i in indices]
gf = set(seq_files[i] for i in indices)
f = ex.submit(_infer_group, label, gs, gf, project_root,
min_coverage, prefer, kmax, N, include_kore, include_idregex, method, min_methods, crx_method)
min_coverage, prefer, kmax, N, include_kore, include_idregex, method, min_methods, crx_method, min_structure)
futures[f] = label
for f in as_completed(futures):
@ -473,6 +477,7 @@ def analyze_directory(
method='langsize',
min_methods=5,
crx_method='standard',
min_structure=0.0,
):
"""Scan a directory and run analysis for each language found.
@ -511,6 +516,7 @@ def analyze_directory(
method=method,
min_methods=min_methods,
crx_method=crx_method,
min_structure=min_structure,
)
else:
results[ext] = analyze_clusters(
@ -680,6 +686,10 @@ def _parse_args(argv=None):
"--crx-method", choices=["standard", "refined"], default="standard",
help="CRX method: standard (default) or refined (cluster-then-infer, tighter grammars)",
)
parser.add_argument(
"--min-structure", type=float, default=0.0,
help="Minimum structure score (0.0-1.0) to keep grammar. Flat bags of symbols below this are dropped (default: 0, keep all)",
)
return parser.parse_args(argv)
@ -703,6 +713,7 @@ def main():
method=args.scoring_method,
min_methods=args.min_methods,
crx_method=args.crx_method,
min_structure=args.min_structure,
)
if args.json_flag or args.format == "json":

File diff suppressed because it is too large Load diff

View file

@ -3,66 +3,71 @@
"package": "examples/celery/src/task_app",
"ext": ".py",
"methods": 11,
"skip": "too_diverse"
"skip": "too_diverse",
"structure_score": 0
},
{
"package": "examples/javascript/tests",
"ext": ".py",
"methods": 5,
"skip": "too_diverse"
"skip": "too_diverse",
"structure_score": 0
},
{
"package": "examples/tutorial/flaskr",
"ext": ".py",
"methods": 18,
"skip": "too_diverse"
"skip": "too_diverse",
"structure_score": 0
},
{
"package": "examples/tutorial/tests",
"ext": ".py",
"methods": 25,
"skip": "too_diverse"
"skip": "too_diverse",
"structure_score": 0
},
{
"package": "src/flask",
"ext": ".py",
"methods": 216,
"skip": "too_diverse"
"skip": "too_diverse",
"structure_score": 0
},
{
"package": "src/flask/json",
"ext": ".py",
"methods": 50,
"sore": "(TypeError+UUID+__html__+_app+_default+_json+_prepare_response_obj+_untag_scan+and+args+current_app+dict+dumps+else+for+fp+http_date+if+in+is+isinstance+item+items+iter+json+k+key+kwargs+len+list+loads+mimetype+next+not+obj+or+order+raise+response_class+return+s+self+serializer+setdefault+str+tag+tags+tuple+v+value)+.NotImplementedError+?",
"gbnf": "root ::= (\"TypeError\" | \"UUID\" | \"__html__\" | \"_app\" | \"_default\" | \"_json\" | \"_prepare_response_obj\" | \"_untag_scan\" | \"and\" | \"args\" | \"current_app\" | \"dict\" | \"dumps\" | \"else\" | \"for\" | \"fp\" | \"http_date\" | \"if\" | \"in\" | \"is\" | \"isinstance\" | \"item\" | \"items\" | \"iter\" | \"json\" | \"k\" | \"key\" | \"kwargs\" | \"len\" | \"list\" | \"loads\" | \"mimetype\" | \"next\" | \"not\" | \"obj\" | \"or\" | \"order\" | \"raise\" | \"response_class\" | \"return\" | \"s\" | \"self\" | \"serializer\" | \"setdefault\" | \"str\" | \"tag\" | \"tags\" | \"tuple\" | \"v\" | \"value\")+ \"NotImplementedError\"*",
"gbnf_ok": true
"skip": "low_structure",
"structure_score": 0.042105263157894736
},
{
"package": "src/flask/sansio",
"ext": ".py",
"methods": 102,
"sore": "(Any+BlueprintSetupState+ValueError+__name__+_method_route+add_url_rule+and+app+append+callable+code+config+decorator+def+dict+else+endpoint+f+for+func+if+import_name+in+is+is not+lambda+name+not+options+or+os+path+raise+record_once+return+root_path+rstrip+rule+s+self+setdefault+state+static_folder+str+t+url_default_functions+value+values+view_func+view_functions)+",
"gbnf": "root ::= (\"Any\" | \"BlueprintSetupState\" | \"ValueError\" | \"__name__\" | \"_method_route\" | \"add_url_rule\" | \"and\" | \"app\" | \"append\" | \"callable\" | \"code\" | \"config\" | \"decorator\" | \"def\" | \"dict\" | \"else\" | \"endpoint\" | \"f\" | \"for\" | \"func\" | \"if\" | \"import_name\" | \"in\" | \"is\" | \"is not\" | \"lambda\" | \"name\" | \"not\" | \"options\" | \"or\" | \"os\" | \"path\" | \"raise\" | \"record_once\" | \"return\" | \"root_path\" | \"rstrip\" | \"rule\" | \"s\" | \"self\" | \"setdefault\" | \"state\" | \"static_folder\" | \"str\" | \"t\" | \"url_default_functions\" | \"value\" | \"values\" | \"view_func\" | \"view_functions\")+",
"gbnf_ok": true
"skip": "low_structure",
"structure_score": 0.009523809523809525
},
{
"package": "tests",
"ext": ".py",
"methods": 962,
"sore": "(@+Blueprint+__name__+app+append+class+client+config+data+def+e+flask+get+in+index+isinstance+not+raise+render_template+request+return+route+rv+s+self+session+value)+",
"gbnf": "root ::= (\"@\" | \"Blueprint\" | \"__name__\" | \"app\" | \"append\" | \"class\" | \"client\" | \"config\" | \"data\" | \"def\" | \"e\" | \"flask\" | \"get\" | \"in\" | \"index\" | \"isinstance\" | \"not\" | \"raise\" | \"render_template\" | \"request\" | \"return\" | \"route\" | \"rv\" | \"s\" | \"self\" | \"session\" | \"value\")+",
"gbnf_ok": true
"skip": "low_structure",
"structure_score": 0.021897810218978103
},
{
"package": "tests/test_apps",
"ext": ".py",
"methods": 3,
"skip": "too_diverse"
"skip": "too_diverse",
"structure_score": 0
},
{
"package": "tests/test_apps/blueprintapp/apps",
"ext": ".py",
"methods": 4,
"skip": "",
"structure_score": 0.2857142857142857,
"sore": "return.render_template+",
"gbnf": "root ::= \"return\" \"render_template\"+",
"gbnf_ok": true
@ -71,13 +76,17 @@
"package": "tests/type_check",
"ext": ".py",
"methods": 24,
"sore": "def?.(Generator+encode+for+in+iter+range+return+show+str+t+x+yield)+?.(Response+code+jsonify)+?.render_template+?.HTTPStatus?.stream_template+?.self?.OK?.name+?.template_name+?",
"gbnf": "root ::= \"def\"? (\"Generator\" | \"encode\" | \"for\" | \"in\" | \"iter\" | \"range\" | \"return\" | \"show\" | \"str\" | \"t\" | \"x\" | \"yield\")* (\"Response\" | \"code\" | \"jsonify\")* \"render_template\"* \"HTTPStatus\"? \"stream_template\"* \"self\"? \"OK\"? \"name\"* \"template_name\"*",
"skip": "",
"structure_score": 0.5597014925373135,
"sore": "def?.(Generator+encode+for+in+iter+range+return+show+str+t+x+yield)+?.stream_template+?.(Response+code+jsonify)+?.render_template+?.HTTPStatus?.name+?.self?.OK?.template_name+?",
"gbnf": "root ::= \"def\"? (\"Generator\" | \"encode\" | \"for\" | \"in\" | \"iter\" | \"range\" | \"return\" | \"show\" | \"str\" | \"t\" | \"x\" | \"yield\")* \"stream_template\"* (\"Response\" | \"code\" | \"jsonify\")* \"render_template\"* \"HTTPStatus\"? \"name\"* \"self\"? \"OK\"? \"template_name\"*",
"gbnf_ok": true
},
{
"package": "(other)",
"ext": ".py",
"methods": 4
"methods": 4,
"skip": "",
"structure_score": 0
}
]

File diff suppressed because it is too large Load diff