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

215 lines
9.1 KiB
Python

"""No board shows the same words on two of its cells, in any language.
A person who sees "encore" twice on one board cannot tell which says "more"
and which says "again"; a partner modelling on it cannot either. So every
board of every package, in every language the package is shown in, is
checked: the words each cell shows there (as the app shows them: a gloss of
its English key, in the sense the cell names; or its words as written) must
differ from every other cell's on that board. Navigation controls (Back,
Home) are the app's controls, drawn and named as such, not words; a cell
with no word in a language, hidden in its place there, shows nothing; nor
does a cell a language says with the same word as another cell of the board
(`ext_aurasense_same_word`: the word is shown once, at the other's place); a
slot a partner fills in (`ext_aurasense_fill_in`: "I enjoy …", each with its
own photo and words) is told apart by what is filled in.
python3 tools/pagesets/pslib/samewords.py DIR [--json OUT]
checks every package (*.obz) under DIR (catalog.py --out DIR builds them),
prints each clash, and exits 1 if there is one. catalog.py runs the same
check on every build and fails on a clash: it is never a warning.
"""
from __future__ import annotations
import json
import sys
import unicodedata
import zipfile
from pathlib import Path
CONTROLS = ("back", "home")
# Languages whose readers tell two words apart by a capital alone: German
# writes every noun with one ("Essen", food; "essen", eat).
CASED = ("de",)
def normal(text: str, lang: str | None = None) -> str:
"""Words as a person reads them: width and spacing aside, and case too,
except where the language tells words apart by it (`CASED`)."""
text = unicodedata.normalize("NFKC", text)
if (lang or "").split("-")[0] not in CASED:
text = text.casefold()
return " ".join(text.split())
def shown(btn: dict, lang: str, package_lang: str) -> str | None:
"""What a button shows in `lang`, as the app shows it; None where it
shows no words there (a control, an empty cell, a word with none in the
language on a set made to be glossed)."""
prov = btn.get("ext_aurasense_provenance") or {}
if btn.get("load_board") and normal(prov.get("source_label", btn.get("label", ""))) in CONTROLS:
return None
label = (btn.get("label") or "").strip()
if not label or "ext_aurasense_fill_in" in btn:
return None
same = btn.get("ext_aurasense_same_word") or {}
if lang in same or "*" in same:
return None
if package_lang != "*":
# A package in one language: its words as written.
return label if lang == package_lang.split("-")[0] or lang == package_lang else None
tr = (btn.get("translations") or {}).get(lang)
if tr and tr.get("label"):
return tr["label"]
if lang == "en":
return label
# No word in this language: hidden in place on a glossed set, else its
# English words (marked English).
return None if "ext_aurasense_pageset" in btn else label
def board_clashes(board: dict, langs: list[str], package_lang: str) -> list[dict]:
out = []
for lang in langs:
seen: dict[str, list[dict]] = {}
for btn in board.get("buttons", []):
words = shown(btn, lang, package_lang)
if words is None:
continue
key = (btn.get("ext_aurasense_provenance") or {}).get("source_label", btn.get("label"))
sense = btn.get("ext_aurasense_gloss_sense")
seen.setdefault(normal(words, lang), []).append({"key": key, "sense": sense, "words": words})
for words, cells in seen.items():
if len(cells) > 1:
out.append({"lang": lang, "words": words, "cells": cells})
return out
def key_of(btn: dict) -> str:
"""The key a button is glossed by: its English word, and the sense it is
glossed in where it names one ("cold (water)")."""
prov = btn.get("ext_aurasense_provenance") or {}
key = prov.get("source_label", btn.get("label", ""))
sense = btn.get("ext_aurasense_gloss_sense")
return f"{key} ({sense})" if sense else key
def shown_from(btn: dict) -> int:
"""The step of a set that grows at which the button is first shown (0:
from the start; aac-core samewords `shown_from`)."""
return int((btn.get("ext_aurasense_pageset") or {}).get("shown_from", 0))
def stage_gaps(board: dict) -> list[dict]:
"""On a board of a set that grows, every word shown has its words shown
somewhere, at every step: a word a language says as another key's word
is left out only where that key is shown too — never for a key that is
hidden for later (the key shown first keeps the word). Checked as the
package comes (its hidden words hidden) and at each step of its growth."""
buttons = board.get("buttons", [])
steps = sorted({shown_from(b) for b in buttons if "shown_from" in (b.get("ext_aurasense_pageset") or {})})
views = [("as it comes", [b for b in buttons if not b.get("hidden")])]
views += [(f"step {t}", [b for b in buttons if shown_from(b) <= t]) for t in steps]
out = []
for view, shown in views:
keys = {key_of(b) for b in shown}
labels = {normal(b.get("label") or "") for b in shown}
for b in shown:
for lang, keeper in sorted((b.get("ext_aurasense_same_word") or {}).items()):
there = normal(keeper) in labels if lang == "*" else keeper in keys
if not there:
out.append({"view": view, "lang": lang, "key": key_of(b), "keeper": keeper})
return out
# The families whose sets grow: each of their words says the step it is
# shown from (or the steps could not be checked).
GROWS = ("growing-core", "fixed-paths")
def package_gaps(data: bytes, grows: bool = False) -> list[dict]:
z = zipfile.ZipFile(__import__("io").BytesIO(data))
out = []
for name in sorted(z.namelist()):
if name.endswith(".obf"):
board = json.loads(z.read(name))
out += [{"board": board.get("name", name), **g} for g in stage_gaps(board)]
if grows:
for b in board.get("buttons", []):
ps = b.get("ext_aurasense_pageset") or {}
if ps.get("kind") in ("word", "modifier") and "shown_from" not in ps:
out.append({"board": board.get("name", name), "view": "its steps", "lang": "*",
"key": key_of(b), "keeper": "(no shown_from: its step is not known)"})
return out
def gaps(index: dict, files: dict[str, Path]) -> list[dict]:
"""Every word of every package left out for a key that is not shown with
it (catalog.py fails on any)."""
out = []
for fam in index["families"]:
grows = fam["key"] in GROWS
for pkg in fam["packages"]:
for g in package_gaps(files[pkg["file"]].read_bytes(), grows):
out.append({"family": fam["key"], "package": pkg["key"], **g})
return out
def describe_gap(gap: dict) -> str:
return (f"{gap['family']}/{gap['package']} · {gap['board']} · {gap['view']} · {gap['lang']}: "
f"“{gap['key']}” is left out for “{gap['keeper']}”, which is not shown")
def package_clashes(data: bytes, langs: list[str], package_lang: str) -> list[dict]:
z = zipfile.ZipFile(__import__("io").BytesIO(data))
out = []
for name in sorted(z.namelist()):
if not name.endswith(".obf"):
continue
board = json.loads(z.read(name))
for c in board_clashes(board, langs, package_lang):
out.append({"board": board.get("name", name), **c})
return out
def check(index: dict, files: dict[str, Path], langs: list[str]) -> list[dict]:
"""Every clash of every package of a catalogue (catalog.py)."""
out = []
for fam in index["families"]:
for pkg in fam["packages"]:
data = files[pkg["file"]].read_bytes()
for c in package_clashes(data, langs, pkg.get("lang", "*")):
out.append({"family": fam["key"], "package": pkg["key"], **c})
return out
def describe(clash: dict) -> str:
keys = " / ".join(c["key"] + (f" ({c['sense']})" if c.get("sense") else "") for c in clash["cells"])
return f"{clash['family']}/{clash['package']} · {clash['board']} · {clash['lang']}: “{clash['words']}” on {keys}"
def main() -> None:
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from pslib.data import Data # noqa: E402
root = Path(sys.argv[1])
langs = ["en"] + [l["code"] for l in Data().languages if l["code"] != "en"]
index = json.loads((root / "index.json").read_text(encoding="utf-8"))
files = {p.name: p for p in root.glob("*.obz")}
found = check(index, files, langs)
for c in found:
print(describe(c))
left = gaps(index, files)
for g in left:
print(describe_gap(g))
print(f"{len(left)} words left out for a key not shown with them")
if "--json" in sys.argv:
Path(sys.argv[sys.argv.index("--json") + 1]).write_text(json.dumps(found, ensure_ascii=False, indent=1), encoding="utf-8")
print(f"{len(found)} clashes")
sys.exit(1 if found or left else 0)
if __name__ == "__main__":
main()