189 lines
6.7 KiB
Python
189 lines
6.7 KiB
Python
"""DSL datatypes for puzzle grammars.
|
|
|
|
A Grammar is a small, fully-executable description of one puzzle language:
|
|
lexicon + affixes + rewrite rules + constituent order (+ numerals). The LLM
|
|
proposes grammars as JSON; `from_json` parses defensively (a malformed rule
|
|
is dropped, never fatal). `mdl` gives the description length used by the
|
|
verifier's simplicity penalty.
|
|
|
|
JSON shape the proposer LLM emits:
|
|
{
|
|
"lexicon": [{"morph": "kupu", "gloss": "bird", "pos": "N"}, ...],
|
|
"affixes": [{"position": "prefix"|"suffix"|"circumfix", "form": "na",
|
|
"form2": "", "feature": "DEF", "trigger": "N"}, ...],
|
|
"rewrites": [{"pattern": "a+a", "repl": "aa", "context": ""}, ...] # regex
|
|
"order": ["V", "S", "O"], # target constituent order
|
|
"agree": [{"src_slot": "S", "dst_slot": "V", "feature": "NUM"}, ...],
|
|
"redup": [{"scope": "first_syllable", "feature": "PL"}, ...]
|
|
}
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import re
|
|
from dataclasses import dataclass, field
|
|
from typing import Dict, List, Optional
|
|
|
|
|
|
@dataclass
|
|
class LexEntry:
|
|
morph: str
|
|
gloss: str
|
|
pos: str = ""
|
|
features: Dict[str, str] = field(default_factory=dict)
|
|
|
|
|
|
@dataclass
|
|
class Affix:
|
|
position: str # prefix | suffix | infix | circumfix
|
|
form: str
|
|
feature: str = "" # what it marks, e.g. "PL", "PST", "DEF"
|
|
trigger: str = "" # pos or feature it attaches to; "" = any
|
|
form2: str = "" # second part for circumfix
|
|
|
|
|
|
@dataclass
|
|
class Rewrite:
|
|
pattern: str # regex over the surface string
|
|
repl: str
|
|
context: str = "" # optional regex that must match for rule to fire
|
|
|
|
def apply(self, s: str) -> str:
|
|
try:
|
|
if self.context and not re.search(self.context, s):
|
|
return s
|
|
return re.sub(self.pattern, self.repl, s)
|
|
except re.error:
|
|
return s
|
|
|
|
|
|
@dataclass
|
|
class Agree:
|
|
src_slot: str
|
|
dst_slot: str
|
|
feature: str
|
|
|
|
|
|
@dataclass
|
|
class Redup:
|
|
scope: str = "first_syllable" # or "full", "first_cv"
|
|
feature: str = "PL"
|
|
|
|
|
|
@dataclass
|
|
class Grammar:
|
|
lexicon: List[LexEntry] = field(default_factory=list)
|
|
affixes: List[Affix] = field(default_factory=list)
|
|
rewrites: List[Rewrite] = field(default_factory=list)
|
|
order: List[str] = field(default_factory=list)
|
|
agree: List[Agree] = field(default_factory=list)
|
|
redup: List[Redup] = field(default_factory=list)
|
|
notes: str = ""
|
|
|
|
# ---- lookup helpers ----
|
|
def by_gloss(self) -> Dict[str, LexEntry]:
|
|
return {e.gloss.casefold(): e for e in self.lexicon}
|
|
|
|
def by_morph(self) -> Dict[str, LexEntry]:
|
|
return {e.morph: e for e in self.lexicon}
|
|
|
|
def mdl(self) -> float:
|
|
"""Description length: total symbols in the grammar. Lightly weighted
|
|
by the verifier; only breaks ties between equally-fitting grammars."""
|
|
n = 0
|
|
for e in self.lexicon:
|
|
n += len(e.morph) + len(e.gloss) + 2
|
|
for a in self.affixes:
|
|
n += len(a.form) + len(a.form2) + len(a.feature) + 3
|
|
for r in self.rewrites:
|
|
n += len(r.pattern) + len(r.repl) + len(r.context) + 3
|
|
n += 2 * len(self.order) + 4 * len(self.agree) + 4 * len(self.redup)
|
|
return float(n)
|
|
|
|
def to_json(self) -> str:
|
|
return json.dumps(
|
|
{
|
|
"lexicon": [
|
|
{"morph": e.morph, "gloss": e.gloss, "pos": e.pos, "features": e.features}
|
|
for e in self.lexicon
|
|
],
|
|
"affixes": [
|
|
{"position": a.position, "form": a.form, "form2": a.form2,
|
|
"feature": a.feature, "trigger": a.trigger}
|
|
for a in self.affixes
|
|
],
|
|
"rewrites": [
|
|
{"pattern": r.pattern, "repl": r.repl, "context": r.context}
|
|
for r in self.rewrites
|
|
],
|
|
"order": self.order,
|
|
"agree": [
|
|
{"src_slot": g.src_slot, "dst_slot": g.dst_slot, "feature": g.feature}
|
|
for g in self.agree
|
|
],
|
|
"redup": [{"scope": d.scope, "feature": d.feature} for d in self.redup],
|
|
},
|
|
ensure_ascii=False,
|
|
)
|
|
|
|
|
|
def _get(d: dict, *keys: str, default: str = "") -> str:
|
|
for k in keys:
|
|
if k in d and d[k] is not None:
|
|
return str(d[k])
|
|
return default
|
|
|
|
|
|
def from_json(text: str) -> Optional[Grammar]:
|
|
"""Parse an LLM-emitted grammar. Tolerates surrounding prose/code fences
|
|
and drops malformed entries instead of failing."""
|
|
m = re.search(r"\{.*\}", text, re.DOTALL)
|
|
if not m:
|
|
return None
|
|
try:
|
|
data = json.loads(m.group(0))
|
|
except json.JSONDecodeError:
|
|
# common LLM damage: trailing commas
|
|
try:
|
|
data = json.loads(re.sub(r",\s*([}\]])", r"\1", m.group(0)))
|
|
except json.JSONDecodeError:
|
|
return None
|
|
if not isinstance(data, dict):
|
|
return None
|
|
|
|
g = Grammar()
|
|
for e in data.get("lexicon") or []:
|
|
if isinstance(e, dict):
|
|
morph, gloss = _get(e, "morph", "form", "word"), _get(e, "gloss", "meaning")
|
|
if morph and gloss:
|
|
feats = e.get("features") if isinstance(e.get("features"), dict) else {}
|
|
g.lexicon.append(LexEntry(morph, gloss, _get(e, "pos"), {str(k): str(v) for k, v in (feats or {}).items()}))
|
|
for a in data.get("affixes") or []:
|
|
if isinstance(a, dict):
|
|
form = _get(a, "form")
|
|
pos = _get(a, "position", default="suffix").lower()
|
|
if form and pos in ("prefix", "suffix", "infix", "circumfix"):
|
|
g.affixes.append(Affix(pos, form, _get(a, "feature", "gloss"), _get(a, "trigger"), _get(a, "form2")))
|
|
for r in data.get("rewrites") or []:
|
|
if isinstance(r, dict) and _get(r, "pattern"):
|
|
try:
|
|
re.compile(_get(r, "pattern"))
|
|
if _get(r, "context"):
|
|
re.compile(_get(r, "context"))
|
|
except re.error:
|
|
continue
|
|
g.rewrites.append(Rewrite(_get(r, "pattern"), _get(r, "repl", "replacement"), _get(r, "context")))
|
|
order = data.get("order") or []
|
|
if isinstance(order, list):
|
|
g.order = [str(x) for x in order]
|
|
for ag in data.get("agree") or []:
|
|
if isinstance(ag, dict) and _get(ag, "feature"):
|
|
g.agree.append(Agree(_get(ag, "src_slot", "src"), _get(ag, "dst_slot", "dst"), _get(ag, "feature")))
|
|
for rd in data.get("redup") or []:
|
|
if isinstance(rd, dict):
|
|
g.redup.append(Redup(_get(rd, "scope", default="first_syllable"), _get(rd, "feature", default="PL")))
|
|
if not g.lexicon and not g.affixes and not g.rewrites:
|
|
return None
|
|
return g
|