a11ydebt 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
a11ydebt/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """a11ydebt: audit accessibility debt (missing content descriptions) in GUI-agent corpora."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,268 @@
1
+ """AndroidControl loader: TFRecord shards to episodes, one screen per step, flags per node.
2
+
3
+ Ports the parse logic of ``phase2_processing/02_parse_androidcontrol.py`` and
4
+ ``src/a11ydebt/apps.py`` from accessibility-debt-agents (commit 97c0378, MIT; see NOTICE), and
5
+ keeps the rules that decide the counts unchanged:
6
+
7
+ * A screen is one step of an episode, ``f"{episode_id}_{step_index}"``. Its nodes are every node
8
+ of every window in that step's AndroidAccessibilityForest, windows in forest order.
9
+ * A forest that fails to decode becomes an empty screen, and an episode that fails to decode is
10
+ recorded as a failure and left out. A truncated shard yields every record it could read and
11
+ sets ``ShardReader.truncated``.
12
+ * The codebook is ``a11ydebt.taxonomy``, applied exactly as for Rico. Roles come from the class name
13
+ only, ``labelled`` means a non-empty content description, and ``included`` means visible to the
14
+ user with positive area. The P01 ``interactive_image`` flag uses ``is_focusable``.
15
+ * An episode's app is the most frequent package among its nodes' resource ids
16
+ (``<package>:id/<name>``), ignoring platform packages, with ties broken alphabetically. An
17
+ episode with no usable resource id gets app "".
18
+
19
+ Standard library only, so the Kaggle kernel can bundle it.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import re
26
+ from collections import Counter
27
+ from collections.abc import Iterator, Mapping
28
+ from dataclasses import dataclass
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from a11ydebt import taxonomy as tx
33
+ from a11ydebt.counts import POPULATIONS
34
+ from a11ydebt.tfrecord import TruncatedShardError, iter_records, parse_example, parse_forest
35
+
36
+ __all__ = [
37
+ "SYSTEM_PACKAGES",
38
+ "AcEpisode",
39
+ "AcNode",
40
+ "AcScreen",
41
+ "ShardReader",
42
+ "episode_app",
43
+ "load_splits",
44
+ "parse_episode",
45
+ ]
46
+
47
+ #: Platform packages that appear on every device; never an episode's app (AISSA config.yaml).
48
+ SYSTEM_PACKAGES: frozenset[str] = frozenset(
49
+ {
50
+ "android",
51
+ "com.android.systemui",
52
+ "com.android.launcher3",
53
+ "com.android.intentresolver",
54
+ "com.android.permissioncontroller",
55
+ "com.android.packageinstaller",
56
+ "com.android.printspooler",
57
+ "com.android.bips",
58
+ "com.android.htmlviewer",
59
+ "com.google.android.inputmethod.latin",
60
+ "com.google.android.apps.nexuslauncher",
61
+ "com.google.android.permissioncontroller",
62
+ "com.google.android.packageinstaller",
63
+ "com.google.android.gms",
64
+ "com.google.android.ext.services",
65
+ "com.google.android.settings.intelligence",
66
+ "com.google.android.tts",
67
+ }
68
+ )
69
+ _PACKAGE = re.compile(r"^([A-Za-z0-9_.]+):id/")
70
+
71
+
72
+ @dataclass(frozen=True, slots=True)
73
+ class AcNode:
74
+ """One accessibility node with the codebook applied. ``node_uid`` is the window-local id."""
75
+
76
+ window_index: int
77
+ window_layer: int
78
+ node_uid: int
79
+ depth: int
80
+ class_name: str
81
+ resource_id: str
82
+ content_desc: str # cleaned: "" when absent or "null"
83
+ text: str # cleaned the same way
84
+ hint_text: str
85
+ clickable: bool
86
+ long_clickable: bool
87
+ focusable: bool
88
+ enabled: bool
89
+ visible_to_user: bool
90
+ bounds: tuple[int, int, int, int]
91
+ role: str
92
+ included: bool
93
+ needs_label: bool
94
+ icon_like: bool
95
+ interactive_image: bool
96
+
97
+ @property
98
+ def labelled(self) -> bool:
99
+ return bool(self.content_desc)
100
+
101
+ def in_population(self, population: str) -> bool:
102
+ if population not in POPULATIONS:
103
+ raise ValueError(f"unknown population {population!r}; choose from {POPULATIONS}")
104
+ return self.included and bool(getattr(self, population))
105
+
106
+ def flagged(self, population: str = "needs_label") -> bool:
107
+ return self.in_population(population) and not self.labelled
108
+
109
+
110
+ @dataclass(frozen=True, slots=True)
111
+ class AcScreen:
112
+ screen_id: str
113
+ app: str
114
+ episode_id: int
115
+ step_index: int
116
+ width: int
117
+ height: int
118
+ nodes: tuple[AcNode, ...]
119
+
120
+ def flagged(self, population: str = "needs_label") -> list[AcNode]:
121
+ return [node for node in self.nodes if node.flagged(population)]
122
+
123
+
124
+ @dataclass(frozen=True, slots=True)
125
+ class AcEpisode:
126
+ episode_id: int
127
+ split: str
128
+ shard: str
129
+ goal: str
130
+ app: str
131
+ n_actions: int
132
+ screens: tuple[AcScreen, ...]
133
+
134
+
135
+ def _make_node(node: Any, window_index: int, window_layer: int) -> AcNode:
136
+ class_name = node.class_name
137
+ text = tx.clean_text(node.text)
138
+ role = tx.classify_role(class_name)
139
+ return AcNode(
140
+ window_index=window_index,
141
+ window_layer=window_layer,
142
+ node_uid=node.unique_id,
143
+ depth=node.depth,
144
+ class_name=class_name,
145
+ resource_id=node.view_id_resource_name,
146
+ content_desc=tx.clean_text(node.content_description),
147
+ text=text,
148
+ hint_text=node.hint_text,
149
+ clickable=node.is_clickable,
150
+ long_clickable=node.is_long_clickable,
151
+ focusable=node.is_focusable,
152
+ enabled=node.is_enabled,
153
+ visible_to_user=node.is_visible_to_user,
154
+ bounds=(node.left, node.top, node.right, node.bottom),
155
+ role=role,
156
+ included=tx.in_analysis_population(
157
+ node.is_visible_to_user, node.left, node.top, node.right, node.bottom, node.is_enabled
158
+ ),
159
+ needs_label=tx.needs_label(role, node.is_clickable, node.is_long_clickable, text),
160
+ icon_like=tx.is_icon_like(role, text, node.is_clickable, node.is_long_clickable),
161
+ interactive_image=tx.is_interactive_image(class_name, node.is_clickable, node.is_focusable),
162
+ )
163
+
164
+
165
+ def episode_app(resource_ids: Iterator[str] | list[str]) -> str:
166
+ """The dominant non-platform package among resource ids; "" when there is none."""
167
+ counts: Counter[str] = Counter()
168
+ for rid in resource_ids:
169
+ match = _PACKAGE.match(rid or "")
170
+ if match and match.group(1) not in SYSTEM_PACKAGES:
171
+ counts[match.group(1)] += 1
172
+ if not counts:
173
+ return ""
174
+ return min(counts.items(), key=lambda item: (-item[1], item[0]))[0]
175
+
176
+
177
+ def parse_episode(
178
+ example: Mapping[str, list], split: str = "unknown", shard: str = ""
179
+ ) -> AcEpisode:
180
+ """Decode one tf.train.Example (as returned by ``parse_example``) into screens of nodes."""
181
+ episode_id = int((example.get("episode_id") or [0])[0])
182
+ goal = (example.get("goal") or [b""])[0].decode("utf-8", "replace")
183
+ trees = example.get("accessibility_trees") or []
184
+ widths = example.get("screenshot_widths") or []
185
+ heights = example.get("screenshot_heights") or []
186
+
187
+ steps: list[tuple[int, int, tuple[AcNode, ...]]] = []
188
+ for step_index, blob in enumerate(trees):
189
+ try:
190
+ windows = parse_forest(blob)
191
+ except Exception: # a forest that will not decode is an empty screen, as in AISSA
192
+ windows = []
193
+ nodes = tuple(
194
+ _make_node(node, window_index, window.layer)
195
+ for window_index, window in enumerate(windows)
196
+ for node in window.nodes
197
+ )
198
+ width = int(widths[step_index]) if step_index < len(widths) else 0
199
+ height = int(heights[step_index]) if step_index < len(heights) else 0
200
+ steps.append((width, height, nodes))
201
+
202
+ app = episode_app([node.resource_id for _w, _h, nodes in steps for node in nodes])
203
+ screens = tuple(
204
+ AcScreen(
205
+ screen_id=f"{episode_id}_{step_index}",
206
+ app=app,
207
+ episode_id=episode_id,
208
+ step_index=step_index,
209
+ width=width,
210
+ height=height,
211
+ nodes=nodes,
212
+ )
213
+ for step_index, (width, height, nodes) in enumerate(steps)
214
+ )
215
+ return AcEpisode(
216
+ episode_id=episode_id,
217
+ split=split,
218
+ shard=shard,
219
+ goal=goal,
220
+ app=app,
221
+ n_actions=len(example.get("actions") or []),
222
+ screens=screens,
223
+ )
224
+
225
+
226
+ def load_splits(path: str | Path) -> dict[int, str]:
227
+ """Map episode id -> split ("train", "validation", "test") from AndroidControl's splits.json."""
228
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
229
+ return {int(episode_id): split for split, ids in data.items() for episode_id in ids}
230
+
231
+
232
+ class ShardReader:
233
+ """Iterate the episodes of one shard. A truncated shard stops early and sets ``truncated``;
234
+ an episode that fails to decode is skipped and listed in ``failures``."""
235
+
236
+ def __init__(
237
+ self, path: str | Path, splits: Mapping[int, str] | None = None, shard: str | None = None
238
+ ) -> None:
239
+ self.path = Path(path)
240
+ self.shard = shard or self.path.name
241
+ self.splits = splits or {}
242
+ self.truncated = False
243
+ self.records = 0
244
+ self.failures: list[dict[str, str]] = []
245
+
246
+ def __iter__(self) -> Iterator[AcEpisode]:
247
+ records = iter_records(self.path)
248
+ while True:
249
+ try:
250
+ blob = next(records)
251
+ except StopIteration:
252
+ return
253
+ except TruncatedShardError:
254
+ self.truncated = True
255
+ return
256
+ self.records += 1
257
+ try:
258
+ example = parse_example(blob)
259
+ episode_id = int((example.get("episode_id") or [0])[0])
260
+ yield parse_episode(example, self.splits.get(episode_id, "unknown"), self.shard)
261
+ except Exception as exc: # one bad episode must not stop a 2.5 GB shard
262
+ self.failures.append(
263
+ {
264
+ "shard": self.shard,
265
+ "record": str(self.records),
266
+ "error": f"{type(exc).__name__}: {exc}",
267
+ }
268
+ )
a11ydebt/bootstrap.py ADDED
@@ -0,0 +1,230 @@
1
+ """Cluster-bootstrap intervals for pooled coverage (labelled / candidates).
2
+
3
+ Elements are nested in screens, and screens in apps, so element-level intervals are too narrow.
4
+ The cluster bootstrap resamples whole clusters with replacement and recomputes the pooled rate
5
+ ``sum(labelled) / sum(candidates)``. The interval is the 2.5th and 97.5th percentile of the
6
+ draws.
7
+
8
+ Ported from accessibility-debt-agents (commit 97c0378, MIT; see NOTICE):
9
+ ``cluster_bootstrap_rate`` and ``pct_ci`` from ``phase4_validation/03_reviewer_checks.py``,
10
+ ``wilson_ci`` from ``src/a11ydebt/stats.py``, and the defaults (seed 20260908, 5,000 resamples)
11
+ from ``config.yaml``. AISSA's ``src/a11ydebt/bootstrap.py`` is only an import shim and holds no
12
+ bootstrap code.
13
+
14
+ The algorithm is unchanged: each resample draws ``rng.integers(0, m, m)`` cluster indices. Here
15
+ a block of resamples is drawn at once (``size=(rows, m)``), which consumes numpy's generator
16
+ exactly as AISSA's one-call-per-resample loop does. Given the same generator state, the draws are
17
+ bit-identical to AISSA's and the generator ends in the same state (``tests/test_bootstrap.py``).
18
+
19
+ Clusters follow AISSA's ReviewerChecks:
20
+
21
+ * ``screen``: each screen with at least one candidate is a cluster.
22
+ * ``app``: each app is a cluster. On AndroidControl, an episode with no identified app is its own
23
+ cluster (``episode:<id>``), so the interval is centred on the same estimate as the others. On
24
+ Rico, a screen with no app would be its own cluster (``screen:<id>``). AISSA drops such Rico
25
+ screens instead, but Rico has none, so the two rules give the same clusters.
26
+
27
+ Clusters are ordered by key (Python string order, the same as pandas ``groupby``), because the
28
+ order decides which cluster each drawn index picks.
29
+ """
30
+
31
+ from __future__ import annotations
32
+
33
+ from collections import defaultdict
34
+ from collections.abc import Iterable
35
+ from statistics import NormalDist
36
+ from typing import NamedTuple
37
+
38
+ import numpy as np
39
+ from pydantic import BaseModel, ConfigDict
40
+
41
+ __all__ = [
42
+ "DEFAULT_CONF",
43
+ "DEFAULT_N_RESAMPLES",
44
+ "DEFAULT_SEED",
45
+ "LEVELS",
46
+ "ClusteredInterval",
47
+ "ScreenCount",
48
+ "cluster_bootstrap_rate",
49
+ "cluster_key",
50
+ "cluster_totals",
51
+ "clustered_interval",
52
+ "percentile_ci",
53
+ "wilson_ci",
54
+ ]
55
+
56
+ #: AISSA config.yaml ``run.seed`` and ``run.n_bootstrap``.
57
+ DEFAULT_SEED = 20260908
58
+ DEFAULT_N_RESAMPLES = 5000
59
+ DEFAULT_CONF = 0.95
60
+ #: Cluster levels, in report order: apps (the primary interval), then screens.
61
+ LEVELS: tuple[str, ...] = ("app", "screen")
62
+ _BLOCK_ELEMENTS = 1 << 22 # cluster indices drawn per block: 32 MB of int64
63
+
64
+
65
+ class ScreenCount(NamedTuple):
66
+ """One screen's counts for one population, as the bootstrap needs them."""
67
+
68
+ screen_id: str
69
+ app: str
70
+ labelled: int
71
+ candidates: int
72
+
73
+
74
+ def _quantile_levels(conf: float) -> tuple[float, float]:
75
+ # (1 - 0.95) / 2 is 0.025000000000000022 in floating point; rounding gives AISSA's literal
76
+ # 0.025 and 0.975, so the percentiles are bit-identical to its pct_ci.
77
+ if not 0 < conf < 1:
78
+ raise ValueError(f"conf must be between 0 and 1, got {conf}")
79
+ low = round((1 - conf) / 2, 10)
80
+ return low, round(1 - low, 10)
81
+
82
+
83
+ def cluster_bootstrap_rate(
84
+ labelled: Iterable[int] | np.ndarray,
85
+ candidates: Iterable[int] | np.ndarray,
86
+ *,
87
+ n_resamples: int = DEFAULT_N_RESAMPLES,
88
+ seed: int | None = DEFAULT_SEED,
89
+ rng: np.random.Generator | None = None,
90
+ block_elements: int = _BLOCK_ELEMENTS,
91
+ ) -> np.ndarray:
92
+ """Bootstrap draws of the pooled rate ``sum(labelled) / sum(candidates)`` over clusters.
93
+
94
+ ``labelled[i]`` and ``candidates[i]`` are cluster ``i``'s totals. Each of the
95
+ ``n_resamples`` draws resamples the ``m`` clusters with replacement. Pass ``rng`` to continue
96
+ an existing generator (as AISSA does across its tables); otherwise a new generator is seeded
97
+ with ``seed``. ``block_elements`` only bounds memory and never changes the draws. A draw whose
98
+ resampled clusters have no candidates is NaN, and with no clusters every draw is NaN.
99
+ """
100
+ k = np.asarray(labelled, dtype=np.int64)
101
+ n = np.asarray(candidates, dtype=np.int64)
102
+ if k.ndim != 1 or k.shape != n.shape:
103
+ raise ValueError("labelled and candidates must be 1-D arrays of the same length")
104
+ if n_resamples < 1:
105
+ raise ValueError(f"n_resamples must be at least 1, got {n_resamples}")
106
+ if (k < 0).any() or (k > n).any():
107
+ raise ValueError("every cluster needs 0 <= labelled <= candidates")
108
+ out = np.full(n_resamples, np.nan)
109
+ m = k.size
110
+ if m == 0:
111
+ return out
112
+ gen = rng if rng is not None else np.random.default_rng(seed)
113
+ rows = max(1, block_elements // m)
114
+ for start in range(0, n_resamples, rows):
115
+ stop = min(start + rows, n_resamples)
116
+ idx = gen.integers(0, m, size=(stop - start, m))
117
+ kk = k[idx].sum(axis=1)
118
+ nn = n[idx].sum(axis=1)
119
+ with np.errstate(invalid="ignore", divide="ignore"):
120
+ out[start:stop] = np.where(nn > 0, kk / nn, np.nan)
121
+ return out
122
+
123
+
124
+ def percentile_ci(draws: np.ndarray, conf: float = DEFAULT_CONF) -> tuple[float, float]:
125
+ """Percentile interval of the draws, ignoring NaN (AISSA ``pct_ci``)."""
126
+ low, high = _quantile_levels(conf)
127
+ arr = np.asarray(draws, dtype=float)
128
+ if np.isnan(arr).all():
129
+ return float("nan"), float("nan")
130
+ return float(np.nanquantile(arr, low)), float(np.nanquantile(arr, high))
131
+
132
+
133
+ def wilson_ci(labelled: int, candidates: int, conf: float = DEFAULT_CONF) -> tuple[float, float]:
134
+ """Element-level Wilson score interval (AISSA ``stats.wilson_ci``, with a stdlib z)."""
135
+ if candidates == 0:
136
+ return float("nan"), float("nan")
137
+ _quantile_levels(conf)
138
+ z = NormalDist().inv_cdf(1 - (1 - conf) / 2)
139
+ p = labelled / candidates
140
+ denom = 1 + z**2 / candidates
141
+ centre = (p + z**2 / (2 * candidates)) / denom
142
+ half = z * (p * (1 - p) / candidates + z**2 / (4 * candidates**2)) ** 0.5 / denom
143
+ return max(0.0, centre - half), min(1.0, centre + half)
144
+
145
+
146
+ def _episode(screen_id: str) -> str:
147
+ # AndroidControl screen ids are f"{episode_id}_{step_index}" (a11ydebt.androidcontrol).
148
+ return screen_id.rsplit("_", 1)[0]
149
+
150
+
151
+ def cluster_key(level: str, dataset: str, screen_id: str, app: str) -> str:
152
+ """The cluster a screen belongs to at ``level`` (``"app"`` or ``"screen"``)."""
153
+ if level == "screen":
154
+ return screen_id
155
+ if level != "app":
156
+ raise ValueError(f"level must be one of {LEVELS}, got {level!r}")
157
+ if app:
158
+ return app
159
+ if dataset == "androidcontrol":
160
+ return f"episode:{_episode(screen_id)}"
161
+ return f"screen:{screen_id}"
162
+
163
+
164
+ def cluster_totals(
165
+ screens: Iterable[ScreenCount], *, level: str, dataset: str
166
+ ) -> tuple[list[str], np.ndarray, np.ndarray]:
167
+ """Sorted cluster keys with their labelled and candidate totals.
168
+
169
+ Only screens with at least one candidate contribute, so every cluster has candidates.
170
+ """
171
+ labelled: dict[str, int] = defaultdict(int)
172
+ candidates: dict[str, int] = defaultdict(int)
173
+ for s in screens:
174
+ if s.candidates <= 0:
175
+ continue
176
+ key = cluster_key(level, dataset, s.screen_id, s.app)
177
+ labelled[key] += s.labelled
178
+ candidates[key] += s.candidates
179
+ keys = sorted(candidates)
180
+ return (
181
+ keys,
182
+ np.array([labelled[key] for key in keys], dtype=np.int64),
183
+ np.array([candidates[key] for key in keys], dtype=np.int64),
184
+ )
185
+
186
+
187
+ class ClusteredInterval(BaseModel):
188
+ """Pooled coverage with its cluster-bootstrap percentile interval."""
189
+
190
+ model_config = ConfigDict(frozen=True)
191
+
192
+ level: str
193
+ n_clusters: int
194
+ labelled: int
195
+ candidates: int
196
+ coverage: float | None
197
+ ci_low: float | None
198
+ ci_high: float | None
199
+ n_resamples: int
200
+ seed: int | None
201
+ conf: float
202
+
203
+
204
+ def clustered_interval(
205
+ screens: Iterable[ScreenCount],
206
+ *,
207
+ level: str,
208
+ dataset: str,
209
+ n_resamples: int = DEFAULT_N_RESAMPLES,
210
+ seed: int | None = DEFAULT_SEED,
211
+ conf: float = DEFAULT_CONF,
212
+ rng: np.random.Generator | None = None,
213
+ ) -> ClusteredInterval:
214
+ """Coverage and its ``conf`` cluster-bootstrap interval at ``level`` for one population."""
215
+ _keys, k, n = cluster_totals(screens, level=level, dataset=dataset)
216
+ draws = cluster_bootstrap_rate(k, n, n_resamples=n_resamples, seed=seed, rng=rng)
217
+ low, high = percentile_ci(draws, conf)
218
+ total_k, total_n = int(k.sum()), int(n.sum())
219
+ return ClusteredInterval(
220
+ level=level,
221
+ n_clusters=len(k),
222
+ labelled=total_k,
223
+ candidates=total_n,
224
+ coverage=total_k / total_n if total_n else None,
225
+ ci_low=None if np.isnan(low) else low,
226
+ ci_high=None if np.isnan(high) else high,
227
+ n_resamples=n_resamples,
228
+ seed=seed if rng is None else None,
229
+ conf=conf,
230
+ )