open-standards/aac/aac-rs/tools/pagesets/pslib/boards.py
Transaction Science d03baf0086 fix(aac): on a set that grows, the key shown first keeps a word two keys share — a key hidden for later never leaves a word without its place
The third review found words missing while their keeper was still hidden for
a later stage. Examples: Growing Core 8 × 10 from 3 × 3 had no negation in
es pt pl hi zh, Fixed Paths 84's first words had no "a" in ten languages.

- aac-core samewords::mark: of a group's keys on the board, the one shown
  first keeps the word (ext_aurasense_pageset.shown_from), then the group's
  order. A keeper someone hid still keeps it.
- pagesets build: every Growing Core and Fixed Paths word says the step it is
  shown from (Growing Core: its stage; Fixed Paths: 0 for the first words,
  1 for the rest). The build picks the keeper the same way.
- catalog.py fails if a word is left out for a key not shown with it, as the
  package comes and at every step of a set that grows (pslib/samewords.py).
- Tests: the app's marks are the build's for every package; no gaps at any
  step; each of the review's 318 cases shows its word
  (tests/fixtures/samewords_r3_growing.json).
- German tells a noun from a verb by its capital: food is "Essen" again, and
  the eat/food group is gone. The checks keep case in German (samewords::as_read).
- Keeper re-audit by two readers (gloss_audit_2026-10-14.json):
  - pl and uk "the" has no word.
  - "one" keeps the numeral in ur, fa and am.
  - fr and de tall is big's word, which keeps it.
  - es "it" is "eso", the word that keeps.
- symbol_coverage resolves cells as the app draws them (symbol_for_cell), so
  "I need help" draws help.
- SPEC §4.1 item 5 and §8.1 updated.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-14 01:58:18 -04:00

275 lines
13 KiB
Python

