Files
iolai26-solve/solver/tables.py

182 lines
7.3 KiB
Python
Raw Permalink Normal View History

"""Table-completion solver: answer items that are rows of a paradigm table
with one or more cells missing.
Covers the recurring Linguini patterns:
- fill_blanks with (k) markers in any column ("netkayʼ | (1) | push"),
including damaged rows where a marker merged with text ("*ʔikat | (4) | (5) to tie");
- numbered query rows lacking one column the context table has
("12. gsnqo'qon | foolishness" against context "word | [IPA] | gloss");
- multi-language columns (Proto-Chamic | Phan Rang Cham | Tsat | meaning).
Method per item:
1. strip (k) markers; the remaining non-empty cell texts are the knowns;
2. map knowns to context-table columns by character overlap (greedy);
3. assign the row's markers, in order, to the free columns, preferring the
free column matching the marker's position in the row (first/last);
4. build (known-column -> answer-column) pairs from the table and pick the
most learnable source column by leave-one-out template-translator fit;
5. predict; abstention returns None (router falls through to LLM/fallback).
"""
from __future__ import annotations
from collections import Counter
from typing import Callable, List, Optional, Sequence, Tuple
from .analogy import solve_from_pairs as analogy_vote
from .fallback import fallback_answer
from .preprocess import _BLANK_MARK, Pair, Puzzle, QueryItem
from .template import TemplateTranslator
from .verifier import leave_one_out
def _col_overlap(cell: str, column: Sequence[str], n: int = 3) -> float:
s = "".join(cell.lower().split())
material = " ".join(c.lower() for c in column)
if len(s) < n:
return 1.0 if any(cell.strip() == c.strip() for c in column) else 0.0
grams = {s[i : i + n] for i in range(len(s) - n + 1)}
return sum(1 for g in grams if g in material) / max(len(grams), 1)
def _clean_rows(table: List[List[str]]) -> List[List[str]]:
if not table:
return []
width = Counter(len(r) for r in table).most_common(1)[0][0]
return [r for r in table
if len(r) == width and not any(_BLANK_MARK.search(c) for c in r)]
def pick_table(puzzle: Puzzle, min_width: int = 2) -> List[List[str]]:
best: List[List[str]] = []
for t in puzzle.tables:
rows = _clean_rows(t)
if rows and len(rows[0]) >= min_width and len(rows) > len(best):
best = rows
return best
def _column_pairs(table: List[List[str]], src_col: int, ans_col: int) -> List[Pair]:
out = []
for r in table:
s, t = r[src_col].strip(), r[ans_col].strip()
if s and t and s != "-" and t != "-":
out.append(Pair(src=s, tgt=t, sep="table"))
return out
def _predictor_for(table: List[List[str]], src_col: int, ans_col: int
) -> Tuple[float, Callable[[str], Optional[str]]]:
"""LOO-scored predictor mapping src_col text to ans_col text. Chain:
template translation (multi-word rows), char-level analogy voting
(single-word paradigm columns: a:b :: query:x over all column pairs),
then the echo fallback."""
pairs = _column_pairs(table, src_col, ans_col)
if len(pairs) < 2:
return -1.0, lambda q: None
attested = [(p.src, p.tgt) for p in pairs]
def make(pair_list: List[Pair]) -> Callable[[str], Optional[str]]:
tmpl = TemplateTranslator(pair_list, "to_work")
ana = [(p.src, p.tgt) for p in pair_list]
def predict(q: str) -> Optional[str]:
ans = tmpl.translate(q)
if ans:
return ans
votes = analogy_vote(ana, q)
if votes:
return votes[0]
return fallback_answer(q, pair_list, "to_work")
return predict
def fit(held_in):
keep = set(held_in)
return make([p for p in pairs if (p.src, p.tgt) in keep])
v = leave_one_out(fit, attested)
return v.score, make(pairs)
class TableSolver:
"""Per-puzzle: caches the context table and column predictors."""
def __init__(self, puzzle: Puzzle):
self.table = pick_table(puzzle)
self._pred_cache: dict = {}
@property
def usable(self) -> bool:
return len(self.table) >= 2
def _predictor(self, src_col: int, ans_col: int):
key = (src_col, ans_col)
if key not in self._pred_cache:
self._pred_cache[key] = _predictor_for(self.table, src_col, ans_col)
return self._pred_cache[key]
def solve(self, item: QueryItem) -> Optional[Tuple[str, float]]:
"""Returns (answer, confidence) or None. Confidence is the LOO fit of
the chosen column predictor."""
if not self.usable or not item.row:
return None
n_cols = len(self.table[0])
cols = [[r[c] for r in self.table] for c in range(n_cols)]
# knowns: marker-stripped non-empty cells, with their row position
knowns: List[Tuple[int, str]] = []
marker_pos: List[Tuple[int, str]] = [] # (cell index, marker number)
for ci, cell in enumerate(item.row):
for k in _BLANK_MARK.findall(cell):
marker_pos.append((ci, k))
text = _BLANK_MARK.sub("", cell).strip()
if text and text != "-":
knowns.append((ci, text))
if not marker_pos and item.blank_col is None:
return None
if len(item.row) == n_cols and item.blank_col is not None:
# same shape as the context table: mapping is positional and the
# marker's own column is the answer column
known_col = {ki: ci for ki, (ci, _) in enumerate(knowns)}
ans_col = item.blank_col
else:
# width mismatch (damaged/narrow rows): map knowns to table
# columns by char overlap with a positional prior as tie-break
scored = sorted(
((_col_overlap(t, cols[c]) + 0.01 / (1 + abs(ci - c)), ki, c)
for ki, (ci, t) in enumerate(knowns) for c in range(n_cols)),
reverse=True,
)
known_col = {}
used = set()
for s, ki, c in scored:
if ki in known_col or c in used:
continue
known_col[ki] = c
used.add(c)
free = [c for c in range(n_cols) if c not in used]
if not free:
return None
# markers in row order take free columns in order; a single
# marker at the row's edge prefers the matching edge column
my_marker_idx = next(
(mi for mi, (_, k) in enumerate(marker_pos) if k == item.number), 0)
if len(marker_pos) <= 1 and item.row and len(free) > 1:
cell_idx = marker_pos[0][0] if marker_pos else (item.blank_col or 0)
ans_col = free[-1] if cell_idx >= len(item.row) - 1 else free[0]
else:
ans_col = free[min(my_marker_idx, len(free) - 1)]
# pick the most learnable known column as the source
best_score, best_predict, best_ki = -1.0, None, None
for ki, c in known_col.items():
score, predict = self._predictor(c, ans_col)
if score > best_score:
best_score, best_predict, best_ki = score, predict, ki
if best_predict is None or best_ki is None:
return None
ans = best_predict(knowns[best_ki][1])
return (ans, max(best_score, 0.0)) if ans else None