"""Metrics that show whether the designs work (DESIGN-core.md, DESIGN-motor.md). Paths are measured on the packages actually written, so a metric is always about the files that ship. """ from __future__ import annotations import math from . import costs from .boards import Package, motor_filter from .data import Data from .grid import Size, centre, rect from .layout import CoreLayout, MotorLayout, _home_words, _pages_at, motor_design from .solve import hungarian TOP_N = (25, 50, 100, 200, 400) # Which words an ending applies to, for the reach metrics (an ending added to # "a" is not the word "as"). ENDING_TAKES = { "plural": ("core_noun", "fringe_noun"), "possessive": ("core_noun", "fringe_noun"), "past": ("verb",), "present-progressive": ("verb",), } # Forms of be, have, do and the past forms: words of their own, not stems. NOT_INFLECTED = {"is", "am", "are", "was", "were", "be", "been", "has", "had", "did", "does", "got", "went", "said", "can", "will"} def apply_modifier(word: str, modifier: str) -> str: """aac-web actions.rs apply_modifier: English-regular endings only.""" if modifier == "plural": if word.endswith(("s", "x", "z", "sh", "ch")): return word + "es" if word.endswith("y") and not word.endswith(("ay", "ey", "oy", "uy")): return word[:-1] + "ies" return word + "s" if modifier == "past": return word + "d" if word.endswith("e") else word + "ed" if modifier == "present-progressive": return word[:-1] + "ing" if word.endswith("e") else word + "ing" if modifier == "future": return f"will {word}" if modifier == "possessive": return f"{word}'s" if modifier == "negation": return f"not {word}" if modifier == "question": return f"{word}?" return word # ── paths through a package ────────────────────────────────────────────── def paths(pkg: Package) -> dict[str, list[tuple[str, tuple[int, int], Size]]]: """Every word's shortest visible path from the root: [(board id, cell, size)].""" boards = {b.id: b for b in pkg.boards} best: dict[str, list] = {} frontier = [(pkg.root, [])] seen = {pkg.root} while frontier: nxt = [] for bid, path in frontier: b = boards[bid] size = Size(b.rows, b.cols) for btn in b.buttons: if btn.hidden: continue step = path + [(bid, (btn.row, btn.col), size)] if btn.action == "speak": if btn.key not in best or len(step) < len(best[btn.key]): best[btn.key] = step elif btn.action == "modifier": best.setdefault("\0mod:" + btn.modifier, step) elif btn.action == "navigate" and btn.control is None and btn.target not in seen: seen.add(btn.target) nxt.append((btn.target, step)) frontier = nxt return best def path_costs(path) -> dict[str, float]: return { "hits": len(path), "scan": sum(costs.scan_steps(c, s) for _, c, s in path), "touch": sum(costs.touch_cost(c, s) for _, c, s in path), "gaze": sum(costs.gaze_cost(c, s) for _, c, s in path), } def reach(data: Data, pkg: Package) -> dict[str, dict]: """Words the package can say: directly, or through the app's endings (base word, then the Word forms page and the ending).""" ps = paths(pkg) out = {} mods = {k[5:]: v for k, v in ps.items() if k.startswith("\0mod:")} for w, p in ps.items(): if w.startswith("\0"): continue out[w.lower()] = {"how": "direct", **path_costs(p)} for w, p in ps.items(): if w.startswith("\0") or " " in w: continue e = data.lexicon.get(w) for m, mp in mods.items(): if not e or e.pos not in ENDING_TAKES.get(m, ()) or (e.pos == "verb" and e.sense and "modal" in e.sense): continue if e.pos == "verb" and w.lower() in NOT_INFLECTED: continue form = apply_modifier(w.lower(), m) if form in out: continue c = path_costs(p + mp) # the ending is chosen from the Word forms page: open it, then the ending out[form] = {"how": f"ending:{m}", **c} return out def spoken_reference(data: Data): ranking = data.spoken_ranking total = sum(n for _, n in ranking) return ranking, total def coverage(data: Data, r: dict[str, dict]) -> dict: ranking, total = spoken_reference(data) out = {} for n in TOP_N: top = [w for w, _ in ranking[:n]] direct = [w for w in top if w in r and r[w]["how"] == "direct"] forms = [w for w in top if w in r] one = [w for w in top if w in r and r[w]["how"] == "direct" and r[w]["hits"] == 1] out[f"top{n}"] = {"one_hit": len(one), "direct": len(direct), "with_endings": len(forms)} out["missing_top100"] = [w for w, _ in ranking[:100] if w not in r] said = sum(c for w, c in ranking if w in r and r[w]["how"] == "direct") out["token_share_direct"] = round(said / total, 4) out["token_share_with_endings"] = round(sum(c for w, c in ranking if w in r) / total, 4) return out def weighted(data: Data, r: dict[str, dict], weight=None) -> dict: """Frequency-weighted mean of each path cost over the words said directly.""" ranking, _ = spoken_reference(data) counts = dict(ranking) tot = {"hits": 0.0, "scan": 0.0, "touch": 0.0, "gaze": 0.0} wsum = 0.0 for w, c in r.items(): if c["how"] != "direct": continue f = weight(w) if weight else counts.get(w, 0) if not f: continue wsum += f for k in tot: tot[k] += f * c[k] return {k: round(v / wsum, 3) for k, v in tot.items()} if wsum else {} def hits_histogram(r: dict[str, dict]) -> dict[str, int]: h: dict[str, int] = {} for c in r.values(): if c["how"] == "direct": h[str(c["hits"])] = h.get(str(c["hits"]), 0) + 1 return dict(sorted(h.items())) def list_coverage(data: Data, r: dict[str, dict]) -> dict: uc = [("all done" if w == "finished" else w).lower() for w in data.universal_core] bj = [w.lower() for w in data.banajee] def hits(ws): return {w: r[w]["hits"] if w in r and r[w]["how"] == "direct" else None for w in ws} u, b = hits(uc), hits(bj) return { "universal_core": {"reachable": sum(1 for v in u.values() if v), "of": len(uc), "one_hit": sum(1 for v in u.values() if v == 1)}, "banajee_2003": {"reachable": sum(1 for v in b.values() if v), "of": len(bj), "one_hit": sum(1 for v in b.values() if v == 1)}, } # ── stability ──────────────────────────────────────────────────────────── def stability(sizes_of: dict[str, dict[str, tuple[int, int]]], sizes: list[Size]) -> dict: """For items present at two sizes A < B: how far the centre of their button moves (in board widths/heights, and in cells of the smaller board A), and whether all of an item's buttons share a point.""" by_name = {s.name: s for s in sizes} worst = {"du": 0.0, "dv": 0.0, "cells": 0.0, "item": None, "from": None, "to": None} total, n = 0.0, 0 worst_step = 0.0 common_ok = True order = [s.name for s in sizes] for key, at in sizes_of.items(): names = [s for s in order if s in at] rects = [rect(at[s], by_name[s]) for s in names] v0 = max(r[0] for r in rects) v1 = min(r[1] for r in rects) u0 = max(r[2] for r in rects) u1 = min(r[3] for r in rects) if not (v0 < v1 and u0 < u1): common_ok = False for i, a in enumerate(names): for b in names[i + 1:]: sa, sb = by_name[a], by_name[b] ca, cb = centre(at[a], sa), centre(at[b], sb) dv, du = abs(ca[0] - cb[0]), abs(ca[1] - cb[1]) cells = max(dv * sa.rows, du * sa.cols) total += math.hypot(du, dv) n += 1 if cells > worst["cells"]: worst = {"du": round(du, 3), "dv": round(dv, 3), "cells": round(cells, 3), "item": key, "from": a, "to": b} if order.index(b) == order.index(a) + 1: worst_step = max(worst_step, math.hypot(du, dv)) return { "pairs": n, "mean_shift": round(total / n, 4) if n else 0.0, "worst": worst, "worst_shift_one_step": round(worst_step, 4), "within_one_cell_of_smaller": worst["cells"] < 1.0, "every_item_keeps_a_common_point": common_ok, } # ── the sets ───────────────────────────────────────────────────────────── def core_metrics(data: Data, lay: CoreLayout, pkgs: list[Package]) -> dict: out = {"sizes": {}} for pkg in pkgs: if pkg.variant != "full": continue size = Size.parse(pkg.size) r = reach(data, pkg) root = next(b for b in pkg.boards if b.id == pkg.root) out["sizes"][pkg.size] = { "cells": size.cells, "core_words": sum(1 for b in root.buttons if b.action == "speak"), "pages": sum(1 for b in root.buttons if b.action == "navigate"), "words_reachable": sum(1 for c in r.values() if c["how"] == "direct"), "hits": hits_histogram(r), "weighted": weighted(data, r), "coverage": coverage(data, r), "lists": list_coverage(data, r), "targets": costs.target_sizes(size), } core_at = {p.key: p.at for p in lay.core} out["stability_core"] = stability(core_at, lay.sizes) out["stability_pages"] = { name: stability({p.key: p.at for p in page.items}, page.sizes) for name, page in lay.pages.items() } return out def motor_metrics(data: Data, lay: MotorLayout, pkgs: list[Package]) -> dict: d = data.fixed_paths out = {"sizes": {}} _, _, _, _, _, w_aac = motor_design(data) _, _, _, _, _, w_spoken = motor_design(data, aac_boost=0) for pkg in pkgs: if pkg.variant != "full": continue ms = next(m for m in lay.sizes if m.label == pkg.size) r = reach(data, pkg) lookup = {k.lower(): k for k in data.lexicon} out["sizes"][pkg.size] = { "grid": ms.size.name, "home_words": sum(1 for k in ms.home if k not in ms.pages), "pages": len(ms.pages), "groups": len(ms.groups), "words": sum(1 for c in r.values() if c["how"] == "direct"), "hits": hits_histogram(r), "mean_hits_unweighted": round(sum(c["hits"] for c in r.values() if c["how"] == "direct") / max(1, sum(1 for c in r.values() if c["how"] == "direct")), 3), "weighted_spoken": weighted(data, r, lambda w: w_spoken.get(lookup.get(w, w), 0.0)), "weighted_design": weighted(data, r, lambda w: w_aac.get(lookup.get(w, w), 0.0)), "coverage": coverage(data, r), "lists": list_coverage(data, r), "targets": costs.target_sizes(ms.size), "tradeoffs": tradeoffs(data, lay, ms), } out["stability_home"] = stability( {k: {m.size.name: m.home[k] for m in lay.sizes if k in m.home} for k in lay.points}, [m.size for m in lay.sizes] ) out["filters"] = {f: sorted(motor_filter(data, f)) for f in d.get("filters", {})} return out def tradeoffs(data: Data, lay: MotorLayout, ms) -> dict: """The chosen layout against layouts that serve one access method only. For each method: put the Home items where that method alone says (the cheapest assignment for this size, with no tie to the other sizes), lay out the pages the same way, and measure every layout under every method: the design-weighted mean cost of saying a word.""" d, labels, sizes, page_of, group_of, weights = motor_design(data) k = labels.index(ms.label) homes = _home_words(d, sizes, weights) items = list(d["pages"]) + homes[k] pw = {p: sum(w for x, w in weights.items() if page_of[x] == p and x not in homes[k]) for p in d["pages"]} def layout_for(table) -> tuple[dict, dict, dict]: cells = list(table) m = [[(pw[it] if it in pw else weights[it]) * table[c] for c in cells] for it in items] _, assign = hungarian(m) home = {it: cells[assign[i]] for i, it in enumerate(items)} pages, groups = _pages_at(d, ms.size, home, page_of, group_of, weights, table) return home, pages, groups def evaluate(home, pages, groups) -> dict: tot = {"hits": 0.0, "scan": 0.0, "touch": 0.0, "gaze": 0.0} wsum = 0.0 size = ms.size for w, wt in weights.items(): if w in home: path = [home[w]] else: p = page_of[w] if w in pages[p]["words"]: path = [home[p], pages[p]["words"][w]] else: g = group_of[w] path = [home[p], pages[p]["groups"][g], groups[g]["words"][w]] wsum += wt tot["hits"] += wt * len(path) tot["scan"] += wt * sum(costs.scan_steps(c, size) for c in path) tot["touch"] += wt * sum(costs.touch_cost(c, size) for c in path) tot["gaze"] += wt * sum(costs.gaze_cost(c, size) for c in path) return {k2: round(v / wsum, 3) for k2, v in tot.items()} out = {"chosen": evaluate(ms.home, ms.pages, ms.groups)} out["blend_alone"] = evaluate(*layout_for(costs.blended(ms.size, d["blend"][ms.label]))) for method in ("touch", "scan", "gaze"): out[f"{method}_only"] = evaluate(*layout_for(costs.blended(ms.size, {method: 1.0}))) return out def endings_report(data: Data, words: set[str]) -> list[dict]: """Where the app's English-regular endings get a word of the sets wrong.""" names = {"past": "past", "ing": "present-progressive", "plural": "plural"} out = [] for w in sorted(words): e = data.lexicon.get(w) if not e: continue for k, m in names.items(): if k in e.forms: app = apply_modifier(w, m) if app != e.forms[k]: out.append({"word": w, "ending": m, "app_says": app, "correct": e.forms[k]}) return out