"""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