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]>
215 lines
9.1 KiB
Python
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()
|