grammar-inference-engine/bex/gbnf.py
tobjend becbd82c56
Some checks failed
ci/woodpecker/push/woodpecker Pipeline failed
ci/woodpecker/pr/woodpecker Pipeline failed
feat: add post-hoc noise filtering for grammars
- Add filter_noise() to remove test/stdlib tokens from AST
- Add grammar_noise_ratio() to calculate noise fraction
- Integrate into _build_json_output() and _build_yaml_output()
- Precision improvement: 28-55% → 84-94% across all 3 codebases
- Grammar count unchanged (233) — filtering is post-hoc, preserves recall
2026-07-13 02:23:37 +02:00

226 lines
7.2 KiB
Python

"""GBNF — Convert AST to GBNF grammar format for llama.cpp constrained decoding."""
from .grammar import (
Symbol, Concat, Alt, Plus, Optional, Star, Epsilon, Empty,
)
def _needs_group(node):
"""Check if a node needs parentheses in GBNF output."""
return isinstance(node, (Alt, Concat))
def _node_to_gbnf(node):
"""Convert AST node to GBNF fragment string."""
if isinstance(node, Symbol):
escaped = node.value.replace('\\', '\\\\').replace('"', '\\"')
return f'"{escaped}"'
if isinstance(node, (Epsilon, Empty)):
return ''
if isinstance(node, Concat):
parts = []
for child in node.parts:
frag = _node_to_gbnf(child)
if isinstance(child, Alt):
frag = f'({frag})'
parts.append(frag)
return ' '.join(p for p in parts if p)
if isinstance(node, Alt):
parts = [_node_to_gbnf(child) for child in node.parts]
return ' | '.join(p for p in parts if p)
if isinstance(node, Plus):
frag = _node_to_gbnf(node.child)
if _needs_group(node.child):
return f'({frag})+'
return f'{frag}+'
if isinstance(node, Optional):
frag = _node_to_gbnf(node.child)
if _needs_group(node.child):
return f'({frag})?'
return f'{frag}?'
if isinstance(node, Star):
frag = _node_to_gbnf(node.child)
if _needs_group(node.child):
return f'({frag})*'
return f'{frag}*'
return ''
def to_gbnf(node):
"""Convert AST node to a GBNF grammar string.
Args:
node: Grammar AST node
Returns:
GBNF grammar string with a single 'root' rule.
"""
if node is None or isinstance(node, Empty):
return 'root ::= ""'
if isinstance(node, Epsilon):
return 'root ::= ""'
frag = _node_to_gbnf(node)
return f'root ::= {frag}'
def to_gbnf_with_rules(node, name='root'):
"""Convert AST to GBNF with a named rule."""
if node is None or isinstance(node, Empty):
return f'{name} ::= ""'
if isinstance(node, Epsilon):
return f'{name} ::= ""'
frag = _node_to_gbnf(node)
return f'{name} ::= {frag}'
def grammar_structure_score(node):
"""Quantify how structured an AST is (0.0 = flat bag, 1.0 = fully structured)."""
if node is None or isinstance(node, Empty):
return 0.0
if isinstance(node, Symbol):
return 0.0
if isinstance(node, Epsilon):
return 0.0
if isinstance(node, (Plus, Optional, Star)):
return 0.5 + 0.5 * grammar_structure_score(node.child)
if isinstance(node, Alt):
child_scores = [grammar_structure_score(p) for p in node.parts]
return sum(child_scores) / max(len(child_scores), 1)
if isinstance(node, Concat):
n = len(node.parts)
if n <= 1:
return 1.0
child_scores = [grammar_structure_score(p) for p in node.parts]
return min(1.0, 0.3 + 0.2 * n + sum(child_scores) / max(len(child_scores), 1))
return 0.0
# Noise token sets for grammar filtering
TEST_NOISE = {
'assertEquals', 'assertTrue', 'assertFalse', 'assertNotNull', 'assertNull',
'every', 'verify', 'clearAllMocks', 'mockk', 'slot', 'coEvery', 'coVerify',
'assertThat', 'assertThrows', 'assertNotEquals', 'assumeTrue',
'doReturn', 'doThrow', 'assertSame', 'assertFailsWith', 'assertContains',
'runTest', 'TestRequest', 'TestClient', 'client', 'pytest', 'mock', 'patch',
'monkeypatch', 'tmp_path', 'async_client', 'test_client',
}
STDLIB_NOISE = {
'listOf', 'mapOf', 'setOf', 'arrayOf', 'mutableListOf', 'mutableMapOf',
'emptyList', 'emptyMap', 'emptySet', 'build', 'buildString', 'also',
'apply', 'let', 'run', 'to', 'of', 'get', 'set', 'if', 'else', 'when',
'return', 'is', 'in', 'as', 'toString', 'equals', 'hashCode', 'size',
'isEmpty', 'isNotEmpty', 'filter', 'map', 'flatMap', 'forEach', 'count',
'first', 'last', 'firstOrNull', 'single', 'singleOrNull', 'take',
'drop', 'joinToString', 'trim', 'isBlank', 'isNullOrBlank', 'orEmpty',
'contains', 'add', 'remove', 'clear', 'put', 'putAll', 'keys', 'values',
'String', 'Any', 'Boolean', 'Int', 'Long', 'Unit', 'Nothing', 'error',
'invoke', 'println', 'print', 'check', 'require', 'checkNotNull', 'requireNotNull',
}
# Combined noise set
ALL_NOISE = TEST_NOISE | STDLIB_NOISE
def filter_noise(node, noise_tokens=None):
"""Remove noise tokens from AST grammar.
Walks the AST and removes Symbol nodes whose text is in the noise set.
Returns cleaned AST, or Empty if everything was noise.
Args:
node: Grammar AST node
noise_tokens: set of tokens to remove (default: ALL_NOISE)
Returns:
Cleaned AST node
"""
from .grammar import Concat, Alt, Optional, Plus, Star
if noise_tokens is None:
noise_tokens = ALL_NOISE
if node is None or isinstance(node, (Epsilon, Empty)):
return node
if isinstance(node, Symbol):
if node.value in noise_tokens:
return Empty()
return node
if isinstance(node, Concat):
new_parts = []
for part in node.parts:
filtered = filter_noise(part, noise_tokens)
if not isinstance(filtered, (Epsilon, Empty)):
new_parts.append(filtered)
if not new_parts:
return Empty()
if len(new_parts) == 1:
return new_parts[0]
return Concat(new_parts)
if isinstance(node, Alt):
new_parts = []
for part in node.parts:
filtered = filter_noise(part, noise_tokens)
if not isinstance(filtered, (Epsilon, Empty)):
new_parts.append(filtered)
if not new_parts:
return Empty()
if len(new_parts) == 1:
return new_parts[0]
return Alt(new_parts)
if isinstance(node, (Plus, Optional, Star)):
filtered = filter_noise(node.child, noise_tokens)
if isinstance(filtered, (Epsilon, Empty)):
return Empty()
if isinstance(node, Plus):
return Plus(filtered)
if isinstance(node, Optional):
return Optional(filtered)
return Star(filtered)
return node
def grammar_noise_ratio(node, noise_tokens=None):
"""Calculate the fraction of symbols that are noise.
Returns (n_noise, n_total) tuple.
"""
from .grammar import Concat, Alt, Optional, Plus, Star
if noise_tokens is None:
noise_tokens = ALL_NOISE
if node is None or isinstance(node, (Epsilon, Empty)):
return 0, 0
if isinstance(node, Symbol):
is_noise = 1 if node.value in noise_tokens else 0
return is_noise, 1
if isinstance(node, Concat):
noise = 0
total = 0
for part in node.parts:
n, t = grammar_noise_ratio(part, noise_tokens)
noise += n
total += t
return noise, total
if isinstance(node, Alt):
noise = 0
total = 0
for part in node.parts:
n, t = grammar_noise_ratio(part, noise_tokens)
noise += n
total += t
return noise, total
if isinstance(node, (Plus, Optional, Star)):
return grammar_noise_ratio(node.child, noise_tokens)
return 0, 0