open-standards/aac/aac-rs/tools/pagesets/pslib/solve.py
Transaction Science abbe4becb6 feat(aac): two open page sets — Growing Core and Fixed Paths — as Open Board Format
Our own vocabularies in the styles of a growing core and a motor-plan set,
built from cited open sources, never from a vendor's words, positions or names.

Growing Core: one core board through eleven sizes (1x1 to 8x10). Every item
lives at one point of the screen and is drawn, at every size, in the cell that
contains it, so a word learned at 3x3 is in the same region at 8x10. The board
is full at every size and nothing that appeared ever goes. The first boards are
designed by hand; a solver places the rest by Fitzgerald region. Ten pages of
more words, each with Back in the top-left corner; social messages with
repairs; a Word forms page; hide-and-reveal at a fixed size from each word's
stage.

Fixed Paths: one meaning per location and one path per word, of 1 to 3 hits,
at 30, 60 and 84 locations. The heaviest words (spoken frequency, more for
Universal Core and Banajee 2003 words) take one hit in the easiest places for
a blend of touch, scanning and gaze; a page is closed by the place of the key
that opened it. No multi-meaning icon sequences. A vocabulary filter
(starter) hides the rest in place.

tools/pagesets: design sources (YAML), the solver (run once, committed as
layouts), and a reproducible build that writes 19 .obz packages, an index,
metrics and a per-language report to data/pagesets. aac-core tests/pagesets.rs
imports every package with the app's own importer and checks positions,
actions, word classes, that every symbol is the one the app draws, that each
button says what the app's own export would, the growth and path rules, and
language honesty.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-05 11:23:41 -04:00

257 lines
10 KiB
Python

