grammar-inference-engine/bex/gbnf.py
tobjend e62fffc6e0 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)
2026-07-12 03:02:50 +02:00

430 lines
13 KiB
Python

"""GBNF — Convert SOREs to GBNF grammar format for llama.cpp constrained decoding.
GBNF (GGML BNF) is the de-facto standard grammar format for grammar-constrained
LLM output. It supports: literals, concatenation, alternation, repetition (+, *, ?),
and grouping.
SORE operators map directly:
SORE `.` (concat) → GBNF implicit concat (space)
SORE `|` (alt) → GBNF `|`
SORE `+` (plus) → GBNF `+`
SORE `?` (optional) → GBNF `?`
SORE `*` (star) → GBNF `*`
SORE `()` (group) → GBNF `()`
SORE literal → GBNF `"literal"`
"""
# ---------------------------------------------------------------------------
# SORE tokenizer
# ---------------------------------------------------------------------------
_TOKEN_CHARS = set('.|+?*()')
def _tokenize(sore):
"""Tokenize a SORE string into a list of (type, value) tuples.
Literals are sequences of non-operator characters.
"""
tokens = []
i = 0
n = len(sore)
while i < n:
ch = sore[i]
if ch == '.':
tokens.append(('DOT', ch))
i += 1
elif ch == '|':
tokens.append(('PIPE', ch))
i += 1
elif ch == '+':
tokens.append(('PLUS', ch))
i += 1
elif ch == '?':
tokens.append(('QUESTION', ch))
i += 1
elif ch == '*':
tokens.append(('STAR', ch))
i += 1
elif ch == '(':
tokens.append(('LPAREN', ch))
i += 1
elif ch == ')':
tokens.append(('RPAREN', ch))
i += 1
elif ch == 'ε':
tokens.append(('EPSILON', 'ε'))
i += 1
elif ch == '':
tokens.append(('EMPTY', ''))
i += 1
else:
# Collect literal characters (until next operator or paren)
start = i
while i < n and sore[i] not in _TOKEN_CHARS and sore[i] not in 'ε∅':
i += 1
lit = sore[start:i]
if lit:
# Strip newlines/extra whitespace from symbol names
lit = ' '.join(lit.split())
tokens.append(('LITERAL', lit))
return tokens
# ---------------------------------------------------------------------------
# SORE parser — produces a tree
# ---------------------------------------------------------------------------
class _Node:
"""AST node for SORE."""
pass
class _Literal(_Node):
def __init__(self, value):
self.value = value
def __repr__(self):
return f'Lit({self.value!r})'
class _Concat(_Node):
def __init__(self, parts):
self.parts = parts
def __repr__(self):
return f'Concat({self.parts})'
class _Alt(_Node):
def __init__(self, parts):
self.parts = parts
def __repr__(self):
return f'Alt({self.parts})'
class _Plus(_Node):
def __init__(self, child):
self.child = child
def __repr__(self):
return f'Plus({self.child})'
class _Optional(_Node):
def __init__(self, child):
self.child = child
def __repr__(self):
return f'Optional({self.child})'
class _Star(_Node):
def __init__(self, child):
self.child = child
def __repr__(self):
return f'Star({self.child})'
class _Epsilon(_Node):
def __repr__(self):
return 'Epsilon()'
class _Empty(_Node):
def __repr__(self):
return 'Empty()'
class _Parser:
"""Recursive descent parser for SOREs.
Handles the overloaded + operator:
- (a+b+c) → disjunction (inside parens)
- r+ → one-or-more repetition (outside parens)
"""
def __init__(self, tokens):
self.tokens = tokens
self.pos = 0
self.paren_depth = 0
def peek(self):
if self.pos < len(self.tokens):
return self.tokens[self.pos]
return ('EOF', '')
def consume(self, expected_type=None):
tok = self.peek()
if tok[0] == 'EOF':
raise ValueError(f'Unexpected end of SORE, expected {expected_type}')
if expected_type and tok[0] != expected_type:
raise ValueError(f'Expected {expected_type}, got {tok}')
self.pos += 1
return tok
def parse(self):
"""Parse full SORE."""
node = self.parse_alternation()
return node
def parse_alternation(self):
"""Parse: concat (('|' | '+') concat)* — + is alternation inside parens"""
parts = [self.parse_concat()]
while self.peek()[0] in ('PIPE', 'PLUS'):
if self.peek()[0] == 'PLUS' and self.paren_depth == 0:
break # + outside parens is repetition, not alternation
self.consume()
parts.append(self.parse_concat())
if len(parts) == 1:
return parts[0]
return _Alt(parts)
def parse_concat(self):
"""Parse: repetition (('.' | LPAREN) repetition)* — implicit concat"""
parts = [self.parse_repetition()]
while self.peek()[0] in ('DOT', 'LPAREN'):
if self.peek()[0] == 'DOT':
self.consume('DOT')
# LPAREN = implicit concat (no separator)
parts.append(self.parse_repetition())
if len(parts) == 1:
return parts[0]
return _Concat(parts)
def parse_repetition(self):
"""Parse: atom ('+' | '?' | '*')?
Inside parens, + is alternation (consumed by parse_alternation),
not repetition. Outside parens, always consume + as repetition.
Handles compound: +?, +*, ?+, *+ etc.
"""
node = self.parse_atom()
if self.peek()[0] in ('PLUS', 'QUESTION', 'STAR'):
if self.peek()[0] == 'PLUS' and self.paren_depth > 0:
return node # + inside parens is alternation, handled by caller
op = self.consume()
if op[0] == 'PLUS':
node = _Plus(node)
elif op[0] == 'QUESTION':
node = _Optional(node)
elif op[0] == 'STAR':
node = _Star(node)
# Handle compound repetition: +?, +*, ?+ etc.
# Normalize: Optional(Plus(x)) → Star(x)
if self.peek()[0] in ('PLUS', 'QUESTION', 'STAR'):
if self.peek()[0] == 'PLUS' and self.paren_depth > 0:
return node
op2 = self.consume()
if op2[0] == 'QUESTION':
if isinstance(node, _Plus):
node = _Star(node.child)
else:
node = _Optional(node)
elif op2[0] == 'STAR':
node = _Star(node.child if isinstance(node, (_Plus, _Optional)) else node)
elif op2[0] == 'PLUS':
if isinstance(node, _Optional):
node = _Plus(node.child)
elif isinstance(node, (_Plus, _Star)):
node = node # ++ is idempotent
else:
node = _Plus(node)
return node
def parse_atom(self):
"""Parse: literal | '(' alternation ')' | 'ε' | ''"""
tok = self.peek()
if tok[0] == 'EOF':
return _Epsilon()
if tok[0] == 'LITERAL':
self.consume()
return _Literal(tok[1])
if tok[0] == 'EPSILON':
self.consume()
return _Epsilon()
if tok[0] == 'EMPTY':
self.consume()
return _Empty()
if tok[0] == 'LPAREN':
self.consume('LPAREN')
self.paren_depth += 1
node = self.parse_alternation()
self.consume('RPAREN')
self.paren_depth -= 1
return node
raise ValueError(f'Unexpected token: {tok}')
def _parse_sore(sore):
"""Parse a SORE string into an AST."""
tokens = _tokenize(sore)
parser = _Parser(tokens)
return parser.parse()
# ---------------------------------------------------------------------------
# AST → GBNF
# ---------------------------------------------------------------------------
def _needs_group(node):
"""Check if a node needs parentheses in GBNF output."""
return isinstance(node, (_Alt, _Concat))
def _node_to_gbnf(node, rule_counter):
"""Convert AST node to GBNF fragment string.
Returns (gbnf_string, new_rule_counter, new_rules_list).
new_rules_list contains any helper rules needed.
"""
if isinstance(node, _Literal):
# Escape special chars in the literal for GBNF
escaped = node.value.replace('\\', '\\\\').replace('"', '\\"')
return f'"{escaped}"', rule_counter, []
if isinstance(node, _Epsilon):
return '', rule_counter, []
if isinstance(node, _Empty):
return '', rule_counter, []
if isinstance(node, _Concat):
parts = []
all_new_rules = []
for child in node.parts:
frag, rule_counter, new_rules = _node_to_gbnf(child, rule_counter)
if isinstance(child, _Alt):
frag = f'({frag})'
parts.append(frag)
all_new_rules.extend(new_rules)
return ' '.join(p for p in parts if p), rule_counter, all_new_rules
if isinstance(node, _Alt):
parts = []
all_new_rules = []
for child in node.parts:
frag, rule_counter, new_rules = _node_to_gbnf(child, rule_counter)
parts.append(frag)
all_new_rules.extend(new_rules)
return ' | '.join(p for p in parts if p), rule_counter, all_new_rules
if isinstance(node, _Plus):
frag, rule_counter, new_rules = _node_to_gbnf(node.child, rule_counter)
if _needs_group(node.child):
return f'({frag})+', rule_counter, new_rules
return f'{frag}+', rule_counter, new_rules
if isinstance(node, _Optional):
frag, rule_counter, new_rules = _node_to_gbnf(node.child, rule_counter)
if _needs_group(node.child):
return f'({frag})?', rule_counter, new_rules
return f'{frag}?', rule_counter, new_rules
if isinstance(node, _Star):
frag, rule_counter, new_rules = _node_to_gbnf(node.child, rule_counter)
if _needs_group(node.child):
return f'({frag})*', rule_counter, new_rules
return f'{frag}*', rule_counter, new_rules
raise ValueError(f'Unknown node type: {type(node)}')
def to_gbnf(sore):
"""Convert a SORE string to a GBNF grammar string.
Args:
sore: A SORE string like 'raise.(ValueError)+' or 'mockk'
Returns:
A GBNF grammar string with a single 'root' rule.
Example:
>>> to_gbnf('mockk')
'root ::= "mockk"'
>>> to_gbnf('raise.(ValueError)+')
'root ::= "raise" ("ValueError")+'
>>> to_gbnf('assertEquals.(of.(assertFailsWith)?)+')
'root ::= "assertEquals" ("of" ("assertFailsWith")?)+'
"""
if not sore or sore == '':
return 'root ::= ""'
if sore == 'ε':
return 'root ::= ""'
tree = _parse_sore(sore)
frag, _, _ = _node_to_gbnf(tree, 0)
return f'root ::= {frag}'
def to_gbnf_with_rules(sore, name='root'):
"""Convert a SORE to GBNF with a named rule.
Args:
sore: A SORE string
name: Rule name (default: 'root')
Returns:
GBNF rule string like 'my-rule ::= "foo" ("bar")+'
"""
if not sore or sore == '':
return f'{name} ::= ""'
if sore == 'ε':
return f'{name} ::= ""'
tree = _parse_sore(sore)
frag, _, _ = _node_to_gbnf(tree, 0)
return f'{name} ::= {frag}'
def validate_sore(sore):
"""Check if a SORE string is parseable. Returns (True, None) or (False, error_msg)."""
if not sore or sore in ('', 'ε'):
return True, None
try:
_parse_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