Source code for flpq_data.queries.mcfpq.readwrite.mcfg

"""Read (and write) a multiple context-free grammar from (and to) different sources."""

import logging
import pathlib
import re
from dataclasses import dataclass
from typing import Union

from lark import Lark, Transformer

__all__ = [
    "MCFG",
    "MCFGRule",
    "mcfg_from_text",
    "mcfg_from_txt",
    "mcfg_to_text",
    "mcfg_to_txt",
]

# Datalog-like syntax for MCFGs (docs/flpq.rst, "MCFG grammar format
# (.mcfg)"): one rule per line; a '#' at the start of a line comments out
# the rest of the line; nonterminals are identifiers starting with an
# uppercase letter; variables are a lowercase letter followed by digits
# (the literature's x^i, y^j notation); 'eps' is the empty string; '<-'
# separates the head from the body. The LALR contextual lexer makes '#'
# context-sensitive: COMMENT is only valid between rules, so inside
# argument lists '#' matches TERMINAL (the spec's examples use it as an
# edge label). EPS/VARIABLE/NONTERMINAL outrank the catch-all TERMINAL, so
# 'eps', 'x1', and uppercase identifiers never lex as terminals; a token
# that merely starts with one of those patterns (e.g. 'x1a') lexes as the
# higher-priority prefix plus the rest — no dataset label has this form.
_MCFG_GRAMMAR = r"""
    start: (COMMENT | rule)*

    rule: basic_rule | production_rule
    basic_rule: NONTERMINAL "(" args ")"
    production_rule: NONTERMINAL "(" args ")" ARROW body

    args: arg ("," arg)*
    arg: (TERMINAL | VARIABLE | EPS)+
    body: atom ("," atom)*
    atom: NONTERMINAL "(" vars ")"
    vars: VARIABLE ("," VARIABLE)*

    COMMENT.3: /#[^\n]*/
    NONTERMINAL.2: /[A-Z]\w*/
    VARIABLE.2: /[a-z][0-9]+/
    EPS.2: /eps/
    TERMINAL.1: /[^ \t\r\n(),<>-]+/
    ARROW: /<-/

    %ignore /[ \t\r\n]/
"""

_PARSER = Lark(_MCFG_GRAMMAR, parser="lalr")


