open-standards/aac/aac-rs/tools/pagesets/build.py
Transaction Science 38707b2495 Merge aac/integ-1015 into aac/ui-i18n: visual scenes, word finding, optional symbol sets and the rest, with the app's words through the catalogue
Conflicts, settled with the features' behaviour and the catalogue's lookups:

- app.rs: the default title is "AAC — reference app" (the app's name, set by
  the host; not UI).
- edit_bar.rs: the title stays a message; the hint is the features' (it
  follows a scene's marking).
- grid_cell.rs: the cell's name holds the words it shows (WCAG 2.5.3) and
  what it says when that holds them; "Edit: …", the notes in brackets, "Say
  the next line", "Undo: bring the message back" and the action names stay
  messages (grid-cell-*, output-strip-undo-clear-name).
- lock.rs: "Lock again after" is a group of radio choices; its legend and
  choices are the lock-relock-* messages.
- navigation_bar.rs: the bar's name and the partner button's name stay
  messages; the recording dot and the new button size are the features'.
- access_settings.rs: the new "start each pass at the place to press" toggle
  is a message (access-settings-scan-from-way).
- catalogue_sets.rs: a size's description, or what it holds when started with
  its first words, through the page-set catalogue's messages.
- cell_editor.rs: hotspot headings and the hotspot's hide label are new
  messages; Undo's descriptions stay messages, with the features' words shown
  and same-word hides.
- import_export.rs: the record of choices in a backup, and in what a backup
  brings back, are new messages.
- listen_settings.rs, settings.rs: the features' new English, in the existing
  messages (settings-scan-bar-note keeps "Delete last word", the button's
  name).
- profiles.rs: the Active tag and the new Delete button are messages.
- symbol_settings.rs, print_view.rs: the optional sets' licences ("ARASAAC's
  licence: …; our adapted versions are shared under …") and their changes
  are messages; licence names come from aac-core (display_name). The changes'
  English is the symbol catalogue's own (a test checks they agree).
- voice_banking.rs, voice_offer.rs: the English fixes were already in the
  messages; unchanged.
- editing.rs: the undo problem names the words shown.
- visual_scene.rs: deleted (scene_board.rs replaces it); its message
  visual-scene-name goes, from English and from es, fr, de and pt.

Merge fixes: the Find a word panel's title is a message (panels-find-word-
title); print_view.rs's file error is file_failed().

The feature tracks' other English is put through the catalogue in the
commits that follow.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-15 21:04:16 -04:00

459 lines
23 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.

