open-standards/aac/aac-rs/tools/pagesets/pslib/grid.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

155 lines
6 KiB
Python

"""Grid sizes, the location rule, and nested stage maps.
The location rule (both page sets use it)
-----------------------------------------
Every item lives at one point (v, u) of the unit square: v down, u across.
On any grid R x C it is drawn in the cell that contains that point:
row = floor(v * R), col = floor(u * C)
So an item's place on every size is a function of its point alone: it never
moves from where it lives, and on any grid it is at most half a cell from its
point. Every button an item has, on every size, contains that one point.
Points are not free: an item lives in an *atom*, one rectangle of the common
refinement of the cell boundaries of every size in the ladder (all the
fractions k/R and k/C). An atom never straddles a cell boundary of any size,
so its centre is a safe point. (The cell centres of the largest grid alone are
not enough: going from 6x8 to 7x9 merges cells of 8x10 that 6x8 keeps apart,
so no layout on 8x10 centres can keep every size full.)
Stage maps
----------
An item's *stage* is the first ladder size at which it appears. A stage map is
valid when, at every size, no cell holds two items whose stages are at or
before that size: nothing that appeared at a smaller size is ever pushed out
at a larger one. When the number of items per stage equals the growth in
capacity between sizes, valid also means every cell of every size is filled.
"""
from __future__ import annotations
from dataclasses import dataclass
from fractions import Fraction
from functools import lru_cache
@dataclass(frozen=True, order=True)
class Size:
rows: int
cols: int
@property
def cells(self) -> int:
return self.rows * self.cols
@property
def name(self) -> str:
return f"{self.rows}x{self.cols}"
@staticmethod
def parse(text: str) -> "Size":
r, c = text.lower().split("x")
return Size(int(r), int(c))
def __str__(self) -> str:
return self.name
def place(point: tuple[float, float], size: Size) -> tuple[int, int]:
"""The cell (row, col) of `size` that contains `point` (v, u)."""
v, u = point
return min(int(v * size.rows), size.rows - 1), min(int(u * size.cols), size.cols - 1)
def centre(cell: tuple[int, int], size: Size) -> tuple[float, float]:
"""Normalised centre (v, u) of `cell` on `size`."""
return (cell[0] + 0.5) / size.rows, (cell[1] + 0.5) / size.cols
def rect(cell: tuple[int, int], size: Size) -> tuple[float, float, float, float]:
"""The cell's area as (v0, v1, u0, u1)."""
r, c = cell
return r / size.rows, (r + 1) / size.rows, c / size.cols, (c + 1) / size.cols
def _breaks(ns: set[int]) -> list[Fraction]:
pts = {Fraction(k, n) for n in ns for k in range(n + 1)}
return sorted(pts)
class Ladder:
"""A ladder of grid sizes, and the atoms items may live in."""
def __init__(self, sizes: list[Size]):
self.sizes = sizes
for a, b in zip(sizes, sizes[1:]):
if not (a.rows <= b.rows and a.cols <= b.cols and a.cells < b.cells):
raise ValueError(f"ladder must grow: {a} then {b}")
vb = _breaks({s.rows for s in sizes})
ub = _breaks({s.cols for s in sizes})
self.v_intervals = list(zip(vb, vb[1:]))
self.u_intervals = list(zip(ub, ub[1:]))
# atom = (i, j): v interval i, u interval j; its point is the centre
self.atoms = [(i, j) for i in range(len(self.v_intervals)) for j in range(len(self.u_intervals))]
self.points = [
(float((self.v_intervals[i][0] + self.v_intervals[i][1]) / 2), float((self.u_intervals[j][0] + self.u_intervals[j][1]) / 2))
for i, j in self.atoms
]
self.block = [[place(p, s) for p in self.points] for s in sizes]
n = len(self.atoms)
# last_same[a][b]: the last ladder index at which atoms a and b fall
# in the same cell (-1 if never). Stored per pair in a flat list.
self._last = [-1] * (n * n)
for k in range(len(sizes)):
groups: dict[tuple[int, int], list[int]] = {}
for a, cell in enumerate(self.block[k]):
groups.setdefault(cell, []).append(a)
for members in groups.values():
for x in members:
base = x * n
for y in members:
self._last[base + y] = k
self.n = n
def last_same(self, a: int, b: int) -> int:
return self._last[a * self.n + b]
def capacity_steps(self) -> list[int]:
caps = [s.cells for s in self.sizes]
return [caps[0]] + [b - a for a, b in zip(caps, caps[1:])]
def compatible(self, a: int, sa: int, b: int, sb: int) -> bool:
"""Can atoms a (stage sa) and b (stage sb) both hold an item?"""
return a != b and self._last[a * self.n + b] < max(sa, sb)
def atom_of(self, point: tuple[float, float]) -> int:
v, u = Fraction(point[0]).limit_denominator(10_000), Fraction(point[1]).limit_denominator(10_000)
i = next(i for i, (lo, hi) in enumerate(self.v_intervals) if lo < v < hi)
j = next(j for j, (lo, hi) in enumerate(self.u_intervals) if lo < u < hi)
return i * len(self.u_intervals) + j
def check_stages(sizes: list[Size], items: dict[str, tuple[tuple[float, float], int]], full: bool) -> list[str]:
"""Problems with items {key: (point, stage index)}; empty when valid."""
problems = []
for k, size in enumerate(sizes):
seen: dict[tuple[int, int], str] = {}
for key, (point, stage) in items.items():
if stage <= k:
cell = place(point, size)
if cell in seen:
problems.append(f"{size}: {seen[cell]!r} and {key!r} both fall in cell {cell}")
seen[cell] = key
if full and len(seen) != size.cells:
problems.append(f"{size}: {size.cells - len(seen)} cell(s) empty")
return problems
@lru_cache(maxsize=None)
def ladder_for(names: tuple[str, ...]) -> Ladder:
return Ladder([Size.parse(n) for n in names])
def fmt_point(p: tuple[float, float]) -> list[float]:
return [round(p[0], 5), round(p[1], 5)]