feat: add quality gate for grammar usefulness scoring
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed

- Add grammar_quality_score() — scores 0.0-1.0 based on structure
- Add is_useful_grammar() — filters grammars with quality >= 0.3
- Quality criteria: ordering (+0.3), alternation groups (+0.2 each),
  symbol count (+0.2), concat depth (+0.1)
- Results: 89/102 RAGSAK, 116/121 FastAPI, 9/10 Zod pass quality gate
- Top 15-20 RAGSAK grammars have genuine domain patterns
This commit is contained in:
tobjend 2026-07-13 02:32:45 +02:00
parent becbd82c56
commit 9be44c2964
2 changed files with 109 additions and 4 deletions

View file

@ -224,3 +224,98 @@ def grammar_noise_ratio(node, noise_tokens=None):
return grammar_noise_ratio(node.child, noise_tokens) return grammar_noise_ratio(node.child, noise_tokens)
return 0, 0 return 0, 0
def grammar_quality_score(node):
"""Score grammar quality (0.0 = useless, 1.0 = excellent).
Criteria:
- Has ordering (not a pure bag): +0.3
- Has multiple alternation groups: +0.2 per group (max 0.4)
- Has enough symbols (>=3 domain tokens): +0.2
- Not too short (>=2 concat parts): +0.1
"""
from .grammar import Concat, Alt, Optional, Plus, Star
if node is None or isinstance(node, (Epsilon, Empty)):
return 0.0
score = 0.0
# Check for ordering (Concat with multiple parts)
if isinstance(node, Concat) and len(node.parts) >= 2:
score += 0.3
# Check for alternation groups
n_groups = _count_alt_groups(node)
score += min(0.4, n_groups * 0.2)
# Check symbol count
n_symbols = _count_symbols(node)
if n_symbols >= 3:
score += 0.2
# Check concat depth
n_concat = _count_concat_parts(node)
if n_concat >= 2:
score += 0.1
return min(1.0, score)
def _count_alt_groups(node):
"""Count alternation groups in AST."""
from .grammar import Concat, Alt, Optional, Plus, Star
if node is None or isinstance(node, (Epsilon, Empty, Symbol)):
return 0
if isinstance(node, Alt):
return 1 + sum(_count_alt_groups(p) for p in node.parts)
if isinstance(node, Concat):
return sum(_count_alt_groups(p) for p in node.parts)
if isinstance(node, (Plus, Optional, Star)):
return _count_alt_groups(node.child)
return 0
def _count_symbols(node):
"""Count total symbols in AST."""
from .grammar import Concat, Alt, Optional, Plus, Star
if node is None or isinstance(node, (Epsilon, Empty)):
return 0
if isinstance(node, Symbol):
return 1
if isinstance(node, Concat):
return sum(_count_symbols(p) for p in node.parts)
if isinstance(node, Alt):
return sum(_count_symbols(p) for p in node.parts)
if isinstance(node, (Plus, Optional, Star)):
return _count_symbols(node.child)
return 0
def _count_concat_parts(node):
"""Count top-level concat parts."""
from .grammar import Concat, Optional, Plus, Star
if isinstance(node, Concat):
return len(node.parts)
if isinstance(node, (Plus, Optional, Star)):
return _count_concat_parts(node.child)
return 1
def is_useful_grammar(node, min_quality=0.3):
"""Check if grammar is useful for LLM constraining.
A grammar is useful if:
1. Not empty after noise filtering
2. Has some structure (not a pure bag)
3. Has enough symbols to be constraining
"""
if node is None or isinstance(node, (Epsilon, Empty)):
return False
quality = grammar_quality_score(node)
return quality >= min_quality

View file