#!/usr/bin/env python3
"""Build the open page sets: Growing Core and Fixed Paths.
python3 tools/pagesets/build.py # update the design documents' tables
python3 tools/pagesets/build.py --check # fail if they are out of date
python3 tools/pagesets/build.py --out DIR # write the packages into DIR
The packages are not committed: they are built where they are needed (the
site's build, through tools/pagesets/catalog.py, and aac-core's tests), from
these sources, byte for byte the same every time. What is committed is the
catalogue (data/pagesets/index.json, written by catalog.py), which names each
package with its size and SHA-256, so a build that does not reproduce them
fails.
Reads tools/pagesets/data (the design and its committed layouts), sources/
(the cited word lists) and the app's own data (glosses, symbols, word
classes); checks every rule of DESIGN-core.md and DESIGN-motor.md; with --out:
growing-core/*.obz one package per size, and reveal examples
fixed-paths/*.obz one package per size, and filter examples
families.json both families, for the catalogue (catalog.py)
cells.json every cell of every board (for aac-core's tests)
metrics.json the metrics of both designs
languages.json what each language gets, and the native work it needs
words.json every word, with its evidence
and always the metric tables inside DESIGN-core.md and DESIGN-motor.md.
Reproducible: fixed ids (UUID v5), a fixed date, sorted keys. Needs Python
3.11+ and PyYAML; nothing else, no network.
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from pslib import costs, langs, metrics, obf # noqa: E402
from pslib.boards import core_packages, motor_filter, motor_packages # noqa: E402
from pslib.data import HERE, WORD_CLASS, Data # noqa: E402
from pslib.layout import load_core, load_motor # noqa: E402
def class_exceptions(data: Data, words: set[str]) -> dict:
out = {}
for w in sorted(words):
e = data.lexicon[w]
ours = e.word_class
app = data.app_vocabulary.get(w.lower())
if app and ours and app[0] != ours:
out[w] = {"ours": ours, "app": app[0], "why": e.sense or f"used as a {e.pos}"}
return out
def index_json(pkgs, exceptions) -> dict:
out = {"version": 1, "packages": [], "class_exceptions": exceptions}
for p in pkgs:
boards = []
for b in p.boards:
cells = []
for btn in sorted(b.buttons, key=lambda x: (x.row, x.col)):
extra = btn.target if btn.action == "navigate" else btn.modifier if btn.action == "modifier" else None
cells.append([btn.key, btn.row, btn.col, btn.action, extra, btn.hidden, btn.control])
boards.append({"id": b.id, "name": b.name, "rows": b.rows, "cols": b.cols, "board": b.pageset.get("board"), "cells": cells})
out["packages"].append({"file": p.file, "set": p.set, "size": p.size, "variant": p.variant, "root": p.root, "boards": boards})
return out
def words_json(data: Data, pkgs) -> dict:
out = {}
for set_name in ("growing-core", "fixed-paths"):
words = sorted({btn.key for p in pkgs if p.set == set_name and p.variant == "full" for b in p.boards for btn in b.buttons if btn.action == "speak"}, key=str.lower)
rows = {}
for w in words:
e = data.lexicon[w]
sym = obf.lookup_for_cell(data, w, "speak")
rows[w] = {
"pos": e.pos,
"word_class": WORD_CLASS[e.pos],
"app_class": (data.app_vocabulary.get(w.lower()) or [None])[0],
"role": e.role,
"sense": e.sense,
"lists": data.in_aac_lists(w),
"spoken_rank": data.spoken_rank.get(w.lower()),
"symbol": f"{sym[0]}:{sym[1]}" if sym else None,
"glosses": len(data.glosses(w)),
}
out[set_name] = rows
return out
# ── tables for the design documents ──────────────────────────────────────
def md_table(head: list[str], rows: list[list]) -> str:
out = ["| " + " | ".join(head) + " |", "|" + "|".join("---" for _ in head) + "|"]
for r in rows:
out.append("| " + " | ".join(str(x) for x in r) + " |")
return "\n".join(out)
def mm(t: dict, key: str) -> str:
x = t[key]
return f"{x['width_mm']:.0f}×{x['height_mm']:.0f}"
def core_tables(m: dict, lay, data: Data) -> dict[str, str]:
t = {}
rows = []
for size, s in m["sizes"].items():
c = s["coverage"]
rows.append([
size, s["cells"], s["core_words"], s["pages"], s["words_reachable"],
f"{c['top25']['direct']}/25", f"{c['top50']['direct']}/50", f"{c['top100']['direct']}/100",
f"{c['top200']['direct']}/200", f"{c['token_share_direct']:.0%}",
f"{s['lists']['universal_core']['one_hit']}/{s['lists']['universal_core']['of']}",
f"{s['lists']['banajee_2003']['one_hit']}/{s['lists']['banajee_2003']['of']}",
])
t["core-reach"] = md_table(
["Size", "Cells", "Core words (1 hit)", "Pages", "Words in reach", "Top 25 spoken", "Top 50", "Top 100", "Top 200",
"Spoken tokens covered", "Universal Core on the core", "Banajee on the core"], rows)
rows = []
for size, s in m["sizes"].items():
w = s["weighted"]
rows.append([size, w.get("hits", "–"), w.get("scan", "–"), w.get("touch", "–"), w.get("gaze", "–")])
t["core-costs"] = md_table(["Size", "Hits per word", "Row-column scan steps", "Touch (Fitts ID, bits)", "Gaze (eccentricity)"], rows)
rows = []
for size, s in m["sizes"].items():
tg = s["targets"]
rows.append([size, mm(tg, "tablet-landscape"), mm(tg, "tablet-portrait"), mm(tg, "phone-portrait"), mm(tg, "phone-landscape"),
", ".join(tg["tablet-landscape"]["meets"]) or "none", ", ".join(tg["phone-portrait"]["meets"]) or "none"])
t["core-targets"] = md_table(["Size", "10.2\" tablet, landscape (mm)", "tablet, portrait", "6.1\" phone, portrait", "phone, landscape",
"Meets on the tablet (landscape)", "Meets on the phone (portrait)"], rows)
st = m["stability_core"]
w = st["worst"]
t["core-stability"] = "\n".join([
f"- Pairs of sizes compared (an item present at both): **{st['pairs']}**.",
f"- Mean shift of a button's centre: **{st['mean_shift']:.3f}** of the board's width or height.",
f"- Largest shift: **{w['cells']:.2f} cells of the smaller board** (`{w['item']}`, {w['from']} → {w['to']}: "
f"{w['du']:.3f} across, {w['dv']:.3f} down). Threshold: less than one cell of the smaller board — "
f"**{'met' if st['within_one_cell_of_smaller'] else 'NOT met'}**.",
f"- Largest shift between neighbouring sizes of the ladder: **{st['worst_shift_one_step']:.3f}** of the board.",
f"- Every item's buttons, at every size, share one point of the screen: **{'yes' if st['every_item_keeps_a_common_point'] else 'NO'}**.",
"- Pages: " + "; ".join(f"{n} {'ok' if s['within_one_cell_of_smaller'] and s['every_item_keeps_a_common_point'] else 'FAILS'}"
for n, s in m["stability_pages"].items()) + ".",
])
rows = []
names = [s.name for s in lay.sizes]
for k, size in enumerate(names):
words = []
for p in lay.core:
if p.stage != k:
continue
if p.kind == "page":
words.append(f"**{p.key}** ▸")
continue
ev = []
lists = data.in_aac_lists(p.key)
if "universal_core" in lists:
ev.append("U")
if "banajee_2003" in lists:
ev.append("B")
rank = data.spoken_rank.get(p.key.lower())
ev.append(f"#{rank}" if rank else "#–")
words.append(f"{p.key} ({', '.join(ev)})")
rows.append([size, len(words), "; ".join(words)])
t["core-stages"] = md_table(["Stage", "New", "Words (U: Universal Core, B: Banajee et al. 2003, #: spoken rank)"], rows)
rows = []
for name, page in lay.pages.items():
words = [p.key for p in page.items if p.kind in ("word", "modifier")]
rows.append([name, page.sizes[0].name, len(words), ", ".join(words[:12]) + (", …" if len(words) > 12 else "")])
t["core-pages"] = md_table(["Page", "Opens from", "Words", "First words (they appear at the page's first size)"], rows)
grids = []
for s in lay.sizes:
from pslib.layout import core_grid
grids.append(f"**{s.name}**\n\n```\n{core_grid(lay, s)}\n```")
t["core-grids"] = "\n\n".join(grids)
return t
def motor_tables(m: dict, lay, data: Data) -> dict[str, str]:
t = {}
rows = []
for size, s in m["sizes"].items():
h = s["hits"]
c = s["coverage"]
rows.append([size, s["grid"], s["home_words"], s["pages"], s["groups"], s["words"], h.get("1", 0), h.get("2", 0), h.get("3", 0),
s["mean_hits_unweighted"], s["weighted_spoken"]["hits"], s["weighted_design"]["hits"],
f"{c['top25']['one_hit']}/25", f"{c['top100']['one_hit']}/100", f"{c['top100']['direct']}/100",
f"{c['top200']['direct']}/200", f"{c['token_share_direct']:.0%}"])
t["motor-reach"] = md_table(
["Locations", "Grid", "Words on Home", "Pages", "Groups", "Words", "1 hit", "2 hits", "3 hits", "Mean hits",
"Hits per word, spoken weight", "Hits, design weight", "Top 25 in 1 hit", "Top 100 in 1 hit", "Top 100 in reach",
"Top 200 in reach", "Spoken tokens covered"], rows)
rows = []
for size, s in m["sizes"].items():
w = s["weighted_spoken"]
rows.append([size, w["hits"], w["scan"], w["touch"], w["gaze"]])
t["motor-costs"] = md_table(["Locations", "Hits per word", "Row-column scan steps", "Touch (Fitts ID, bits)", "Gaze (eccentricity)"], rows)
rows = []
for size, s in m["sizes"].items():
for name, v in s["tradeoffs"].items():
rows.append([size, name.replace("_", " "), v["hits"], v["scan"], v["touch"], v["gaze"]])
t["motor-tradeoffs"] = md_table(["Locations", "Layout", "Hits", "Scan steps", "Touch (bits)", "Gaze"], rows)
rows = []
for size, s in m["sizes"].items():
tg = s["targets"]
rows.append([size, s["grid"], mm(tg, "tablet-landscape"), mm(tg, "tablet-portrait"), mm(tg, "phone-portrait"), mm(tg, "phone-landscape"),
", ".join(tg["tablet-landscape"]["meets"]) or "none", ", ".join(tg["phone-portrait"]["meets"]) or "none"])
t["motor-targets"] = md_table(["Locations", "Grid", "10.2\" tablet, landscape (mm)", "tablet, portrait", "6.1\" phone, portrait",
"phone, landscape", "Meets on the tablet (landscape)", "Meets on the phone (portrait)"], rows)
st = m["stability_home"]
w = st["worst"]
t["motor-stability"] = (
f"Home items present at two sizes: {st['pairs']} pairs; mean shift {st['mean_shift']:.3f} of the board; "
f"largest {w['cells']:.2f} cells of the smaller board (`{w['item']}`, {w['from']} → {w['to']}); "
f"every item's buttons share a point: {'yes' if st['every_item_keeps_a_common_point'] else 'NO'}."
)
rows = []
d = data.fixed_paths
for page, spec in d["pages"].items():
groups = spec.get("groups", {})
used = {ms.label: sorted(g for g in ms.pages[page]["groups"]) for ms in lay.sizes}
n = len(spec.get("words", [])) + sum(len(v) for v in groups.values()) + len(spec.get("modifiers", []))
rows.append([page, n, ", ".join(groups) or "–", "; ".join(f"{k}: {', '.join(v) or 'none'}" for k, v in used.items())])
t["motor-pages"] = md_table(["Page", "Words", "Groups (by meaning)", "Groups in use, by size"], rows)
grids = []
for ms in lay.sizes:
from pslib.layout import _grid_text
cells = {cell: key + ("▸" if key in ms.pages else "") for key, cell in ms.home.items()}
grids.append(f"**{ms.label} ({ms.size.name})**\n\n```\n{_grid_text(ms.size, cells, 11)}\n```")
t["motor-grids"] = "\n\n".join(grids)
return t
def lang_table(rep: dict) -> str:
rows = []
for l in rep["languages"]:
if l["code"] == "en":
rows.append(["en", "English", "ValidatedNativeCorpus", l["prediction_pack"], "source", "source", "–"])
continue
gc, fp = l["sets"]["growing-core"], l["sets"]["fixed-paths"]
rows.append([l["code"], l["name"], l["core_status"], l["prediction_pack"] or "none",
f"{gc['glossed_share']:.0%} ({gc['glossed_share_by_spoken_frequency']:.0%} by use)",
f"{fp['glossed_share']:.0%} ({fp['glossed_share_by_spoken_frequency']:.0%} by use)",
"yes" if any("published core" in n for n in l["needs"]) else ""])
return md_table(["Code", "Language", "Core status", "Prediction pack", "Growing Core words glossed", "Fixed Paths words glossed",
"Published native core list exists"], rows)
def fill(doc: Path, tables: dict[str, str]) -> str:
text = doc.read_text(encoding="utf-8")
for key, body in tables.items():
pat = re.compile(rf"(<!-- table:{re.escape(key)} -->\n).*?(<!-- /table -->)", re.S)
if not pat.search(text):
raise SystemExit(f"{doc.name}: no <!-- table:{key} --> block")
text = pat.sub(lambda mt: mt.group(1) + body + "\n" + mt.group(2), text)
return text
FAMILY_TEXT = {
"growing-core": {
"name": "Growing Core",
"summary": "Core words that grow from 1 to 80 on a board, and no word ever moves.",
"description": (
"Our own core vocabulary of the words people say most, in eleven sizes from one button to 80. "
"A word learned on a small board is in the same place on every bigger one, so reaching for it "
"carries over. Pages of more words open from the right edge, each with Back in its top-left corner. "
"Words are coloured by the Fitzgerald Key. You can also start on a bigger board with only the "
"first words shown, and show the rest later, each in its own place."
),
"chooser": "growing-core",
},
"fixed-paths": {
"name": "Fixed Paths",
"summary": "Every word in one place, reached the same way every time, in 1 to 3 touches.",
"description": (
"A motor-plan vocabulary: every word is one, two or three touches from Home, always the same ones, "
"so the movements become automatic. Made to go back to Home after each word. 30 places (the "
"largest targets, for eye gaze and head pointers), 60 or 84. You can start with 45 first words "
"shown and the rest hidden in their places."
),
"chooser": "fixed-paths",
},
}
SOURCES_SHORT = [
{"name": "Universal Core vocabulary (UNC CLDS / Project Core)", "licence": "CC-BY-SA-4.0"},
{"name": "Banajee, DiCarlo & Buras Stricklin (2003), core vocabulary for toddlers", "licence": "cited"},
{"name": "FrequencyWords 2018 (OpenSubtitles), Hermit Dave", "licence": "CC-BY-SA-4.0"},
{"name": "Mulberry Symbols, OpenMoji, Blissymbolics", "licence": "CC-BY-SA-4.0"},
]
def families_json(data: Data, pkgs, rep: dict) -> dict:
"""This generator's families, for tools/pagesets/catalog.py."""
langs_by_set: dict[str, dict] = {}
for set_name in ("growing-core", "fixed-paths"):
out = {}
for lang in rep["languages"]:
if lang["code"] == "en":
out["en"] = {"status": "ValidatedNativeCorpus"}
else:
st = lang["sets"][set_name]
out[lang["code"]] = {"status": lang["core_status"], "glossed": st["glossed_share"]}
langs_by_set[set_name] = out
fams = []
for set_name in ("growing-core", "fixed-paths"):
d = data.growing_core if set_name == "growing-core" else data.fixed_paths
packages = []
for p in (q for q in pkgs if q.set == set_name):
root = next(b for b in p.boards if b.id == p.root)
home_words = sum(1 for b in root.buttons if b.action == "speak" and not b.hidden)
pages = sum(1 for b in root.buttons if b.action == "navigate" and not b.control)
words = len({btn.key for b in p.boards for btn in b.buttons if btn.action == "speak" and not btn.hidden})
entry = {
"key": p.file.rsplit("/", 1)[-1].removesuffix(".obz"),
"file": p.file,
"lang": "*",
"variant": p.variant,
"size": p.size,
"rows": root.rows,
"cols": root.cols,
"boards": len(p.boards),
"home": p.root,
"words": words,
"home_words": home_words,
"pages": pages,
}
if set_name == "growing-core":
entry["label"] = p.size.replace("x", " \u00d7 ")
entry["description"] = f"{home_words} word{'' if home_words == 1 else 's'}" + (
f" and {pages} pages of more" if pages else ""
)
if p.variant != "full":
entry["reveal_stage"] = p.manifest["reveal_stage"]
else:
entry["label"] = f"{p.size} places"
if p.manifest.get("filter"):
# Started with fewer words: what each number counts (the
# words shown first, those of them on Home, all the set's
# words as it grows), never "40 on Home … 45 in all".
every = len({btn.key for b in p.boards for btn in b.buttons if btn.action == "speak"})
entry["description"] = (
f"{words} words to start with ({home_words} on Home), {every} as it grows, {pages} pages"
)
entry["filter"] = p.manifest["filter"]
else:
entry["description"] = f"{home_words} words on Home, {pages} pages, {words} words in all"
packages.append(entry)
fam = {
"key": set_name,
**FAMILY_TEXT[set_name],
"version": d["version"],
"licence": "CC-BY-SA-4.0",
"attribution": data.catalog["attribution"].strip(),
"sources": SOURCES_SHORT,
"languages": langs_by_set[set_name],
"language_note": (
"The words are English, shown in your language through the app's translations, which are not "
"your language's own core words. Words without a translation in your language are hidden in "
"their places; a partner can add their own words there in Edit mode."
),
"packages": packages,
}
if set_name == "growing-core":
fam["ladder"] = d["ladder"]
else:
fam["recommends"] = {"return_home_after_word": True, "make_home": True}
# Its first words (the starter filter): a word kept by an earlier
# version, which does not say the step it is shown from, is placed
# by them (aac-core samewords::shown_from).
if "starter" in d.get("filters", {}):
fam["first_words"] = sorted(motor_filter(data, "starter"))
fams.append(fam)
return {"families": fams}
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--check", action="store_true", help="fail if the design documents' tables are out of date")
ap.add_argument("--out", type=Path, help="write the packages, families.json (for catalog.py), cells.json (for "
"aac-core's tests) and the reports into this directory")
args = ap.parse_args()
data = Data()
core = load_core(data)
motor = load_motor(data)
pkgs = core_packages(data, core) + motor_packages(data, motor)
all_words = {btn.key for p in pkgs for b in p.boards for btn in b.buttons if btn.action == "speak"}
exceptions = class_exceptions(data, all_words)
cm = metrics.core_metrics(data, core, [p for p in pkgs if p.set == "growing-core"])
mm_ = metrics.motor_metrics(data, motor, [p for p in pkgs if p.set == "fixed-paths"])
mx = {
"about": "Generated by tools/pagesets/build.py. Models and reference lists: DESIGN-core.md and DESIGN-motor.md.",
"screens": {k: vars(v) for k, v in costs.SCREENS.items()},
"growing_core": cm,
"fixed_paths": mm_,
"endings_the_app_gets_wrong": metrics.endings_report(data, all_words),
"word_class_differences": exceptions,
}
rep = langs.report(data, pkgs)
def js(v) -> bytes:
return (json.dumps(v, ensure_ascii=False, sort_keys=True, indent=1) + "\n").encode()
# The design documents' tables: committed, and checked.
docs: dict[Path, bytes] = {}
ct = core_tables(cm, core, data)
ct["languages"] = lang_table(rep)
ct["endings"] = md_table(["Word", "Ending", "The app says", "Correct"],
[[e["word"], e["ending"], e["app_says"], e["correct"]] for e in mx["endings_the_app_gets_wrong"]])
ct["class-differences"] = md_table(["Word", "These sets", "The app's vocabulary.rs", "Why"],
[[w, v["ours"], v["app"], v["why"]] for w, v in exceptions.items()]) if exceptions else "None."
no_sym = sorted({btn.key for p in pkgs if p.variant == "full" for b in p.boards for btn in b.buttons
if not btn.control and not btn.symbol and not obf.lookup_for_cell(data, btn.key, btn.action)}, key=str.lower)
ct["no-symbol"] = ", ".join(f"`{w}`" for w in no_sym) + f" ({len(no_sym)})"
for doc, tables in ((HERE / "DESIGN-core.md", ct), (HERE / "DESIGN-motor.md", motor_tables(mm_, motor, data))):
if doc.exists():
docs[doc] = fill(doc, tables).encode()
if args.out:
out: dict[Path, bytes] = {}
for p in pkgs:
out[args.out / p.file] = obf.obz(data, p.boards, p.root, p.manifest)
out[args.out / "families.json"] = js(families_json(data, pkgs, rep))
out[args.out / "cells.json"] = js(index_json(pkgs, exceptions))
out[args.out / "metrics.json"] = js(mx)
out[args.out / "languages.json"] = js(rep)
out[args.out / "words.json"] = js(words_json(data, pkgs))
for path, content in out.items():
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
total = sum(len(c) for p, c in out.items() if p.suffix == ".obz")
print(f"wrote {len(out)} files to {args.out} ({len(pkgs)} packages, {total / 1e6:.1f} MB)")
stale = []
for path, content in docs.items():
if args.check:
if path.read_bytes() != content:
stale.append(str(path.relative_to(HERE.parent.parent)))
else:
path.write_bytes(content)
if args.check:
if stale:
print("out of date (run tools/pagesets/build.py):\n " + "\n ".join(stale))
sys.exit(1)
print("the design documents' tables are up to date")
if __name__ == "__main__":
main()