"""Boards of each package, from a checked layout."""
from __future__ import annotations
from dataclasses import dataclass
from .data import Data
from .layout import BACK, CoreLayout, MotorLayout
from .obf import Board, Button, uid
@dataclass
class Package:
file: str # path under data/pagesets
set: str
size: str
variant: str # "full", "reveal-3x3", "filter-starter"
boards: list[Board]
root: str
manifest: dict
def _evidence(data: Data, word: str) -> dict:
out: dict = {}
lists = data.in_aac_lists(word)
if lists:
out["lists"] = lists
rank = data.spoken_rank.get(word.lower())
if rank:
out["spoken_rank"] = rank
app = data.app_vocabulary.get(word.lower())
if app:
out["app_level"] = app[2]
return out
def _word_button(data: Data, key: str, cell, hidden: bool, ext: dict) -> Button:
e = data.entry(key)
ps = {"kind": "word", "pos": e.pos, **ext, **_evidence(data, key)}
if e.sense:
ps["sense"] = e.sense
return Button(key, cell[0], cell[1], "speak", e.role, e.word_class, hidden=hidden, pageset=ps,
sense=e.sense if e.unglossed else None)
# ════════════════════════════════════════════════════════════════════════
# Growing Core
# ════════════════════════════════════════════════════════════════════════
def check_key_symbols(data: Data, name: str, ks: dict[str, str], keys: set[str]) -> None:
"""A key's chosen picture is a concept of the symbol index, and no two
keys share one (DESIGN-motor.md, "One meaning per location")."""
from .obf import lookup_concept
problems = [f"{k}: {c!r} is not a concept of the symbol index" for k, c in ks.items() if not lookup_concept(data, c)]
problems += [f"{k}: not a page or group key" for k in ks if k not in keys]
seen: dict[str, str] = {}
for k, c in ks.items():
if c in seen:
problems.append(f"{k} and {seen[c]} share the picture {c!r}")
seen[c] = k
if problems:
raise SystemExit(f"{name} key_symbols:\n " + "\n ".join(problems))
def check_labels(pkg: "Package") -> None:
"""On a board, no page key has the same words as a word cell: two buttons
that read alike never mean two things on one page. A Back control is
drawn as its arrow, with no words (the app's grid_cell.rs), so the word
"back" may sit beside it."""
problems = []
for b in pkg.boards:
words = {btn.key.lower() for btn in b.buttons if btn.action == "speak"}
for btn in b.buttons:
if btn.action == "navigate" and btn.control != "back" and btn.key.lower() in words:
problems.append(f"{b.name}: the key {btn.key!r} reads like the word on the same board")
if problems:
raise SystemExit(f"{pkg.file}:\n " + "\n ".join(problems))
def core_packages(data: Data, lay: CoreLayout) -> list[Package]:
d = data.growing_core
check_key_symbols(data, "growing-core", d.get("key_symbols", {}), set(d["pages"]))
out = []
for k, size in enumerate(lay.sizes):
out.append(_core_package(data, lay, k, None))
for name in d.get("reveal", []):
size_name, stage_name = name.split(":")
k = [s.name for s in lay.sizes].index(size_name)
r = [s.name for s in lay.sizes].index(stage_name)
out.append(_core_package(data, lay, k, r))
return out
def _core_package(data: Data, lay: CoreLayout, k: int, reveal: int | None) -> Package:
d = data.growing_core
size = lay.sizes[k]
names = [s.name for s in lay.sizes]
variant = "full" if reveal is None else f"reveal-{names[reveal]}"
tag = f"{size.name}" if reveal is None else f"{size.name}-{variant}"
core_id = uid("growing-core", tag, "core")
opener_stage = {p.key: p.stage for p in lay.core if p.kind == "page"}
page_ids = {name: uid("growing-core", tag, "page", name) for name in lay.pages if opener_stage[name] <= k}
key_symbols = d.get("key_symbols", {})
buttons = []
for p in lay.core:
if p.stage > k:
continue
cell = p.at[size.name]
hidden = reveal is not None and p.stage > reveal
# shown_from: the step of the set's growth it is first shown at (aac-core
# samewords: the key shown first keeps a word two keys share).
ext = {"stage": names[p.stage], "shown_from": p.stage, "point": list(p.point)}
if p.kind == "page":
page = lay.pages[p.key]
buttons.append(
Button(page.label, cell[0], cell[1], "navigate", page.role, target=page_ids[p.key],
symbol=key_symbols.get(p.key), hidden=hidden, pageset={"kind": "page", **ext})
)
else:
buttons.append(_word_button(data, p.key, cell, hidden, ext))
boards = [Board(core_id, f"Growing Core {size.name}", size.rows, size.cols, buttons,
{"set": "growing-core", "board": "core", "size": size.name, "variant": variant})]
for name, pid in page_ids.items():
page = lay.pages[name]
start = opener_stage[name]
pb = []
for p in page.items:
g = start + p.stage # its stage on the core's ladder
if g > k:
continue
cell = p.at[size.name]
hidden = reveal is not None and g > reveal and p.kind != "back"
ext = {"stage": names[g], "shown_from": g, "point": list(p.point)}
if p.kind == "back":
pb.append(Button(BACK, cell[0], cell[1], "navigate", "control", target=core_id, control="back",
pageset={"kind": "back"}))
elif p.kind == "modifier":
pb.append(Button(p.key, cell[0], cell[1], "modifier", "little", modifier=page.modifiers[p.key],
hidden=hidden, pageset={"kind": "modifier", **ext}))
else:
pb.append(_word_button(data, p.key, cell, hidden, ext))
boards.append(Board(pid, page.label, size.rows, size.cols, pb,
{"set": "growing-core", "board": "page", "page": name, "size": size.name, "variant": variant}))
manifest = {
"set": "growing-core",
"title": d["title"],
"version": d["version"],
"size": size.name,
"ladder": names,
"variant": variant,
"reveal_stage": names[reveal] if reveal is not None else None,
"licence": "CC-BY-SA-4.0",
"sources": sorted(data.catalog["sources"]),
}
file = f"growing-core/growing-core-{tag}.obz"
pkg = Package(file, "growing-core", size.name, variant, boards, core_id, manifest)
check_labels(pkg)
return pkg
# ════════════════════════════════════════════════════════════════════════
# Fixed Paths
# ════════════════════════════════════════════════════════════════════════
def motor_filter(data: Data, name: str) -> set[str]:
spec = data.fixed_paths["filters"][name]
words: set[str] = set()
for src in spec["from"]:
lst = data.universal_core if src == "universal_core" else data.banajee
for w in lst:
words.add("all done" if w.lower() in ("finished", "all done") else w)
lex = {k.lower(): k for k in data.lexicon}
return {lex.get(w.lower(), w) for w in words}
def motor_packages(data: Data, lay: MotorLayout) -> list[Package]:
d = data.fixed_paths
keys = set(d["pages"]) | {g for spec in d["pages"].values() for g in spec.get("groups", {})}
check_key_symbols(data, "fixed-paths", d.get("key_symbols", {}), keys)
out = []
for ms in lay.sizes:
out.append(_motor_package(data, lay, ms, None))
for f in d.get("filters", {}):
out.append(_motor_package(data, lay, ms, f))
return out
def _motor_package(data: Data, lay: MotorLayout, ms, filt: str | None) -> Package:
d = data.fixed_paths
variant = "full" if filt is None else f"filter-{filt}"
tag = ms.label if filt is None else f"{ms.label}-{filt}"
show = None if filt is None else motor_filter(data, filt)
# The set grows from its first words (the starter filter) to the rest:
# shown_from 0 and 1, in every variant, so that a word two keys share
# stays where it was shown first (aac-core samewords).
first = motor_filter(data, "starter") if "starter" in d.get("filters", {}) else None
def step(key: str) -> dict:
return {} if first is None else {"shown_from": 0 if key in first else 1}
home_id = uid("fixed-paths", tag, "home")
page_ids = {p: uid("fixed-paths", tag, "page", p) for p in ms.pages}
group_ids = {g: uid("fixed-paths", tag, "group", g) for g in ms.groups}
ks = d.get("key_symbols", {})
size = ms.size
def behind_page(p):
spec = ms.pages[p]
return list(spec["words"]) + [w for g in spec["groups"] for w in ms.groups[g]["words"]]
def visible(words) -> bool:
return show is None or any(w in show for w in words)
mods = {m["label"]: m["modifier"] for m in d["pages"]["Word forms"].get("modifiers", [])}
home_buttons = []
for key, cell in ms.home.items():
if key in ms.pages:
home_buttons.append(Button(key, cell[0], cell[1], "navigate", d["pages"][key]["role"], target=page_ids[key],
symbol=ks.get(key), hidden=not visible(behind_page(key)),
pageset={"kind": "page", "path": [key]}))
else:
home_buttons.append(_word_button(data, key, cell, show is not None and key not in show,
{"path": [key], "hits": 1, **step(key)}))
boards = [Board(home_id, f"Fixed Paths {ms.label}", size.rows, size.cols, home_buttons,
{"set": "fixed-paths", "board": "home", "size": ms.label, "grid": size.name, "variant": variant})]
for page, spec in ms.pages.items():
pb = [Button(BACK, spec["close"][0], spec["close"][1], "navigate", "control", target=home_id, control="back",
pageset={"kind": "close"})]
for w, cell in spec["words"].items():
if w in mods:
pb.append(Button(w, cell[0], cell[1], "modifier", "little", modifier=mods[w],
hidden=show is not None,
pageset={"kind": "modifier", "path": [page, w], "hits": 2,
**({} if first is None else {"shown_from": 1})}))
else:
pb.append(_word_button(data, w, cell, show is not None and w not in show,
{"path": [page, w], "hits": 2, **step(w)}))
for g, cell in spec["groups"].items():
pb.append(Button(g, cell[0], cell[1], "navigate", d["pages"][page]["role"], target=group_ids[g],
symbol=ks.get(g), hidden=not visible(ms.groups[g]["words"]),
pageset={"kind": "group", "path": [page, g]}))
boards.append(Board(page_ids[page], page, size.rows, size.cols, pb,
{"set": "fixed-paths", "board": "page", "page": page, "size": ms.label, "variant": variant}))
for g, spec in ms.groups.items():
gb = [Button(BACK, spec["close"][0], spec["close"][1], "navigate", "control", target=page_ids[spec["page"]],
control="back", pageset={"kind": "close"})]
for w, cell in spec["words"].items():
gb.append(_word_button(data, w, cell, show is not None and w not in show,
{"path": [spec["page"], g, w], "hits": 3, **step(w)}))
boards.append(Board(group_ids[g], g, size.rows, size.cols, gb,
{"set": "fixed-paths", "board": "group", "page": spec["page"], "group": g,
"size": ms.label, "variant": variant}))
manifest = {
"set": "fixed-paths",
"title": d["title"],
"version": d["version"],
"size": ms.label,
"grid": size.name,
"variant": variant,
"filter": filt,
"filter_words": sorted(show) if show is not None else None,
"recommends": {
"return_home_after_word": True,
"why": "Every path starts at Home. SPEC §5.6 item 6 leaves this to the person: ask, never turn it on.",
},
"licence": "CC-BY-SA-4.0",
"sources": sorted(data.catalog["sources"]),
}
file = f"fixed-paths/fixed-paths-{tag}.obz"
pkg = Package(file, "fixed-paths", ms.label, variant, boards, home_id, manifest)
check_labels(pkg)
return pkg