147 lines
5.8 KiB
Python
147 lines
5.8 KiB
Python
"""Template translation by minimal-pair substitution — the strongest
|
|
zero-LLM translation baseline for constructed puzzles.
|
|
|
|
Idea: puzzles are built so query sentences differ from attested ones by a
|
|
small substitution. Find the attested pair whose source is closest to the
|
|
query (token-level), then replace the differing tokens in its *target* using
|
|
alignment links (align.py). Works in both directions. Also supports
|
|
morph-level substitution for single-word queries (paradigm cells).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections import Counter
|
|
from typing import Dict, List, Optional, Tuple
|
|
|
|
from .align import align as build_align, one_to_one
|
|
from .preprocess import Pair, strip_punct, tokenize
|
|
|
|
|
|
def _toks(s: str) -> List[str]:
|
|
return [strip_punct(t).casefold() for t in tokenize(s) if strip_punct(t)]
|
|
|
|
|
|
def _flip(pairs: List[Pair]) -> List[Pair]:
|
|
return [Pair(src=p.tgt, tgt=p.src) for p in pairs]
|
|
|
|
|
|
class TemplateTranslator:
|
|
"""direction 'to_work': translate task->work; 'to_task': work->task."""
|
|
|
|
def __init__(self, pairs: List[Pair], direction: str = "to_work"):
|
|
self.pairs = pairs if direction == "to_work" else _flip(pairs)
|
|
self.amap = build_align(self.pairs) # src tok -> ranked [(tgt, score)]
|
|
|
|
def _sub(self, src_tok: str) -> Optional[str]:
|
|
cands = self.amap.get(src_tok.casefold())
|
|
return cands[0][0] if cands else None
|
|
|
|
def _sub_in(self, src_tok: str, pool: List[str]) -> Optional[str]:
|
|
"""Best candidate for src_tok that is present in pool (context-aware:
|
|
a token may have both a bare and an inflected realization; the one
|
|
actually in the template target is the right one)."""
|
|
for c, _ in self.amap.get(src_tok.casefold(), []):
|
|
if c in pool:
|
|
return c
|
|
return None
|
|
|
|
def _sub_like(self, src_tok: str, model: str) -> Optional[str]:
|
|
"""Best candidate for src_tok, preferring one that shares an affix
|
|
(prefix/suffix >= 2 chars) with `model` — the form it will replace.
|
|
kupu:nakupu :: moko:namoko."""
|
|
cands = self.amap.get(src_tok.casefold(), [])
|
|
for c, _ in cands:
|
|
if len(c) >= 2 and len(model) >= 2 and (c[:2] == model[:2] or c[-2:] == model[-2:]):
|
|
return c
|
|
return cands[0][0] if cands else None
|
|
|
|
def translate(self, query: str) -> Optional[str]:
|
|
q = _toks(query)
|
|
if not q:
|
|
return None
|
|
# rank templates by token-bag distance, then by length mismatch: a
|
|
# same-length template is a substitution frame; a much shorter one
|
|
# (e.g. a single-word gloss) would force fabricating structure
|
|
ranked = sorted(
|
|
((_bag_distance(q, _toks(p.src)), abs(len(_toks(p.src)) - len(q)),
|
|
_toks(p.src), _toks(p.tgt)) for p in self.pairs),
|
|
key=lambda x: (x[0], x[1]),
|
|
)
|
|
max_dist = max(2, len(q) // 2)
|
|
for dist, _, s, t in ranked:
|
|
if dist == 0:
|
|
return " ".join(t)
|
|
if dist > max_dist:
|
|
break
|
|
out = self._substitute(q, s, t)
|
|
if out:
|
|
return out
|
|
return None
|
|
|
|
def _substitute(self, q: List[str], s: List[str], t: List[str]) -> Optional[str]:
|
|
"""Swap the tokens where query and template source differ, mapping
|
|
both sides through the alignment. Abstains (None) when any needed
|
|
link is missing — a wrong-but-confident answer is worse than letting
|
|
the next template or the fallback ladder take over."""
|
|
q_extra = list((Counter(q) - Counter(s)).elements())
|
|
s_extra = list((Counter(s) - Counter(q)).elements())
|
|
out = list(t)
|
|
used: set = set()
|
|
for s_tok in s_extra:
|
|
s_tgt = self._sub_in(s_tok, out)
|
|
if s_tgt is None:
|
|
return None
|
|
repl = None
|
|
for qi, q_tok in enumerate(q_extra):
|
|
if qi in used:
|
|
continue
|
|
q_tgt = self._sub_like(q_tok, s_tgt)
|
|
if q_tgt:
|
|
repl = q_tgt
|
|
used.add(qi)
|
|
break
|
|
if repl is None:
|
|
return None
|
|
out[out.index(s_tgt)] = repl
|
|
for qi, q_tok in enumerate(q_extra):
|
|
if qi not in used:
|
|
q_tgt = self._sub(q_tok)
|
|
if q_tgt and q_tgt not in out:
|
|
out.append(q_tgt)
|
|
return " ".join(out) if out else None
|
|
|
|
|
|
def _bag_distance(a: List[str], b: List[str]) -> int:
|
|
ca, cb = Counter(a), Counter(b)
|
|
return sum((ca - cb).values()) + sum((cb - ca).values())
|
|
|
|
|
|
def paradigm_complete(stem: str, pairs: List[Pair], cue: str = "") -> Optional[str]:
|
|
"""Complete a paradigm cell: find attested form-pairs (a, b) sharing a
|
|
stem, group them by their string edit, and apply the dominant edit to
|
|
`stem`. `cue` (e.g. 'plural') restricts to pairs whose gloss relation
|
|
mentions the cue when glosses are available."""
|
|
from .analogy import edit_rules, apply_rule
|
|
|
|
vocab: Dict[str, str] = {} # form -> gloss
|
|
for p in pairs:
|
|
if " " not in p.src.strip():
|
|
vocab[p.src.strip().casefold()] = p.tgt.strip().casefold()
|
|
|
|
rules: Counter = Counter()
|
|
for a in vocab:
|
|
for b in vocab:
|
|
if a != b and len(b) > len(a) and b.startswith(a[: max(2, len(a) - 1)]):
|
|
for r in edit_rules(a, b):
|
|
if cue:
|
|
ga, gb = vocab.get(a, ""), vocab.get(b, "")
|
|
# cue must relate the two glosses (e.g. 'houses' vs 'house')
|
|
if not (ga and gb and (ga in gb or gb in ga)):
|
|
continue
|
|
rules[r] += 1
|
|
for r, _ in rules.most_common(3):
|
|
out = apply_rule(r, stem.casefold())
|
|
if out and out != stem:
|
|
return out
|
|
return None
|