Files
iolai26-solve/solver/numerals.py
ModelHub XC 5b016c1af1 初始化项目,由ModelHub XC社区提供模型
Model: rpant/iolai26-solve
Source: Original Platform
2026-07-28 09:36:12 +08:00

248 lines
9.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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