初始化项目,由ModelHub XC社区提供模型
Model: rpant/iolai26-solve Source: Original Platform
This commit is contained in:
247
solver/numerals.py
Normal file
247
solver/numerals.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""Numeral system induction: recover morpheme values + combination structure
|
||||
from attested (numeral phrase, integer) pairs, then convert both directions.
|
||||
|
||||
Model (covers the large majority of IOL numeral systems):
|
||||
value(phrase) = fold over tokens, where adjacent (multiplier, base-power)
|
||||
groups combine multiplicatively and groups combine additively — i.e. the
|
||||
standard "mixed-radix polynomial" reading: [2] [20] [3] -> 2*20 + 3.
|
||||
Some systems are subtractive or overcounting; a signed-additive variant is
|
||||
also searched. Token values are solved by constraint search: each distinct
|
||||
token gets an unknown integer value; attested equations constrain them.
|
||||
|
||||
Search is tiny: numeral puzzles use ~5-15 morpheme types with values drawn
|
||||
from {1..9, base, base^2, ...}. We enumerate candidate value sets per token
|
||||
from divisors/residues of the attested numbers, then DFS with propagation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import Counter
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
from .preprocess import strip_punct, tokenize
|
||||
|
||||
BASES = (10, 20, 5, 12, 60, 4, 6, 8, 15)
|
||||
MAX_TOKEN_VALUE = 10_000
|
||||
|
||||
|
||||
def _norm_tokens(phrase: str) -> List[str]:
|
||||
toks = []
|
||||
for t in tokenize(phrase.casefold()):
|
||||
t = strip_punct(t)
|
||||
# split on hyphens: numeral compounds are often hyphenated
|
||||
toks.extend([p for p in re.split(r"[-‑]", t) if p])
|
||||
return toks
|
||||
|
||||
|
||||
def _eval(vals: Sequence[int]) -> int:
|
||||
"""Evaluate token values with the multiplicative-additive convention: a
|
||||
smaller value directly before a larger one multiplies it; otherwise
|
||||
values add. E.g. [2,20,3] -> 2*20+3 = 43; [3,100,20,7] -> 327."""
|
||||
total = 0
|
||||
cur = vals[0]
|
||||
for prev, v in zip(vals, vals[1:]):
|
||||
if v > prev:
|
||||
cur = cur * v # e.g. 2 then 20 -> 40
|
||||
else:
|
||||
total += cur
|
||||
cur = v
|
||||
return total + cur
|
||||
|
||||
|
||||
class NumeralSystem:
|
||||
def __init__(self, values: Dict[str, int]):
|
||||
self.values = dict(values)
|
||||
|
||||
def text_to_num(self, phrase: str) -> Optional[int]:
|
||||
toks = _norm_tokens(phrase)
|
||||
if not toks or any(t not in self.values for t in toks):
|
||||
return None
|
||||
return _eval([self.values[t] for t in toks])
|
||||
|
||||
def num_to_text(self, n: int, attested_phrases: Sequence[str]) -> Optional[str]:
|
||||
"""Generate the phrase for n: enumerate token sequences (up to length
|
||||
6) whose evaluation equals n, then pick the one most consistent with
|
||||
the attested phrasing style (e.g. do multi-token phrases always give
|
||||
a base its explicit multiplier, even 'one'?)."""
|
||||
toks = sorted(self.values, key=lambda t: -self.values[t])
|
||||
found: List[List[str]] = []
|
||||
self._search(n, toks, [], 6, found, limit=16, budget=[100_000])
|
||||
if not found:
|
||||
return None
|
||||
style = _StyleModel(self.values, attested_phrases)
|
||||
found.sort(key=lambda seq: (-style.score(seq), len(seq)))
|
||||
return " ".join(found[0])
|
||||
|
||||
def _search(self, target: int, toks: List[str], acc: List[str], depth: int,
|
||||
found: List[List[str]], limit: int, budget: List[int]) -> None:
|
||||
if len(found) >= limit or budget[0] <= 0:
|
||||
return
|
||||
budget[0] -= 1
|
||||
if target == 0 and acc:
|
||||
found.append(list(acc))
|
||||
return
|
||||
if depth == 0 or target <= 0:
|
||||
return
|
||||
for t in toks:
|
||||
v = self.values[t]
|
||||
if v > target:
|
||||
continue
|
||||
# multiplicative: k * v <= target with k attested as token
|
||||
for m in toks:
|
||||
mv = self.values[m]
|
||||
if 1 <= mv < v and mv * v <= target:
|
||||
self._search(target - mv * v, toks, acc + [m, t], depth - 2,
|
||||
found, limit, budget)
|
||||
self._search(target - v, toks, acc + [t], depth - 1, found, limit, budget)
|
||||
|
||||
|
||||
class _StyleModel:
|
||||
"""Scores a candidate numeral phrase by consistency with attested style:
|
||||
(a) are base tokens (value >= 10) given an explicit smaller multiplier in
|
||||
attested multi-token phrases? (b) reuse of attested token bigrams."""
|
||||
|
||||
def __init__(self, values: Dict[str, int], phrases: Sequence[str]):
|
||||
self.values = values
|
||||
self.bigrams = set()
|
||||
obs: List[bool] = []
|
||||
for ph in phrases:
|
||||
toks = _norm_tokens(ph)
|
||||
if not toks or any(t not in values for t in toks):
|
||||
continue
|
||||
self.bigrams.update(zip(toks, toks[1:]))
|
||||
if len(toks) < 2:
|
||||
continue
|
||||
for i, t in enumerate(toks):
|
||||
if values[t] >= 10:
|
||||
obs.append(i > 0 and values[toks[i - 1]] < values[t])
|
||||
self.prefer_explicit = sum(obs) > len(obs) / 2 if obs else False
|
||||
|
||||
def score(self, seq: Sequence[str]) -> float:
|
||||
s = 0.0
|
||||
s += 0.5 * sum(1 for bg in zip(seq, seq[1:]) if bg in self.bigrams)
|
||||
base_seen: Counter = Counter()
|
||||
if len(seq) >= 2:
|
||||
for i, t in enumerate(seq):
|
||||
if self.values[t] >= 10:
|
||||
base_seen[t] += 1
|
||||
explicit = i > 0 and self.values[seq[i - 1]] < self.values[t]
|
||||
s += 1.0 if explicit == self.prefer_explicit else -1.0
|
||||
# positional systems use each base power once; repeats are degenerate
|
||||
s -= 2.0 * sum(c - 1 for c in base_seen.values())
|
||||
return s
|
||||
|
||||
|
||||
def induce(attested: List[Tuple[str, int]], max_candidates: int = 8) -> Optional[NumeralSystem]:
|
||||
"""Induce token values from attested (phrase, value) pairs by DFS with
|
||||
forward checking. Candidate values per token come from structural
|
||||
positions: divisors of attested values, small digits, and base powers."""
|
||||
eqs: List[Tuple[List[str], int]] = []
|
||||
vocab: List[str] = []
|
||||
for phrase, val in attested:
|
||||
toks = _norm_tokens(phrase)
|
||||
if not toks:
|
||||
continue
|
||||
eqs.append((toks, val))
|
||||
for t in toks:
|
||||
if t not in vocab:
|
||||
vocab.append(t)
|
||||
if not eqs:
|
||||
return None
|
||||
|
||||
# Candidate values per token.
|
||||
digits = set(range(1, 10))
|
||||
base_powers = {b ** k for b in BASES for k in (1, 2, 3) if b ** k <= MAX_TOKEN_VALUE}
|
||||
cands: Dict[str, List[int]] = {}
|
||||
for t in vocab:
|
||||
cs = set(digits) | base_powers
|
||||
# a token appearing alone in an equation must equal that value
|
||||
for toks, val in eqs:
|
||||
if toks == [t]:
|
||||
cs = {val}
|
||||
break
|
||||
if t in toks:
|
||||
cs |= {val} | {d for d in _divisors(val) if d <= MAX_TOKEN_VALUE}
|
||||
cands[t] = sorted(cs)
|
||||
|
||||
# Constraint propagation on short equations before search: a 1-token
|
||||
# equation pins its token; a 2-token equation with one token pinned
|
||||
# constrains the other to {V-a, V/a}.
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for toks, val in eqs:
|
||||
unknown = [t for t in set(toks) if len(cands[t]) > 1]
|
||||
if len(set(toks)) == 1:
|
||||
t = toks[0]
|
||||
if len(toks) == 1 and cands[t] != [val]:
|
||||
cands[t] = [val]
|
||||
changed = True
|
||||
elif len(toks) == 2 and len(unknown) == 1:
|
||||
t = unknown[0]
|
||||
other = toks[0] if toks[1] == t else toks[1]
|
||||
if len(cands[other]) == 1:
|
||||
a = cands[other][0]
|
||||
allowed = {val - a}
|
||||
if a and val % a == 0:
|
||||
allowed.add(val // a)
|
||||
new = [v for v in cands[t] if v in allowed]
|
||||
if new and new != cands[t]:
|
||||
cands[t] = new
|
||||
changed = True
|
||||
|
||||
# Order: most-constrained tokens first.
|
||||
order = sorted(vocab, key=lambda t: len(cands[t]))
|
||||
|
||||
assignment: Dict[str, int] = {}
|
||||
budget = {"nodes": 200_000}
|
||||
|
||||
def consistent() -> bool:
|
||||
for toks, val in eqs:
|
||||
if all(t in assignment for t in toks):
|
||||
if _eval([assignment[t] for t in toks]) != val:
|
||||
return False
|
||||
return True
|
||||
|
||||
def dfs(i: int) -> bool:
|
||||
if budget["nodes"] <= 0:
|
||||
return False # search space too big — abstain, don't hang
|
||||
if i == len(order):
|
||||
return True
|
||||
t = order[i]
|
||||
for v in cands[t]:
|
||||
budget["nodes"] -= 1
|
||||
assignment[t] = v
|
||||
if consistent() and dfs(i + 1):
|
||||
return True
|
||||
assignment.pop(t, None)
|
||||
return False
|
||||
|
||||
if dfs(0) and budget["nodes"] > 0:
|
||||
sys_ = NumeralSystem(assignment)
|
||||
# verify every attested equation round-trips
|
||||
if all(sys_.text_to_num(p) == v for p, v in attested):
|
||||
return sys_
|
||||
return None
|
||||
|
||||
|
||||
def _divisors(n: int) -> List[int]:
|
||||
n = abs(n)
|
||||
out = []
|
||||
for d in range(1, int(n ** 0.5) + 1):
|
||||
if n % d == 0:
|
||||
out += [d, n // d]
|
||||
return sorted(set(out))
|
||||
|
||||
|
||||
def extract_attested(pairs) -> List[Tuple[str, int]]:
|
||||
"""From preprocess Pairs, pull (phrase, int) where one side is a number."""
|
||||
out = []
|
||||
for p in pairs:
|
||||
for a, b in ((p.src, p.tgt), (p.tgt, p.src)):
|
||||
bs = b.strip().replace(",", "").replace(" ", "")
|
||||
if re.fullmatch(r"\d+", bs):
|
||||
out.append((a, int(bs)))
|
||||
break
|
||||
return out
|
||||
Reference in New Issue
Block a user