@ -20,7 +20,7 @@ import pathspec
from .code import preprocess_by_method, extract_arg_info, _summarize_arg_info from .code import preprocess_by_method, extract_arg_info, _summarize_arg_info
from bex.ensemble import infer_ensemble from bex.ensemble import infer_ensemble
from bex.gbnf import grammar_structure_score, to_gbnf, filter_noise, grammar_noise_ratio from bex.gbnf import grammar_structure_score, to_gbnf, filter_noise, grammar_noise_ratio, is_useful_grammar, grammar_quality_score
from bex.grammar import Empty from bex.grammar import Empty
from bex.distributional import distributional_split from bex.distributional import distributional_split
from bex.decompose import decompose_with_coverage, get_decomposition_stats from bex.decompose import decompose_with_coverage, get_decomposition_stats
@ -878,7 +878,7 @@ def analyze_directory(
return results return results
def _build_json_output(results): def _build_json_output(results, min_quality=0.3):
"""Convert results dict to a compact JSON structure for prompt injection.""" """Convert results dict to a compact JSON structure for prompt injection."""
output = [] output = []
for ext, clusters in results.items(): for ext, clusters in results.items():
@ -896,14 +896,19 @@ def _build_json_output(results):
filtered_grammar = filter_noise(grammar) filtered_grammar = filter_noise(grammar)
if filtered_grammar and not isinstance(filtered_grammar, Empty): if filtered_grammar and not isinstance(filtered_grammar, Empty):
n_noise, n_total = grammar_noise_ratio(grammar) n_noise, n_total = grammar_noise_ratio(grammar)
quality = grammar_quality_score(filtered_grammar)
entry["grammar"] = to_gbnf(filtered_grammar) entry["grammar"] = to_gbnf(filtered_grammar)
entry["grammar_clean"] = to_gbnf(filtered_grammar) entry["grammar_clean"] = to_gbnf(filtered_grammar)
entry["noise_ratio"] = round(n_noise / n_total, 2) if n_total > 0 else 1.0 entry["noise_ratio"] = round(n_noise / n_total, 2) if n_total > 0 else 1.0
entry["symbols_before"] = n_total entry["symbols_before"] = n_total
entry["symbols_after"] = n_total - n_noise entry["symbols_after"] = n_total - n_noise
entry["quality"] = round(quality, 2)
entry["useful"] = quality >= min_quality
else: else:
entry["grammar"] = to_gbnf(grammar) entry["grammar"] = to_gbnf(grammar)
entry["noise_ratio"] = 1.0 entry["noise_ratio"] = 1.0
entry["quality"] = 0.0
entry["useful"] = False
entry["algorithm"] = result["best"]["algorithm"] entry["algorithm"] = result["best"]["algorithm"]
entry["mdl_score"] = round(result['best']['mdl_score'], 1) entry["mdl_score"] = round(result['best']['mdl_score'], 1)
entry["imports"] = meta.get("imports", []) entry["imports"] = meta.get("imports", [])
@ -914,11 +919,11 @@ def _build_json_output(results):
return json.dumps(output, indent=2) return json.dumps(output, indent=2)
def _build_yaml_output(results, dir_path, max_mdl=500.0, min_structure=0.0, filter_grammar_noise=True): def _build_yaml_output(results, dir_path, max_mdl=500.0, min_structure=0.0, filter_grammar_noise=True, min_quality=0.3):
"""Build YAML output grouped by top-level module, sorted by MDL. """Build YAML output grouped by top-level module, sorted by MDL.
Filters out (other), no-grammar groups, groups above max_mdl, Filters out (other), no-grammar groups, groups above max_mdl,
and groups below min_structure. groups below min_structure, and low-quality grammars.
Returns YAML string. Returns YAML string.
""" """
import yaml import yaml
@ -952,6 +957,10 @@ def _build_yaml_output(results, dir_path, max_mdl=500.0, min_structure=0.0, filt
else: else:
continue # Skip grammars that become empty after filtering continue # Skip grammars that become empty after filtering
# Apply quality gate
if not is_useful_grammar(grammar, min_quality):
continue
# Extract top-level module from package path # Extract top-level module from package path
parts = label.replace(os.sep, "/").split("/") parts = label.replace(os.sep, "/").split("/")
module = parts[0] if len(parts) > 1 else "(root)" module = parts[0] if len(parts) > 1 else "(root)"
@ -964,6 +973,7 @@ def _build_yaml_output(results, dir_path, max_mdl=500.0, min_structure=0.0, filt
"score": round(best.get("mdl_score", 0), 3), "score": round(best.get("mdl_score", 0), 3),
"algorithm": best["algorithm"], "algorithm": best["algorithm"],
"mdl": round(best["mdl_score"], 1), "mdl": round(best["mdl_score"], 1),
"quality": round(grammar_quality_score(grammar), 2),
} }
modules.setdefault(module, []).append(entry) modules.setdefault(module, []).append(entry)