[docs] @dataclass(frozen=True) class MCFGRule: """A rule of a multiple context-free grammar. A basic rule is a production with an empty body: ``A(eps, eps)`` is stored as ``MCFGRule("A", (("eps",), ("eps",)), ())``. """ head: str head_args: tuple[tuple[str, ...], ...] body: tuple[tuple[str, tuple[str, ...]], ...] = ()
[docs] @dataclass(frozen=True) class MCFG: """A multiple context-free grammar in the Datalog-like ``.mcfg`` syntax.""" rules: tuple[MCFGRule, ...] start_symbol: str = "S" @property def dimension(self) -> int: """The maximum arity of a nonterminal (the :math:`d` of :math:`d`-MCFG(:math:`r`)).""" sizes = [len(rule.head_args) for rule in self.rules] sizes.extend(len(args) for rule in self.rules for _, args in rule.body) return max(sizes, default=0) @property def rank(self) -> int: """The maximum number of body atoms of a rule (the :math:`r` of :math:`d`-MCFG(:math:`r`)).""" return max((len(rule.body) for rule in self.rules), default=0)
_VARIABLE_RE = re.compile(r"[a-z][0-9]+") def _validate_mcfg(rules: tuple[MCFGRule, ...], start_symbol: str) -> None: arities: dict[str, int] = {} for rule in rules: if rule.head in arities and arities[rule.head] != len(rule.head_args): raise ValueError( f"The nonterminal {rule.head} has inconsistent arity: " f"{arities[rule.head]} and {len(rule.head_args)}" ) arities[rule.head] = len(rule.head_args) for head, args in rule.body: if head in arities and arities[head] != len(args): raise ValueError( f"The nonterminal {head} has inconsistent arity: " f"{arities[head]} and {len(args)}" ) arities.setdefault(head, len(args)) for rule in rules: body_variables = [var for _, args in rule.body for var in args] if len(set(body_variables)) != len(body_variables): duplicated = sorted( {var for var in body_variables if body_variables.count(var) > 1} ) raise ValueError( f"The body variables of the rule {rule} are not pairwise " f"distinct: {', '.join(duplicated)}" ) head_variables = [ token for arg in rule.head_args for token in arg if _VARIABLE_RE.fullmatch(token) ] if sorted(head_variables) != sorted(body_variables): raise ValueError( f"The rule {rule} has dangling variables: the head uses " f"{sorted(set(head_variables))}, the body uses " f"{sorted(set(body_variables))}" ) if rule.body and any("eps" in arg for arg in rule.head_args): raise ValueError( f"The rule {rule} uses 'eps' in a production; 'eps' is only " "allowed in basic rules (rules without a body)" ) if start_symbol not in arities: raise ValueError( f"The start symbol {start_symbol} does not occur in the grammar" ) if arities[start_symbol] != 1: raise ValueError( f"The start symbol {start_symbol} must have arity 1, " f"not {arities[start_symbol]}" ) class _MCFGBuilder(Transformer): def arg(self, items): return tuple(str(item) for item in items) def args(self, items): return tuple(items) def vars(self, items): return tuple(str(item) for item in items) def atom(self, items): return (str(items[0]), items[1]) def body(self, items): return tuple(items) def basic_rule(self, items): return MCFGRule(head=str(items[0]), head_args=items[1], body=()) def production_rule(self, items): return MCFGRule(head=str(items[0]), head_args=items[1], body=items[3]) def rule(self, items): return items[0] def start(self, items): return tuple(item for item in items if isinstance(item, MCFGRule))
[docs] def mcfg_from_text(text: str, *, start_symbol: str = "S") -> MCFG: """Create a multiple context-free grammar [1]_ from text. Parameters ---------- text : str The text with which the multiple context-free grammar will be created. start_symbol : str Start symbol of a multiple context-free grammar. Examples -------- >>> from flpq_data import * >>> text = ( ... "A(eps, eps)\\n" ... "A(x1 0, x2 0) <- A(x1, x2)\\n" ... "A(x1 1, x2 1) <- A(x1, x2)\\n" ... "S(x1 y1 # y2 x2) <- A(x1, x2), A(y1, y2)" ... ) >>> mcfg = mcfg_from_text(text) >>> len(mcfg.rules) 4 >>> mcfg.rules[0] MCFGRule(head='A', head_args=(('eps',), ('eps',)), body=()) >>> mcfg.dimension, mcfg.rank (2, 2) Returns ------- mcfg : MCFG Multiple context-free grammar. References ---------- .. [1] https://arxiv.org/abs/2411.06383 """ tree = _PARSER.parse(text) rules = _MCFGBuilder().transform(tree) _validate_mcfg(rules, start_symbol) mcfg = MCFG(rules=rules, start_symbol=start_symbol) logging.info(f"Create {mcfg=} from {text=}, {start_symbol=}") return mcfg
def _render_rule(rule: MCFGRule) -> str: head = f"{rule.head}(" + ", ".join(" ".join(arg) for arg in rule.head_args) + ")" if not rule.body: return head body = ", ".join( f"{atom_head}(" + ", ".join(atom_vars) + ")" for atom_head, atom_vars in rule.body ) return f"{head} <- {body}"
[docs] def mcfg_to_text(mcfg: MCFG) -> str: """Turns a multiple context-free grammar [1]_ into its text representation. The rendering is canonical: one rule per line, argument tokens space-joined, so the same model always produces the same text and ``mcfg_to_text(mcfg_from_text(text))`` reproduces ``text`` up to whitespace and comments. Parameters ---------- mcfg : MCFG Multiple context-free grammar. Examples -------- >>> from flpq_data import * >>> text = ( ... "A(eps, eps)\\n" ... "A(x1 0, x2 0) <- A(x1, x2)\\n" ... "A(x1 1, x2 1) <- A(x1, x2)\\n" ... "S(x1 y1 # y2 x2) <- A(x1, x2), A(y1, y2)" ... ) >>> mcfg_to_text(mcfg_from_text(text)) == text True Returns ------- text : str Multiple context-free grammar text representation. References ---------- .. [1] https://arxiv.org/abs/2411.06383 """ text = "\n".join(_render_rule(rule) for rule in mcfg.rules) logging.info(f"Turn {mcfg=} into {text=}") return text
[docs] def mcfg_from_txt(path: Union[pathlib.Path, str], *, start_symbol: str = "S") -> MCFG: """Create a multiple context-free grammar [1]_ from an ``.mcfg`` file. Parameters ---------- path : Union[Path, str] The path to the ``.mcfg`` file with which the multiple context-free grammar will be created. start_symbol : str Start symbol of a multiple context-free grammar. Examples -------- >>> from flpq_data import * >>> mcfg = mcfg_from_text("A(eps)\\nS(x1) <- A(x1)") >>> path = mcfg_to_txt(mcfg, "test.mcfg") >>> mcfg_from_txt(path) == mcfg True Returns ------- mcfg : MCFG Multiple context-free grammar. References ---------- .. [1] https://arxiv.org/abs/2411.06383 """ with open(path, "r") as f: text = f.read() mcfg = mcfg_from_text(text, start_symbol=start_symbol) logging.info(f"Create {mcfg=} from {path=}, {start_symbol=}") return mcfg
[docs] def mcfg_to_txt(mcfg: MCFG, path: Union[pathlib.Path, str]) -> pathlib.Path: """Saves a multiple context-free grammar [1]_ text representation into an ``.mcfg`` file. Parameters ---------- mcfg : MCFG Multiple context-free grammar. path : Union[Path, str] The path to the ``.mcfg`` file where the multiple context-free grammar text representation will be saved. Examples -------- >>> from flpq_data import * >>> mcfg = mcfg_from_text("A(eps)\\nS(x1) <- A(x1)") >>> path = mcfg_to_txt(mcfg, "test.mcfg") Returns ------- path : Path The path to the ``.mcfg`` file where the text representation will be saved. References ---------- .. [1] https://arxiv.org/abs/2411.06383 """ with open(path, "w") as f: f.write(mcfg_to_text(mcfg)) dest = pathlib.Path(path).resolve() logging.info(f"Save {mcfg=} to {dest=}") return dest