初始化项目,由ModelHub XC社区提供模型
Model: rpant/iolai26-solve Source: Original Platform
This commit is contained in:
0
solver/dsl/__init__.py
Normal file
0
solver/dsl/__init__.py
Normal file
188
solver/dsl/grammar.py
Normal file
188
solver/dsl/grammar.py
Normal file
@@ -0,0 +1,188 @@
|
||||
"""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
|
||||
198
solver/dsl/interpreter.py
Normal file
198
solver/dsl/interpreter.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Deterministic interpreter for DSL grammars: generate() (work -> task
|
||||
language) and analyze() (task -> work language).
|
||||
|
||||
The interpreter is intentionally strict and simple: it executes exactly what
|
||||
the grammar says. If a grammar needs cleverness, the proposer must encode it
|
||||
(e.g. list 'birds' as its own lexicon entry instead of relying on affix
|
||||
machinery). The verifier then selects grammars that this interpreter executes
|
||||
into correct outputs — that closed loop is the whole design.
|
||||
|
||||
Conventions the proposer prompt establishes:
|
||||
- lexicon glosses are work-language words/phrases (may be multiword);
|
||||
- affix `feature` is the work-language cue it realizes: a function word
|
||||
("the", "will", "not") or a marker name ("plural") — during generation a
|
||||
feature fires when its cue appears in the work sentence next to the stem;
|
||||
- `order` is a list of pos tags giving target-language constituent order;
|
||||
- rewrites are surface regex applied after morph concatenation (word-level).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Dict, List, Optional, Tuple
|
||||
|
||||
from .grammar import Affix, Grammar, LexEntry
|
||||
|
||||
|
||||
def _words(s: str) -> List[str]:
|
||||
return [w for w in re.findall(r"[^\s]+", s.strip()) if w]
|
||||
|
||||
|
||||
def _clean(w: str) -> str:
|
||||
return w.strip(",;.!?()[]\"'«»").casefold()
|
||||
|
||||
|
||||
class Interpreter:
|
||||
def __init__(self, grammar: Grammar):
|
||||
self.g = grammar
|
||||
# gloss index: multiword glosses first (longest match wins)
|
||||
self._gloss_entries: List[Tuple[List[str], LexEntry]] = sorted(
|
||||
(( [_clean(w) for w in _words(e.gloss)], e) for e in grammar.lexicon if e.gloss),
|
||||
key=lambda t: -len(t[0]),
|
||||
)
|
||||
self._morphs: Dict[str, LexEntry] = {e.morph: e for e in grammar.lexicon}
|
||||
self._affix_by_cue: Dict[str, List[Affix]] = {}
|
||||
for a in grammar.affixes:
|
||||
self._affix_by_cue.setdefault(_clean(a.feature), []).append(a)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# generation: work-language sentence -> task-language string
|
||||
# ------------------------------------------------------------------
|
||||
def generate(self, work_sentence: str) -> Optional[str]:
|
||||
toks = [_clean(w) for w in _words(work_sentence)]
|
||||
if not toks:
|
||||
return None
|
||||
n = len(toks)
|
||||
used = [False] * n
|
||||
stems: List[Tuple[int, LexEntry]] = [] # (position of first gloss word, entry)
|
||||
|
||||
# 1. cover with lexicon glosses, longest first
|
||||
for gloss_words, entry in self._gloss_entries:
|
||||
L = len(gloss_words)
|
||||
i = 0
|
||||
while i + L <= n:
|
||||
if not any(used[i : i + L]) and toks[i : i + L] == gloss_words:
|
||||
for j in range(i, i + L):
|
||||
used[j] = True
|
||||
stems.append((i, entry))
|
||||
i += L
|
||||
else:
|
||||
i += 1
|
||||
if not stems:
|
||||
return None
|
||||
stems.sort(key=lambda t: t[0])
|
||||
|
||||
# 2. leftover tokens fire affixes on the nearest eligible stem
|
||||
pending: Dict[int, List[Affix]] = {k: [] for k in range(len(stems))}
|
||||
uncovered = [i for i in range(n) if not used[i]]
|
||||
for i in uncovered:
|
||||
cue = toks[i]
|
||||
for a in self._affix_by_cue.get(cue, []):
|
||||
k = self._nearest_stem(stems, i, a)
|
||||
if k is not None:
|
||||
pending[k].append(a)
|
||||
break
|
||||
|
||||
# 3. order stems by target constituent order if pos info available
|
||||
idx = list(range(len(stems)))
|
||||
if self.g.order and all(e.pos for _, e in stems):
|
||||
rank = {pos: r for r, pos in enumerate(self.g.order)}
|
||||
idx.sort(key=lambda k: (rank.get(stems[k][1].pos, len(rank)), stems[k][0]))
|
||||
|
||||
# 4. build surface words: affix attachment then rewrites
|
||||
out_words = []
|
||||
for k in idx:
|
||||
_, entry = stems[k]
|
||||
w = entry.morph
|
||||
for a in pending[k]:
|
||||
w = self._attach(w, a)
|
||||
w = self._apply_rewrites(w)
|
||||
out_words.append(w)
|
||||
surface = " ".join(out_words)
|
||||
return self._apply_rewrites_sentence(surface)
|
||||
|
||||
def _nearest_stem(self, stems: List[Tuple[int, LexEntry]], cue_pos: int, affix: Affix) -> Optional[int]:
|
||||
best_k, best_d = None, 10 ** 9
|
||||
for k, (pos, entry) in enumerate(stems):
|
||||
if affix.trigger and affix.trigger not in (entry.pos, entry.gloss):
|
||||
continue
|
||||
d = abs(pos - cue_pos)
|
||||
if d < best_d:
|
||||
best_k, best_d = k, d
|
||||
return best_k
|
||||
|
||||
def _attach(self, w: str, a: Affix) -> str:
|
||||
if a.position == "prefix":
|
||||
return a.form + w
|
||||
if a.position == "suffix":
|
||||
return w + a.form
|
||||
if a.position == "circumfix":
|
||||
return a.form + w + (a.form2 or a.form)
|
||||
if a.position == "infix":
|
||||
# after the first vowel-less onset (common infix site: after first C)
|
||||
m = re.match(r"^([^aeiouAEIOU]*)(.*)$", w)
|
||||
return (m.group(1) + a.form + m.group(2)) if m else w + a.form
|
||||
return w
|
||||
|
||||
def _apply_rewrites(self, w: str) -> str:
|
||||
for r in self.g.rewrites:
|
||||
w = r.apply(w)
|
||||
return w
|
||||
|
||||
def _apply_rewrites_sentence(self, s: str) -> str:
|
||||
# rewrites with explicit spaces/anchors act at sentence level too
|
||||
for r in self.g.rewrites:
|
||||
if " " in r.pattern or r.pattern.startswith("^") or r.pattern.endswith("$"):
|
||||
s = r.apply(s)
|
||||
return s
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# analysis: task-language sentence -> work-language string
|
||||
# ------------------------------------------------------------------
|
||||
def analyze(self, task_sentence: str) -> Optional[str]:
|
||||
words = [_clean(w) for w in _words(task_sentence)]
|
||||
if not words:
|
||||
return None
|
||||
rendered: List[Tuple[str, str, str]] = [] # (prefix cues, gloss, suffix cues)
|
||||
any_known = False
|
||||
for w in words:
|
||||
pre, gloss, post, known = self._analyze_word(w)
|
||||
any_known = any_known or known
|
||||
rendered.append((pre, gloss, post))
|
||||
if not any_known:
|
||||
return None
|
||||
out: List[str] = []
|
||||
for pre, gloss, post in rendered:
|
||||
for c in pre.split():
|
||||
out.append(c)
|
||||
out.append(gloss)
|
||||
for c in post.split():
|
||||
out.append(c)
|
||||
return " ".join(x for x in out if x)
|
||||
|
||||
def _analyze_word(self, w: str) -> Tuple[str, str, str, bool]:
|
||||
"""Decompose one surface word into (prefix cues, stem gloss, suffix
|
||||
cues, matched?). Tries direct lexicon hit, then affix stripping
|
||||
(longest affix first), then returns the word untouched."""
|
||||
if w in self._morphs:
|
||||
return "", self._morphs[w].gloss, "", True
|
||||
affixes = sorted(self.g.affixes, key=lambda a: -len(a.form))
|
||||
for a in affixes:
|
||||
if a.position == "prefix" and w.startswith(a.form):
|
||||
pre, gloss, post, ok = self._analyze_word(w[len(a.form):])
|
||||
if ok:
|
||||
return (self._cue(a) + " " + pre).strip(), gloss, post, True
|
||||
if a.position == "suffix" and w.endswith(a.form):
|
||||
pre, gloss, post, ok = self._analyze_word(w[: len(w) - len(a.form)])
|
||||
if ok:
|
||||
return pre, gloss, (post + " " + self._cue(a)).strip(), True
|
||||
if a.position == "circumfix" and w.startswith(a.form) and w.endswith(a.form2 or a.form):
|
||||
inner = w[len(a.form): len(w) - len(a.form2 or a.form)]
|
||||
pre, gloss, post, ok = self._analyze_word(inner)
|
||||
if ok:
|
||||
return (self._cue(a) + " " + pre).strip(), gloss, post, True
|
||||
# last resort: greedy stem containment (rewrite rules may have altered edges)
|
||||
for morph, entry in sorted(self._morphs.items(), key=lambda kv: -len(kv[0])):
|
||||
if len(morph) >= 3 and morph in w:
|
||||
return "", entry.gloss, "", True
|
||||
return "", w, "", False
|
||||
|
||||
@staticmethod
|
||||
def _cue(a: Affix) -> str:
|
||||
"""How an affix surfaces in the work-language output: function-word
|
||||
cues are emitted verbatim; abstract markers (PL, PST) are dropped —
|
||||
the proposer should prefer word cues for translatable material."""
|
||||
cue = a.feature.strip()
|
||||
if cue and cue.isalpha() and cue.casefold() == cue:
|
||||
return cue
|
||||
return ""
|
||||
Reference in New Issue
Block a user