"""The layout solver: items placed in atoms so that every size stays valid.
Used by a designer (tools/pagesets/solve.py) to propose where items live; the
result is written to a layout YAML, reviewed, and committed. The build never
runs the solver: it reads the committed positions and checks them.
Search
------
1. Construct: stage by stage, give each item of the stage an atom in a cell of
that size no earlier item covers (depth-first, most-constrained item first,
atoms in random order, honouring an item's allowed atoms). With as many
items per stage as the size grows by, every cell of every size is filled.
2. Improve: simulated annealing over moves of one item to another atom, or
swaps of two items' atoms, keeping every pair of items compatible (never
in one cell at a size where both are present).
3. Polish: within each stage, the cheapest assignment of that stage's items
to that stage's atoms (Hungarian algorithm) — swapping items of one stage
can never make a map invalid.
"""
from __future__ import annotations
import math
import random
from dataclasses import dataclass, field
from typing import Callable
from .grid import Ladder
def hungarian(cost: list[list[float]]) -> tuple[float, list[int]]:
"""Minimum-cost perfect matching of a square matrix: (total, col for each row)."""
n = len(cost)
if n == 0:
return 0.0, []
INF = float("inf")
u = [0.0] * (n + 1)
v = [0.0] * (n + 1)
p = [0] * (n + 1)
way = [0] * (n + 1)
for i in range(1, n + 1):
p[0] = i
j0 = 0
minv = [INF] * (n + 1)
used = [False] * (n + 1)
while True:
used[j0] = True
i0 = p[j0]
delta = INF
j1 = 0
for j in range(1, n + 1):
if not used[j]:
cur = cost[i0 - 1][j - 1] - u[i0] - v[j]
if cur < minv[j]:
minv[j] = cur
way[j] = j0
if minv[j] < delta:
delta = minv[j]
j1 = j
for j in range(n + 1):
if used[j]:
u[p[j]] += delta
v[j] -= delta
else:
minv[j] -= delta
j0 = j1
if p[j0] == 0:
break
while True:
j1 = way[j0]
p[j0] = p[j1]
j0 = j1
if j0 == 0:
break
assign = [0] * n
for j in range(1, n + 1):
if p[j]:
assign[p[j] - 1] = j - 1
return sum(cost[i][assign[i]] for i in range(n)), assign
@dataclass
class Item:
key: str
stage: int # ladder index at which it first appears
pinned: int | None = None # atom, when fixed by the designer
allowed: set[int] | None = None # atoms it may take (None: any)
data: dict = field(default_factory=dict)
class StageSolver:
def __init__(
self,
ladder: Ladder,
items: list[Item],
cost: Callable[[Item, int], float],
full: bool = True,
seed: int = 1,
):
self.ladder = ladder
self.items = items
self.cost = cost
self.full = full
self.rng = random.Random(seed)
self.k = len(ladder.sizes)
self.by_stage: list[list[Item]] = [[] for _ in range(self.k)]
for it in items:
self.by_stage[it.stage].append(it)
if it.pinned is not None:
it.allowed = {it.pinned}
if full:
steps = ladder.capacity_steps()
counts = [len(s) for s in self.by_stage]
if counts != steps:
raise ValueError(f"items per stage {counts} must equal capacity steps {steps}")
self._cc: dict[tuple[str, int], float] = {}
def c(self, it: Item, atom: int) -> float:
key = (it.key, atom)
if key not in self._cc:
self._cc[key] = self.cost(it, atom)
return self._cc[key]
def _fits(self, atom: int, stage: int, placed: dict[str, tuple[int, int]], skip: tuple[str, ...] = ()) -> bool:
lad = self.ladder
for key, (a, s) in placed.items():
if key in skip:
continue
if not lad.compatible(atom, stage, a, s):
return False
return True
# ── 1. construct ──────────────────────────────────────────────────────
def construct(self, tries: int = 400, node_limit: int = 20000) -> dict[str, int]:
lad = self.ladder
for _ in range(tries):
placed: dict[str, tuple[int, int]] = {}
ok = True
for k in range(self.k):
items = self.by_stage[k]
taken = {a for a, _ in placed.values()}
covered = {lad.block[k][a] for a, s in placed.values()}
opts = {}
for it in items:
cand = it.allowed if it.allowed is not None else range(lad.n)
o = [a for a in cand if a not in taken and lad.block[k][a] not in covered and self._fits(a, k, placed)]
self.rng.shuffle(o)
# prefer cheaper atoms, with some randomness
o.sort(key=lambda a, it=it: self.c(it, a) * (0.7 + 0.6 * self.rng.random()))
opts[it.key] = o
order = sorted(items, key=lambda it: len(opts[it.key]))
nodes = [0]
chosen: dict[str, int] = {}
def dfs(i: int) -> bool:
if i == len(order):
return True
nodes[0] += 1
if nodes[0] > node_limit:
return False
it = order[i]
blocks = {lad.block[k][a] for a in chosen.values()}
for a in opts[it.key]:
if lad.block[k][a] in blocks:
continue
if any(not lad.compatible(a, k, b, k) for b in chosen.values()):
continue
chosen[it.key] = a
if dfs(i + 1):
return True
del chosen[it.key]
return False
if not dfs(0):
ok = False
break
for key, a in chosen.items():
placed[key] = (a, k)
if ok:
return {key: a for key, (a, _) in placed.items()}
raise RuntimeError("no valid placement found: loosen the anchors or try another seed")
# ── 2. improve ────────────────────────────────────────────────────────
def total(self, where: dict[str, int]) -> float:
return sum(self.c(it, where[it.key]) for it in self.items)
def anneal(self, where: dict[str, int], iters: int) -> dict[str, int]:
lad = self.ladder
stage = {it.key: it.stage for it in self.items}
item = {it.key: it for it in self.items}
movable = [it for it in self.items if it.pinned is None]
placed = {k: (a, stage[k]) for k, a in where.items()}
occupied = {a: k for k, a in where.items()}
cur = self.total(where)
t0 = max(cur * 0.01, 1e-6)
t1 = t0 * 1e-4
best, best_cost = dict(where), cur
for step in range(iters):
t = t0 * (t1 / t0) ** (step / max(1, iters - 1))
it = self.rng.choice(movable)
a_old = placed[it.key][0]
cand = list(it.allowed) if it.allowed is not None else None
b = self.rng.choice(cand) if cand else self.rng.randrange(lad.n)
if b == a_old:
continue
other = occupied.get(b)
if other is None:
if not self._fits(b, stage[it.key], placed, skip=(it.key,)):
continue
delta = self.c(it, b) - self.c(it, a_old)
if delta <= 0 or self.rng.random() < math.exp(-delta / t):
placed[it.key] = (b, stage[it.key])
del occupied[a_old]
occupied[b] = it.key
cur += delta
else:
ot = item[other]
if ot.pinned is not None or (ot.allowed is not None and a_old not in ot.allowed):
continue
trial = dict(placed)
trial[it.key] = (b, stage[it.key])
trial[other] = (a_old, stage[other])
if not self._fits(b, stage[it.key], trial, skip=(it.key,)) or not self._fits(a_old, stage[other], trial, skip=(other,)):
continue
delta = self.c(it, b) + self.c(ot, a_old) - self.c(it, a_old) - self.c(ot, b)
if delta <= 0 or self.rng.random() < math.exp(-delta / t):
placed = trial
occupied[b], occupied[a_old] = it.key, other
cur += delta
if cur < best_cost - 1e-12:
best, best_cost = {k: a for k, (a, _) in placed.items()}, cur
return best
# ── 3. polish ─────────────────────────────────────────────────────────
def polish(self, where: dict[str, int]) -> dict[str, int]:
out = dict(where)
for k in range(self.k):
items = [it for it in self.by_stage[k] if it.pinned is None]
atoms = [out[it.key] for it in items]
if len(items) < 2:
continue
big = 1e9
m = [[self.c(it, a) if (it.allowed is None or a in it.allowed) else big for a in atoms] for it in items]
_, assign = hungarian(m)
for r, it in enumerate(items):
out[it.key] = atoms[assign[r]]
return out
def solve(self, restarts: int = 6, iters: int = 20000) -> tuple[dict[str, int], float]:
best, best_cost = None, float("inf")
for _ in range(restarts):
w = self.polish(self.anneal(self.construct(), iters))
c = self.total(w)
if c < best_cost:
best, best_cost = w, c
assert best is not None
return best, best_cost