初始化项目,由ModelHub XC社区提供模型

Model: rpant/iolai26-solve
Source: Original Platform
This commit is contained in:
ModelHub XC
2026-07-28 09:36:12 +08:00
commit 5b016c1af1
45 changed files with 461120 additions and 0 deletions

0
solver/dsl/__init__.py Normal file
View File

188
solver/dsl/grammar.py Normal file
View 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
View 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 ""