From 694d135bfc08212a4e278919b7d4b97af6759598 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 30 Jul 2026 15:49:07 -0400 Subject: [PATCH 1/5] Packet 2B apparatus: the neutral candidatekit and the check-5 oracle Pin 13 requires an oracle committed and reviewed before any candidate is scored against it. Both halves land here, before C1 or C2 exists, so neither candidate can shape what it will be measured by. round2-candidatekit is the only code the two candidates share. It loads and validates Packet 2A's fixtures, probe table, reference rasters and regions; it renders nothing, resolves no hit test, and builds no accessibility node -- a dependency deny-list test reads its own manifest at test time to keep it that way, including the dotted-subtable and target-cfg forms a line scanner would otherwise miss. Check 3 is pinned NOT RUN by the recipe's 1.2 ruling: a report claiming Pass or Fail for it is refused, and the supplementary F-D field is one the scoring function never reads, so it cannot reach the criterion cell by construction rather than by convention. A check-5 NOT RUN now requires typed evidence that the platform bus was unreachable, because Round 0 already proved it reachable for both candidates -- "we did not build it" is scope, and scope is not environmental. round2-a11y-oracle derives every byte string a live AT-SPI readback must compare against, from the digest-validated fixtures alone: the expected name three ways, the at-spi2 role sets, a precommitted alternative form per prohibited outcome, the visual-order form, and the per-segment source atoms. The fixtures digest is unchanged at acc13c0d, so Packet 2A stays frozen. verify.py gains a check-5 mode whose classifier is a pure function, unit tested without a bus, and which fails closed on platform, digest, fixture completeness, the name/hex/length triple, atom concatenation, and any string that would classify as two different outcomes. The scoring policy, settled over eight review rounds and worth stating because most of it was won by finding the opposite in the code first: - An accepted-role exact match wins from anywhere in the forest. It used to be first-match, so tree order decided a disqualifying check. - Composition is scoped to one run subtree and outranks any per-node diagnosis. Application-wide concatenation both passed a run half-drawn as a canvas and failed a correct split run because the window carried a label. - Contributor order stays significant, since 8.1 requires logical order; only non-contributor order is required not to matter. - name-empty means an attempted static-text exposure without a name. absent-from-tree means a drawing-only or unrelated tree, and nothing else -- it is the outcome 8.3 says this check will most likely catch, and it was nearly unreachable while gated on role presence. - A byte-exact PASS outranks an alternative-form match. F-C's "drops unresolved codepoints" form is its own first segment, so a legitimate per-run split was being failed on a check that eliminates. That collision is structural for any two-segment fixture with one unresolved segment, not an F-C accident. Recorded as a finding, not fixed: the recipe's claim that visual-order assembly diverges for F-D "and only there" is false -- F-B diverges too, and any non-palindromic RTL run of two or more graphemes will. F-D's real distinguishing property is an interior RTL run. The same stale wording is compiled into fixtures.json and covered by its frozen digest, so correcting it would move acc13c0d out from under Packet 2A. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RSX4zSLgKvtiXaPjnMqLGz --- spikes/editor-toolkit/Cargo.lock | 21 + spikes/editor-toolkit/Cargo.toml | 2 + .../editor-toolkit/a11y-verifier/.gitignore | 1 + .../a11y-verifier/test_verify.py | 1230 +++++++++++++++++ spikes/editor-toolkit/a11y-verifier/verify.py | 1012 +++++++++++++- .../round2-a11y-oracle/Cargo.toml | 40 + .../round2-a11y-oracle/a11y_expectations.json | 144 ++ .../src/bin/generate_a11y_expectations.rs | 58 + .../round2-a11y-oracle/src/findings.rs | 113 ++ .../round2-a11y-oracle/src/lib.rs | 971 +++++++++++++ .../round2-candidatekit/Cargo.toml | 24 + .../round2-candidatekit/src/inputs.rs | 332 +++++ .../round2-candidatekit/src/lib.rs | 71 + .../round2-candidatekit/src/outcome.rs | 270 ++++ .../round2-candidatekit/src/report.rs | 486 +++++++ .../round2-candidatekit/src/scoring.rs | 368 +++++ .../tests/dependency_deny_list.rs | 223 +++ 17 files changed, 5328 insertions(+), 38 deletions(-) create mode 100644 spikes/editor-toolkit/a11y-verifier/.gitignore create mode 100644 spikes/editor-toolkit/a11y-verifier/test_verify.py create mode 100644 spikes/editor-toolkit/round2-a11y-oracle/Cargo.toml create mode 100644 spikes/editor-toolkit/round2-a11y-oracle/a11y_expectations.json create mode 100644 spikes/editor-toolkit/round2-a11y-oracle/src/bin/generate_a11y_expectations.rs create mode 100644 spikes/editor-toolkit/round2-a11y-oracle/src/findings.rs create mode 100644 spikes/editor-toolkit/round2-a11y-oracle/src/lib.rs create mode 100644 spikes/editor-toolkit/round2-candidatekit/Cargo.toml create mode 100644 spikes/editor-toolkit/round2-candidatekit/src/inputs.rs create mode 100644 spikes/editor-toolkit/round2-candidatekit/src/lib.rs create mode 100644 spikes/editor-toolkit/round2-candidatekit/src/outcome.rs create mode 100644 spikes/editor-toolkit/round2-candidatekit/src/report.rs create mode 100644 spikes/editor-toolkit/round2-candidatekit/src/scoring.rs create mode 100644 spikes/editor-toolkit/round2-candidatekit/tests/dependency_deny_list.rs diff --git a/spikes/editor-toolkit/Cargo.lock b/spikes/editor-toolkit/Cargo.lock index 9421b1b..79707e9 100644 --- a/spikes/editor-toolkit/Cargo.lock +++ b/spikes/editor-toolkit/Cargo.lock @@ -3807,6 +3807,27 @@ dependencies = [ "serde_json", ] +[[package]] +name = "round2-a11y-oracle" +version = "0.1.0" +dependencies = [ + "round2-textkit", + "serde", + "serde_json", + "unicode-normalization", + "unicode-segmentation", +] + +[[package]] +name = "round2-candidatekit" +version = "0.1.0" +dependencies = [ + "round2-diff", + "round2-textkit", + "serde", + "serde_json", +] + [[package]] name = "round2-diff" version = "0.1.0" diff --git a/spikes/editor-toolkit/Cargo.toml b/spikes/editor-toolkit/Cargo.toml index a5a539f..a1d4ed2 100644 --- a/spikes/editor-toolkit/Cargo.toml +++ b/spikes/editor-toolkit/Cargo.toml @@ -12,6 +12,8 @@ members = [ "round2-textkit", "round2-svgref", "round2-reference", + "round2-candidatekit", + "round2-a11y-oracle", ] # a11y-verifier is a standalone Python script (a11y-verifier/verify.py), not diff --git a/spikes/editor-toolkit/a11y-verifier/.gitignore b/spikes/editor-toolkit/a11y-verifier/.gitignore new file mode 100644 index 0000000..c18dd8d --- /dev/null +++ b/spikes/editor-toolkit/a11y-verifier/.gitignore @@ -0,0 +1 @@ +__pycache__/ diff --git a/spikes/editor-toolkit/a11y-verifier/test_verify.py b/spikes/editor-toolkit/a11y-verifier/test_verify.py new file mode 100644 index 0000000..cf6d607 --- /dev/null +++ b/spikes/editor-toolkit/a11y-verifier/test_verify.py @@ -0,0 +1,1230 @@ +"""Plain `unittest` coverage for `verify.classify` and +`verify.validate_expectations_file` — check 5's whole scoring logic and its +fail-closed artifact validation, isolated from any live AT-SPI bus. + +`verify.py`'s `gi.repository.Atspi` import happens inside `main()`, not at +module import time, specifically so this file can `import verify` and drive +`classify()` directly with synthetic `ObservedNode` trees — no bus, no +desktop, no display required. Run with: + + python3 -m unittest + +from this directory (`a11y-verifier/`). + +Every test constructs the *wrong* input for the property it names and +asserts the *specific* verdict/outcome that input must produce — never a +bare "FAIL" or "not PASS", because a classifier branch that silently +degrades to the wrong FAIL reason would pass a weaker test and is exactly +the kind of regression this file exists to catch. +""" +import unittest + +from verify import ( + EXPECTED_FIXTURE_IDS, + PLATFORM, + VISUAL_ORDER_TRAP, + ObservedNode, + Verdict, + _is_source_bearing_fragment, + classify, + validate_expectations_file, +) + + +def node(role, name, children=None): + """A synthetic `ObservedNode`, the same shape `walk_for_check5` builds + from a live AT-SPI tree.""" + return ObservedNode(role=role, name=name, children=list(children) if children else []) + + +def expectation( + expected_name, + accepted_roles=("label", "static", "text", "paragraph"), + prohibited_roles=("image", "canvas", "filler", "panel", "unknown"), + alternative_forms=None, + visual_order_name=None, + source_atoms=None, +): + """A synthetic fixture entry with the same shape + `round2-a11y-oracle/a11y_expectations.json` emits.""" + return { + "fixture_id": "F-TEST", + "expected_name": expected_name, + "expected_name_hex": expected_name.encode("utf-8").hex(), + "expected_name_byte_len": len(expected_name.encode("utf-8")), + "accepted_roles": list(accepted_roles), + "prohibited_roles": list(prohibited_roles), + "alternative_forms": alternative_forms or {}, + "visual_order_name": visual_order_name, + # D1: per-segment source atoms (`round2-a11y-oracle`'s + # `source_atoms`). Defaults to `None` (classify's own `.get(...) or + # []` treats that as no atoms), since most tests don't need one. + "source_atoms": source_atoms, + } + + +class ByteExactPass(unittest.TestCase): + def test_single_node_with_accepted_role_and_exact_name_passes(self): + exp = expectation("Allegro affettuoso — al fine") + verdict = classify(exp, [node("text", "Allegro affettuoso — al fine")]) + self.assertEqual(verdict.verdict, "PASS") + self.assertIsNone(verdict.prohibited_outcome) + self.assertEqual(verdict.observed_role, "text") + self.assertEqual(verdict.observed_name, exp["expected_name"]) + + def test_a_one_byte_difference_does_not_pass(self): + """Mutation guard: if byte comparison were replaced by e.g. a + case-insensitive or trimmed comparison, this would wrongly PASS.""" + exp = expectation("Allegro") + verdict = classify(exp, [node("text", "allegro")]) + self.assertNotEqual(verdict.verdict, "PASS") + + +class CompositionConcatenationPass(unittest.TestCase): + def test_two_segment_names_concatenated_in_tree_order_pass(self): + exp = expectation("Coro אבג") + # No single node carries the whole name — only the concatenation of + # two text descendants of a common parent, in tree/logical order, + # does. + run = node("frame", "", children=[node("text", "Coro "), node("text", "אבג")]) + verdict = classify(exp, [run]) + self.assertEqual(verdict.verdict, "PASS") + self.assertIsNone(verdict.prohibited_outcome) + self.assertEqual(verdict.observed_name, exp["expected_name"]) + + def test_concatenation_in_the_wrong_order_does_not_pass(self): + """Mutation guard: if concatenation order were unspecified (e.g. a + set instead of an ordered list), swapping the two nodes would still + wrongly PASS.""" + exp = expectation("Coro אבג") + run = node("frame", "", children=[node("text", "אבג"), node("text", "Coro ")]) + verdict = classify(exp, [run]) + self.assertNotEqual(verdict.verdict, "PASS") + + +class ProhibitedOutcomes(unittest.TestCase): + """One test per `round2_textkit::a11y::PROHIBITED_OUTCOMES` name.""" + + def test_absent_from_tree_when_no_candidate_node_is_found(self): + exp = expectation("Allegro") + verdict = classify(exp, []) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "absent-from-tree") + + def test_name_empty_is_distinct_from_absent_from_tree(self): + """§8.3: "name-empty ... absence wearing a role." A node is present + (unlike the absent-from-tree case above) but its name is the empty + string — these must classify differently.""" + exp = expectation("Allegro") + verdict = classify(exp, [node("label", "")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "name-empty") + + def test_name_normalized_matches_the_precommitted_nfc_form(self): + # F-E's case: source is NFD ("Cafe" + combining acute, "Café"), + # a wrong tree exposes the NFC "Café" ("Café", a *different* + # string byte-for-byte even though the two render identically) + # instead. Written with explicit \N escapes rather than the literal + # glyph so the two forms cannot be silently typed as the same string + # by accident — that mistake produced a false PASS here once already. + nfd = "Cafe\N{COMBINING ACUTE ACCENT}" + nfc = "Caf\N{LATIN SMALL LETTER E WITH ACUTE}" + self.assertNotEqual(nfd, nfc, "anchor: the two forms must be different strings") + exp = expectation( + nfd, + alternative_forms={"name-normalized": [nfc]}, + ) + verdict = classify(exp, [node("text", nfc)]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "name-normalized") + self.assertEqual(verdict.observed_name, nfc) + + def test_name_is_shaped_glyphs_matches_the_precommitted_ligature_form(self): + # F-A's case: the `ff` ligature collapses, so a wrong tree drops a + # letter relative to the source string. + exp = expectation( + "Allegro affettuoso — al fine", + alternative_forms={"name-is-shaped-glyphs": ["Allegro afettuoso — al fne"]}, + ) + verdict = classify(exp, [node("text", "Allegro afettuoso — al fne")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "name-is-shaped-glyphs") + + def test_name_drops_unresolved_codepoints_matches_the_precommitted_form(self): + # F-C's case: U+0627 is covered by no declared face and is dropped. + exp = expectation( + "Coro ا", + alternative_forms={"name-drops-unresolved-codepoints": ["Coro "]}, + ) + verdict = classify(exp, [node("static", "Coro ")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "name-drops-unresolved-codepoints") + + def test_a_correct_name_does_not_spuriously_match_an_alternative_form(self): + """Mutation guard: if alternative-form matching ran before the exact + match, a correct name would risk matching an alternative_forms entry + by accident and wrongly FAIL.""" + exp = expectation( + "Coro ا", + alternative_forms={"name-drops-unresolved-codepoints": ["Coro "]}, + ) + verdict = classify(exp, [node("static", "Coro ا")]) + self.assertEqual(verdict.verdict, "PASS") + + +class VisualOrderDiagnosis(unittest.TestCase): + def test_visual_order_concatenation_is_named_specifically(self): + # F-D's designed trap: a tree walking visual runs left to right + # reverses the embedded RTL segment's codepoint order. + exp = expectation( + "Allegro אבג con brio", + visual_order_name="Allegro גבא con brio", + ) + run = node( + "frame", + "", + children=[node("text", "Allegro "), node("text", "גבא"), node("text", " con brio")], + ) + verdict = classify(exp, [run]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, VISUAL_ORDER_TRAP) + self.assertEqual(verdict.observed_name, exp["visual_order_name"]) + + def test_visual_order_diagnosis_is_not_reported_as_a_generic_mismatch(self): + """Mutation guard: if the visual-order check were deleted, this + would still FAIL but with `prohibited_outcome=None` (the generic + fallback) instead of the specific diagnosis — asserting the exact + name, not just FAIL, is what catches that.""" + exp = expectation( + "Allegro אבג con brio", + visual_order_name="Allegro גבא con brio", + ) + run = node( + "frame", + "", + children=[node("text", "Allegro "), node("text", "גבא"), node("text", " con brio")], + ) + verdict = classify(exp, [run]) + self.assertIsNotNone(verdict.prohibited_outcome) + self.assertNotEqual(verdict.prohibited_outcome, "absent-from-tree") + self.assertNotEqual(verdict.prohibited_outcome, "name-empty") + + +class ProhibitedRole(unittest.TestCase): + def test_a_prohibited_role_fails_even_with_the_exact_name(self): + exp = expectation("Allegro") + verdict = classify(exp, [node("canvas", "Allegro")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.observed_role, "canvas") + # This is a role-vocabulary failure (§8.2), not one of the five + # name-transformation PROHIBITED_OUTCOMES (§8.3) — it must not be + # reported as one. + self.assertIsNone(verdict.prohibited_outcome) + self.assertEqual( + verdict.reason, + "a node's name matches expected_name byte-for-byte, but its role 'canvas' is in the " + "at-spi2 prohibited set (no accepted-role node also carries it)", + ) + + def test_an_accepted_role_with_the_exact_name_is_not_penalized(self): + """Mutation guard: confirms the previous test is actually exercising + the role check, not some other reason that name would fail.""" + exp = expectation("Allegro") + verdict = classify(exp, [node("label", "Allegro")]) + self.assertEqual(verdict.verdict, "PASS") + + def test_an_unlisted_role_carrying_the_exact_name_is_not_absent_from_tree(self): + """A role outside `accepted_roles | prohibited_roles` (e.g. `push + button`) is never a text-*candidate* for the composition/single-node + role checks — `_flatten_candidates`/`_subtree_contributors` exclude + it. But a node under that role can still carry the run's exact + text, and §8.3's absent-from-tree ("the default outcome for a + toolkit that draws to a canvas and stops") does not describe that: + the run *is* in the tree. This must FAIL naming the actual observed + role, not report absent-from-tree — conflating "wrong role" with + "nothing there at all" would hide evidence a real candidate + produced.""" + exp = expectation("Allegro") + verdict = classify(exp, [node("push button", "Allegro")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertNotEqual(verdict.prohibited_outcome, "absent-from-tree") + self.assertEqual(verdict.observed_role, "push button") + self.assertEqual(verdict.observed_name, "Allegro") + + def test_a_truly_empty_tree_is_still_absent_from_tree(self): + """The companion case to the one above, pinned side by side so a + regression that merges the two back together (e.g. by making the + new unlisted-role scan fire unconditionally) is caught: with + nothing in the tree at all, the outcome must still be + `absent-from-tree`.""" + exp = expectation("Allegro") + verdict = classify(exp, []) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "absent-from-tree") + + def test_an_unlisted_role_carrying_an_alternative_form_is_also_not_absent_from_tree(self): + """The whole-tree scan must check precommitted alternative forms + too, not only `expected_name` — a candidate that shaped the name + wrong *and* exposed it under an unlisted role has still put the + (wrong) text in the tree, which is a different, more specific, + finding than "nothing is there.""" + exp = expectation( + "Coro ا", + alternative_forms={"name-drops-unresolved-codepoints": ["Coro "]}, + ) + verdict = classify(exp, [node("push button", "Coro ")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertNotEqual(verdict.prohibited_outcome, "absent-from-tree") + self.assertEqual(verdict.observed_role, "push button") + self.assertEqual(verdict.observed_name, "Coro ") + + def test_an_unlisted_role_carrying_the_visual_order_form_is_also_not_absent_from_tree(self): + exp = expectation( + "Allegro אבג con brio", + visual_order_name="Allegro גבא con brio", + ) + verdict = classify(exp, [node("push button", "Allegro גבא con brio")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertNotEqual(verdict.prohibited_outcome, "absent-from-tree") + self.assertEqual(verdict.observed_role, "push button") + + def test_an_unlisted_role_carrying_an_unrelated_name_is_still_absent_from_tree(self): + """Mutation guard: the whole-tree scan must only match a name + against `expected_name`/alternative forms/`visual_order_name` — a + node with an unlisted role and completely unrelated text must not + rescue the verdict away from absent-from-tree either.""" + exp = expectation("Allegro") + verdict = classify(exp, [node("push button", "something unrelated")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "absent-from-tree") + + +class MultipleFormsPerOutcome(unittest.TestCase): + """O2: one outcome can carry more than one precommitted rendering — + `round2-a11y-oracle` gives `name-is-shaped-glyphs` both a + cluster-collapse form and a ligature presentation-form substitution for + F-A. Either one observed must classify the same outcome.""" + + def _f_a_like_expectation(self): + return expectation( + "Allegro affettuoso — al fine", + alternative_forms={ + "name-is-shaped-glyphs": [ + "Allegro afettuoso — al fne", + "Allegro a\N{LATIN SMALL LIGATURE FF}ettuoso — al \N{LATIN SMALL LIGATURE FI}ne", + ] + }, + ) + + def test_the_cluster_collapse_form_classifies(self): + verdict = classify( + self._f_a_like_expectation(), [node("text", "Allegro afettuoso — al fne")] + ) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "name-is-shaped-glyphs") + + def test_the_presentation_form_classifies_the_same_outcome(self): + presentation = ( + "Allegro a\N{LATIN SMALL LIGATURE FF}ettuoso — al \N{LATIN SMALL LIGATURE FI}ne" + ) + verdict = classify(self._f_a_like_expectation(), [node("text", presentation)]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "name-is-shaped-glyphs") + + def test_a_third_string_matches_neither_form(self): + """Mutation guard: confirms the two tests above are matching a + specific listed string, not merely "anything different from + expected_name" — if list matching degenerated to that, this would + wrongly report name-is-shaped-glyphs too.""" + verdict = classify( + self._f_a_like_expectation(), + [node("text", "something else entirely")], + ) + self.assertNotEqual(verdict.prohibited_outcome, "name-is-shaped-glyphs") + + +class SubtreeScopedComposition(unittest.TestCase): + """B1: composition is scored against one run subtree's own descendants, + never the whole application flattened into one list. These are the two + cases the coordinator reproduced against the pre-B1 flat classifier: + + - `[("canvas", "Coro "), ("text", "אבג")]` wrongly PASSed. + - `[("text", "Coro "), ("text", "אבג"), ("label", "MyApp Window")]` + wrongly FAILed, even though `label` is an accepted at-spi2 role that + every real application's window frame carries, elsewhere in the tree. + """ + + def _f_b_like_expectation(self): + return expectation("Coro אבג") + + def test_a_prohibited_role_sibling_in_the_same_run_subtree_fails_with_the_role_named(self): + """The false-PASS case (B1's first reproduction), expressed as a + real tree: `canvas` and `text` are siblings under one run subtree — + together they still spell out expected_name byte-for-byte, but a + `canvas` contributed to it, which must FAIL, naming `canvas`, not + PASS.""" + run = node("frame", "", children=[node("canvas", "Coro "), node("text", "אבג")]) + verdict = classify(self._f_b_like_expectation(), [run]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.observed_role, "canvas") + self.assertEqual(verdict.observed_name, "Coro אבג") + self.assertIn("canvas", verdict.reason) + self.assertIn("otherwise-correct composition", verdict.reason) + # Not one of the five PROHIBITED_OUTCOMES names — this is a §8.2 + # role-vocabulary failure inside a composition, not a §8.3 name + # transformation. + self.assertIsNone(verdict.prohibited_outcome) + + def test_an_unrelated_accepted_role_node_elsewhere_does_not_poison_a_correct_composition(self): + """The false-FAIL case (B1's second reproduction), expressed as a + real tree: the run's own two text nodes are correctly grouped under + their own subtree; an unrelated `label` (an *accepted* at-spi2 role + — every real window frame carries one) sits elsewhere in the same + application. The label must not be able to corrupt the run's own, + otherwise-correct, composition into a FAIL.""" + application = node( + "frame", + "", + children=[ + node("group", "", children=[node("text", "Coro "), node("text", "אבג")]), + node("label", "MyApp Window"), + ], + ) + verdict = classify(self._f_b_like_expectation(), [application]) + self.assertEqual(verdict.verdict, "PASS") + self.assertIsNone(verdict.prohibited_outcome) + self.assertEqual(verdict.observed_name, "Coro אבג") + + def test_the_legitimate_accepted_role_split_still_passes_when_it_is_the_whole_tree(self): + """Sanity companion to the two reproductions above: a run correctly + split across two accepted-role text nodes, with nothing else in the + tree at all, must still PASS — B1's fix must not have become so + conservative that it stopped recognizing the ordinary case.""" + run = node("paragraph", "", children=[node("text", "Coro "), node("text", "אבג")]) + verdict = classify(self._f_b_like_expectation(), [run]) + self.assertEqual(verdict.verdict, "PASS") + self.assertIsNone(verdict.prohibited_outcome) + + def test_a_prohibited_contributor_is_named_even_when_a_correct_subtree_exists_elsewhere(self): + """The composition scan must not let a PASS found in one subtree + erase evidence of a bad contributor found in *another* — but it must + still prefer reporting the PASS overall, since a candidate that gets + it right anywhere in a legitimate run subtree has satisfied §8.1. + This test pins the reverse: when NO subtree passes cleanly, the + reported reason must name the actual bad contributor, not a generic + mismatch.""" + run = node( + "frame", + "", + children=[node("canvas", "Coro "), node("text", "אבג")], + ) + verdict = classify(self._f_b_like_expectation(), [run]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.observed_role, "canvas") + self.assertNotIn("no node or composition matched", verdict.reason) + + def test_a_composition_that_silently_dropped_a_prohibited_contributor_would_be_wrong(self): + """Guards the specific failure mode B1 warns against: a subtree must + not PASS by concatenating only its *accepted*-role contributors and + ignoring a prohibited one. Here, dropping the `canvas` node would + leave just `"אבג"`, which does not equal expected_name either — so + this also confirms the concatenation includes every contributor's + name, not a filtered subset, before the role check ever runs.""" + run = node("frame", "", children=[node("canvas", "Coro "), node("text", "אבג")]) + verdict = classify(self._f_b_like_expectation(), [run]) + # If contributors had been filtered to accepted-only before + # concatenating, the concat would be "אבג" (not expected_name), and + # this subtree would be silently skipped rather than FAILed with a + # named reason — falling through to a *weaker* diagnosis than the + # sharp one B1 requires. + self.assertEqual(verdict.verdict, "FAIL") + self.assertIn("canvas", verdict.reason) + + +class ContainerNamingDoesNotChangeBlame(unittest.TestCase): + """An empty-named structural wrapper (a `frame` around the real + contributors, exposing no name of its own) must never be blamed for a + bad composition. It contributes zero bytes to the concatenation, so it + cannot be what made the composition wrong; blaming it hides the actual + offender — here, a *prohibited*-role `canvas` that carried half the + run, which is the real §8.2 violation the report exists to name. + + Same tree content in all three shapes below; only the container's own + name (or its absence) differs. All three must name `canvas`.""" + + def _f_b_like_expectation(self): + return expectation("Coro אבג") + + def test_canvas_and_text_inside_an_unnamed_frame_blames_canvas(self): + tree = node( + "application", + "p", + children=[node("frame", "", children=[node("canvas", "Coro "), node("text", "אבג")])], + ) + verdict = classify(self._f_b_like_expectation(), [tree]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.observed_role, "canvas") + + def test_canvas_and_text_inside_a_named_frame_blames_canvas(self): + tree = node( + "application", + "p", + children=[ + node("frame", "MyApp", children=[node("canvas", "Coro "), node("text", "אבג")]) + ], + ) + verdict = classify(self._f_b_like_expectation(), [tree]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.observed_role, "canvas") + + def test_canvas_and_text_as_direct_siblings_blames_canvas(self): + tree = node( + "application", + "p", + children=[node("canvas", "Coro "), node("text", "אבג")], + ) + verdict = classify(self._f_b_like_expectation(), [tree]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.observed_role, "canvas") + + def test_an_unlisted_but_not_prohibited_contributor_is_still_named_when_no_prohibited_one_exists( + self, + ): + """The weaker fallback branch stays reachable: with no + prohibited-role contributor at all, an unlisted-role one is still + named (not silently dropped just because it is the weaker case). + Nested under an unnamed wrapper, so this test also isolates the + empty-name exclusion on its own: with no prohibited contributor + present, the "prefer prohibited" rule cannot be what saves this + case from blaming the wrapper — only excluding the empty-named + `frame` from the contributor set can. A mutation that deleted the + empty-name exclusion (but kept the prohibited-preference) would + wrongly blame `frame` here, even though the same mutation happens + to survive the two `blames_canvas` tests above (where a prohibited + `canvas` is also present and the preference rule alone rescues + them).""" + tree = node( + "application", + "p", + children=[ + node( + "frame", "", children=[node("push button", "Coro "), node("text", "אבג")] + ) + ], + ) + verdict = classify(self._f_b_like_expectation(), [tree]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.observed_role, "push button") + self.assertIn("neither accepted nor prohibited", verdict.reason) + + def test_a_prohibited_contributor_is_preferred_over_an_unlisted_one_listed_first(self): + """Isolates the "prefer prohibited" rule specifically, with no + empty-named node anywhere in the tree: an unlisted-role + (`push button`) contributor is listed *before* a prohibited-role + (`canvas`) one, both non-empty-named. Naming "the first non-accepted + contributor" (no preference) would wrongly blame `push button`; only + the explicit prohibited-preference blames `canvas`.""" + tree = node( + "frame", + "", + children=[node("push button", "Coro "), node("canvas", "אבג")], + ) + verdict = classify(self._f_b_like_expectation(), [tree]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.observed_role, "canvas") + self.assertIn("prohibited", verdict.reason) + + def test_a_correct_split_run_nested_under_an_unnamed_container_still_passes(self): + """Confirms excluding empty-named nodes does not disturb a + legitimate PASS: the concatenation is unchanged whether the + empty-named wrapper is included or excluded (it contributes zero + bytes either way), so a correct split run nested under one must + still pass.""" + tree = node( + "application", + "p", + children=[node("frame", "", children=[node("text", "Coro "), node("text", "אבג")])], + ) + verdict = classify(self._f_b_like_expectation(), [tree]) + self.assertEqual(verdict.verdict, "PASS") + + +class ExactNamePrecedence(unittest.TestCase): + """C1: exact-name matching must evaluate every node before deciding, + never return on the first match — an accepted-role node carrying + expected_name wins regardless of where in the tree it sits, even when a + prohibited-role node carrying the *same* exact name is listed first.""" + + @staticmethod + def _forest(canvas_first): + canvas = node("canvas", "Coro אבג") + text = node("text", "Coro אבג") + return [canvas, text] if canvas_first else [text, canvas] + + def test_prohibited_role_node_listed_first_still_passes(self): + exp = expectation("Coro אבג") + verdict = classify(exp, self._forest(canvas_first=True)) + self.assertEqual(verdict.verdict, "PASS") + self.assertEqual(verdict.observed_role, "text") + self.assertIsNone(verdict.prohibited_outcome) + + def test_accepted_role_node_listed_first_still_passes(self): + exp = expectation("Coro אבג") + verdict = classify(exp, self._forest(canvas_first=False)) + self.assertEqual(verdict.verdict, "PASS") + self.assertEqual(verdict.observed_role, "text") + self.assertIsNone(verdict.prohibited_outcome) + + def test_both_orderings_of_the_forest_produce_the_same_verdict(self): + """The direct C1 reproduction: the coordinator measured opposite + verdicts for the two orderings of this exact forest. Pinned here as + one assertion comparing both `classify` calls, not two independently + hand-written expectations that could each be individually wrong in + the same direction.""" + exp = expectation("Coro אבג") + v_canvas_first = classify(exp, self._forest(canvas_first=True)) + v_text_first = classify(exp, self._forest(canvas_first=False)) + self.assertEqual(v_canvas_first.verdict, v_text_first.verdict) + self.assertEqual(v_canvas_first.observed_role, v_text_first.observed_role) + self.assertEqual(v_canvas_first.prohibited_outcome, v_text_first.prohibited_outcome) + self.assertEqual(v_canvas_first.verdict, "PASS") + + def test_without_any_accepted_role_match_the_prohibited_one_still_fails(self): + """Mutation guard: confirms the PASSes above happen *because* an + accepted-role match exists, not because exact-name matching became + unconditional PASS — with only the prohibited-role node present, + this must still FAIL.""" + exp = expectation("Coro אבג") + verdict = classify(exp, [node("canvas", "Coro אבג")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.observed_role, "canvas") + + +class SubtreeScopedDiagnoses(unittest.TestCase): + """C2: the alternative-form and visual-order diagnoses are scored per + subtree, exactly like composition (B1) — an unrelated accepted-role node + elsewhere in the application (e.g. a window `label`) must not poison a + legitimate subtree's diagnosis into a generic mismatch.""" + + def test_visual_order_trap_is_found_despite_an_unrelated_window_label(self): + """The direct C2 reproduction: F-D's designed visual-order trap, + with an unrelated `label` elsewhere in the application.""" + exp = expectation( + "Allegro אבג con brio", + visual_order_name="Allegro גבא con brio", + ) + application = node( + "application", + "p", + children=[ + node("label", "MyApp Window"), + node( + "frame", + "", + children=[ + node("text", "Allegro "), + node("text", "גבא"), + node("text", " con brio"), + ], + ), + ], + ) + verdict = classify(exp, [application]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, VISUAL_ORDER_TRAP) + self.assertNotIn("no node or composition matched", verdict.reason) + + def test_alternative_form_composition_is_found_despite_an_unrelated_window_label(self): + """The same poisoning bug, for an alternative-form composition (not + visual-order) — the ligature-collapse form split across two text + nodes, so this exercises the *subtree-concatenation* alt-form path + specifically, not the already-order-independent single-node one.""" + exp = expectation( + "Allegro affettuoso — al fine", + alternative_forms={"name-is-shaped-glyphs": ["Allegro afettuoso — al fne"]}, + ) + application = node( + "application", + "p", + children=[ + node("label", "MyApp Window"), + node( + "frame", + "", + children=[ + node("text", "Allegro afettuoso — al "), + node("text", "fne"), + ], + ), + ], + ) + verdict = classify(exp, [application]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "name-is-shaped-glyphs") + self.assertNotIn("no node or composition matched", verdict.reason) + + def test_without_the_unrelated_label_the_same_composition_still_matches(self): + """Mutation guard: confirms the two tests above are really about the + label not poisoning the result, not about some other property of + the tree shape — remove the label and the same diagnosis must still + fire.""" + exp = expectation( + "Allegro אבג con brio", + visual_order_name="Allegro גבא con brio", + ) + frame = node( + "frame", + "", + children=[node("text", "Allegro "), node("text", "גבא"), node("text", " con brio")], + ) + verdict = classify(exp, [frame]) + self.assertEqual(verdict.prohibited_outcome, VISUAL_ORDER_TRAP) + + +class UnlistedRoleComposition(unittest.TestCase): + """C3: text composed across two or more unlisted-role descendants must + not be misreported as absent-from-tree — the same per-subtree + composition scoring applied to roles in neither `accepted_roles` nor + `prohibited_roles`.""" + + def test_two_unlisted_role_nodes_composing_the_exact_name_is_not_absent(self): + """The direct C3 reproduction: two `push button` nodes whose + concatenation is the run's exact text.""" + exp = expectation("Coro אבג") + application = node( + "application", + "p", + children=[node("push button", "Coro "), node("push button", "אבג")], + ) + verdict = classify(exp, [application]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertNotEqual(verdict.prohibited_outcome, "absent-from-tree") + self.assertEqual(verdict.observed_name, "Coro אבג") + + def test_unlisted_role_composition_with_unrelated_text_is_still_absent(self): + """The composition-scan analogue of the single-node absent-from-tree + guard: two unlisted-role nodes whose concatenation is *not* the + run's text, an alternative form, or visual_order_name, must still + classify as genuine absence — the scan must not over-fire just + because *some* unlisted-role composition exists.""" + exp = expectation("Coro אבג") + application = node( + "application", + "p", + children=[node("push button", "something"), node("push button", "unrelated")], + ) + verdict = classify(exp, [application]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "absent-from-tree") + + +class AbsentFromTreeVsMisorderedFragments(unittest.TestCase): + """User ruling, following C3: a permutation probe found that reversing + the same two contributors gave `absent-from-tree` under unlisted roles + but a plain generic mismatch under accepted roles — two different named + outcomes for the same underlying defect (contributors in the wrong + order). Three cases, side by side, each asserting its own distinct + outcome, so a future change cannot silently re-merge them. + + **Behaviour change, reported explicitly (not adjusted quietly), from the + reorder that moved source-bearing detection to run before absence/ + name-empty (the very next ruling in this same sequence):** case 1 below + used to assert "unchanged, a generic composition FAIL" — that was true + only because the fragment scan, at the time, ran solely inside the + `flat_candidates`-empty branch and so never even looked at accepted-role + contributors. Once source-bearing detection (fragments included) was + unified to run over *every* role unconditionally, the identical + reversed-`text` case is now *also* caught by the fragment scan, with a + more specific message than the old generic mismatch — which is the + intended, uniform consequence of "misordered source fragments -> + composition/role failure, never absence" applying without a role + exception. Nothing about *this* file's tests silently changed; the + updated assertion below is that report. + + 1. reversed **accepted**-role contributors — a role/composition FAIL + (fragment-scan diagnosis, naming the fragments), never + `absent-from-tree`; + 2. reversed **unlisted**-role contributors — the original fix: a + role/composition FAIL, never `absent-from-tree`; + 3. the true-absence control — nothing resembling the run's text + anywhere — still reaches `absent-from-tree`, proving the fix + narrowed the bug without making the outcome unreachable. + """ + + def test_reversed_accepted_role_contributors_are_a_composition_failure_not_absence(self): + """Behaviour change (see class docstring): this used to assert a + generic mismatch (`prohibited_outcome=None`, "no node or + composition matched..."). It now asserts the fragment-scan + diagnosis — still `prohibited_outcome=None` (a §8.2 role/composition + failure, not a §8.3 PROHIBITED_OUTCOMES name), but a more specific + reason, because the fragment scan is no longer gated to unlisted + roles only.""" + exp = expectation("Coro אבג") + run = node("frame", "", children=[node("text", "אבג"), node("text", "Coro ")]) + verdict = classify(exp, [run]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertIsNone(verdict.prohibited_outcome) + self.assertNotEqual(verdict.prohibited_outcome, "absent-from-tree") + self.assertIn("role/composition failure", verdict.reason) + + def test_reversed_unlisted_role_contributors_are_a_role_failure_not_absence(self): + """Item 2, the fix itself — the coordinator's exact reproduction: + the identical shape, under unlisted roles, must NOT be + `absent-from-tree`. The text is genuinely present; only the order + (and the role) is wrong.""" + exp = expectation("Coro אבג") + application = node( + "application", + "p", + children=[node("push button", "אבג"), node("push button", "Coro ")], + ) + verdict = classify(exp, [application]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertNotEqual(verdict.prohibited_outcome, "absent-from-tree") + # Not a §8.3 PROHIBITED_OUTCOMES name either — this is the §8.2 + # role/composition failure category, the same as C3's other cases. + self.assertIsNone(verdict.prohibited_outcome) + + def test_true_absence_is_still_reachable(self): + """Item 1, the control: with nothing resembling the run's text + anywhere, `absent-from-tree` must still fire — proving the fix + narrowed the bug rather than making the outcome unreachable.""" + exp = expectation("Coro אבג") + verdict = classify(exp, []) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "absent-from-tree") + + +class SourceBearingFragmentGuards(unittest.TestCase): + """Prove `_is_source_bearing_fragment` (C3's fix) is not loose enough to + make `absent-from-tree` unreachable in practice — the user's own stated + concern: "a rule loose enough that an application's ordinary window + label counts as a fragment would make absent-from-tree unreachable in + practice, which is a worse failure than the one being fixed." Each + guard below is the specific scenario that concern describes.""" + + def test_a_single_shared_character_is_not_a_fragment(self): + """A one-character coincidence — "o" appears in both "Coro" and + almost any ordinary English text — must not count; this is exactly + the case the length-2 floor exists to exclude.""" + self.assertFalse(_is_source_bearing_fragment("o", {"Coro אבג"})) + + def test_an_ordinary_window_label_is_not_a_fragment(self): + """The user's own example, direct: a whole, realistic window title + is longer than (and unrelated to) the run's short text, so it can + never be a literal substring of it.""" + self.assertFalse(_is_source_bearing_fragment("MyApp Window", {"Coro אבג"})) + + def test_a_whitespace_only_name_is_not_a_fragment(self): + self.assertFalse(_is_source_bearing_fragment(" ", {"Coro אבג"})) + + def test_an_empty_name_is_not_a_fragment(self): + self.assertFalse(_is_source_bearing_fragment("", {"Coro אבג"})) + + def test_a_two_character_real_fragment_does_count(self): + """Anchors the floor at exactly two characters, not three or more — + confirms the guards above are testing the length-1 boundary + specifically, not merely "short strings never match".""" + self.assertTrue(_is_source_bearing_fragment("בג", {"Coro אבג"})) + + def test_an_ordinary_window_label_does_not_rescue_a_tree_from_absence_end_to_end(self): + """The end-to-end version of the guard above: a real, unrelated, + realistic window label under an unlisted role, with nothing else in + the tree, must still classify as absent-from-tree.""" + exp = expectation("Coro אבג") + application = node( + "application", + "p", + # An unlisted role, not "label" (which is an accepted at-spi2 + # role and would exit the C3 branch this test is about via the + # ordinary accepted-role path instead). + children=[node("push button", "MyApp Window")], + ) + verdict = classify(exp, [application]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "absent-from-tree") + + +class FCTwoSegmentComposition(unittest.TestCase): + """D1/D2 regression group, required together — all four reproduce + against F-C's real shape (`"Coro "` + `"ا"`, with `"Coro "` also being + F-C's own precommitted `name-drops-unresolved-codepoints` form) and must + all hold simultaneously: + + unlisted "ا" for F-C -> NOT absent-from-tree + unrelated single ASCII "o" -> still NOT source-bearing + F-C accepted split ["Coro ", "ا"] -> PASS + F-C lone accepted node named exactly "Coro " -> still name-drops-unresolved-codepoints + + The first two prove D1 (an unresolved segment can be a single character, + which the length-2 substring rule alone cannot catch, but the + coincidence guard must still hold); the last two prove D2 (a legitimate + two-node split now PASSes despite the first segment alone matching a + precommitted alternative form, and the single-node case — which has no + composition to find — still names that outcome exactly as before). + """ + + def _f_c_like_expectation(self): + return expectation( + "Coro ا", + alternative_forms={"name-drops-unresolved-codepoints": ["Coro "]}, + source_atoms=["Coro ", "ا"], + ) + + def test_unlisted_role_carrying_f_cs_unresolved_segment_is_not_absent_from_tree(self): + """D1, the reported finding: F-C's unresolved segment `ا` is a + single character — below `_is_source_bearing_fragment`'s length-2 + floor — but it is still a precommitted `source_atoms` entry, so a + node carrying it under an unlisted role must be a role/composition + failure, never absence. §8.3: "the accessibility tree carries the + text, not the ink".""" + exp = self._f_c_like_expectation() + verdict = classify(exp, [node("push button", "ا")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertNotEqual(verdict.prohibited_outcome, "absent-from-tree") + self.assertEqual(verdict.observed_role, "push button") + + def test_an_unrelated_single_ascii_character_is_still_not_source_bearing(self): + """Mutation guard, D1: confirms the atom-matching path is additive + and precommitted, not a blanket "any single character counts" rule + — an unrelated stray `"o"` (present in `"Coro"` only by coincidence, + and not a `source_atoms` entry) must still not rescue the tree from + absence, exactly as `_is_source_bearing_fragment`'s own coincidence + guard already requires on its own.""" + exp = self._f_c_like_expectation() + verdict = classify(exp, [node("push button", "o")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "absent-from-tree") + + def test_the_legitimate_two_node_split_passes(self): + """D2, the fix itself — the coordinator's exact reproduction: two + ACCEPTED-role text nodes, one per direction run (exactly what §8.1 + permits: "a tree that exposes one text node per direction run is + not wrong"), must PASS even though the first segment alone happens + to equal F-C's own precommitted `name-drops-unresolved-codepoints` + form.""" + exp = self._f_c_like_expectation() + run = node("frame", "", children=[node("text", "Coro "), node("text", "ا")]) + verdict = classify(exp, [run]) + self.assertEqual(verdict.verdict, "PASS") + self.assertIsNone(verdict.prohibited_outcome) + + def test_a_lone_accepted_node_named_exactly_coro_is_still_the_named_outcome(self): + """The required guard proving D2's fix did not simply disable the + alternative-form diagnosis: with no second node, there is no + composition to find, so a single `text:"Coro "` node must still be + classified by name, exactly as before D2.""" + exp = self._f_c_like_expectation() + verdict = classify(exp, [node("text", "Coro ")]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "name-drops-unresolved-codepoints") + + +class AbsentFromTreeVsNameEmpty(unittest.TestCase): + """User ruling: `absent-from-tree` must be gated on *no source-bearing + text existing anywhere*, not on *no accepted-or-prohibited-role + candidate existing* — the latter gate is nearly always false for a real + application (a window `label` alone defeats it), which made + `absent-from-tree` — §8.3's own words, "the one this check will most + likely actually catch" — effectively unreachable in practice. + + The pinned precedence: + + 1. Source-bearing text or a precommitted form present anywhere -> + classify its name/role/composition outcome. This runs first, over + the whole forest, any role — before any absence/empty-name + determination. + 2. Otherwise, `name-empty` only when both hold: at least one + **accepted**-role candidate node exists, and every + accepted-or-prohibited-role candidate's name is empty. + 3. Every other no-source-bearing case -> `absent-from-tree`. + + The intended taxonomy, and the required regression lock: four cases, + side by side, so a future change cannot re-merge them. + + unrelated UI text only -> absent-from-tree + empty prohibited canvas only -> absent-from-tree + empty accepted text/label node -> name-empty + misordered source fragments -> composition/role failure, never absence + + The distinction being preserved: `name-empty` means an attempted + static-text exposure without a name; `absent-from-tree` covers + drawing-only or unrelated trees. A prohibited-role empty node is not + "wearing a role" in §8.3's sense — it is the draw-and-stop case. + """ + + def test_unrelated_ui_text_only_is_absent_from_tree(self): + """The coordinator's exact reproduction: ordinary application + chrome — a button, a window label — none of it related to the run. + Every real application has role-listed nodes like this, which is + exactly why the old "no candidate exists anywhere" gate made + `absent-from-tree` nearly unreachable.""" + exp = expectation("Coro אבג") + application = node( + "application", + "p", + children=[node("push button", "Save"), node("label", "MyApp Window")], + ) + verdict = classify(exp, [application]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "absent-from-tree") + + def test_empty_prohibited_canvas_only_is_absent_from_tree(self): + """The case that matters most (§8.3's own words): a toolkit that + drew to a canvas and stopped. A `canvas` node exposing no name is + not "wearing a role" in §8.3's name-empty sense — with no + accepted-role candidate anywhere in the tree, this is the + draw-and-stop case, `absent-from-tree`, not `name-empty`.""" + exp = expectation("Coro אבג") + application = node("frame", "MyApp", children=[node("canvas", "")]) + verdict = classify(exp, [application]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "absent-from-tree") + + def test_empty_accepted_text_node_is_name_empty(self): + """The companion case: an *accepted*-role node attempting to expose + static text, but with no name — this is the genuine name-empty + case, "absence wearing a role".""" + exp = expectation("Coro אבג") + application = node("frame", "MyApp", children=[node("label", "")]) + verdict = classify(exp, [application]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertEqual(verdict.prohibited_outcome, "name-empty") + + def test_misordered_source_fragments_are_a_composition_failure_not_absence_or_empty(self): + """The fourth leg: fragments of the run's actual text, present but + in the wrong order, must never be classified as absence or + name-empty — a composition/role failure, per item 1's precedence + over items 2 and 3.""" + exp = expectation("Coro אבג") + application = node( + "application", + "p", + children=[node("push button", "אבג"), node("push button", "Coro ")], + ) + verdict = classify(exp, [application]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertNotEqual(verdict.prohibited_outcome, "absent-from-tree") + self.assertNotEqual(verdict.prohibited_outcome, "name-empty") + + def test_an_accepted_node_with_real_text_alongside_an_empty_one_is_not_name_empty(self): + """Mutation guard: `name-empty` requires *every* candidate to be + empty, not merely *an* accepted-role candidate existing — an + accepted node with real (if unrelated) text sitting alongside an + empty one must not trigger name-empty, since not every + text-candidate node is actually empty.""" + exp = expectation("Coro אבג") + application = node( + "frame", + "MyApp", + children=[node("label", "Random"), node("label", "")], + ) + verdict = classify(exp, [application]) + self.assertEqual(verdict.verdict, "FAIL") + self.assertNotEqual(verdict.prohibited_outcome, "name-empty") + + +class ExpectationsFileValidation(unittest.TestCase): + """O1/B2: the loader must fail closed on a malformed or stale oracle + before any live AT-SPI readback — never a FAIL, always a usage error + that the caller (`run_check5`) turns into exit 2.""" + + VALID_DIGEST = "deadbeef" * 8 # a plausible-looking 64-hex-char sha256 + + @staticmethod + def _fixture(fixture_id, name, alternative_forms=None, source_atoms=None): + return { + "fixture_id": fixture_id, + "expected_name": name, + "expected_name_hex": name.encode("utf-8").hex(), + "expected_name_byte_len": len(name.encode("utf-8")), + "accepted_roles": ["label", "static", "text", "paragraph"], + "prohibited_roles": ["image", "canvas", "filler", "panel", "unknown"], + # Defaults to one atom equal to the whole name (trivially + # satisfies the join-equals-name invariant) — tests of other + # fields don't need more than one segment. + "source_atoms": source_atoms if source_atoms is not None else [name], + "alternative_forms": alternative_forms or {}, + } + + def _valid_file(self): + """A fully self-consistent, five-fixture file — every B2/O1/D1 check + passes against this by construction. Each test below mutates + exactly one thing away from it, so a raised error is attributable to + the one defect under test rather than an incidental other one.""" + return { + "contract": "spec/CONTRACT_EDITOR_T4_SPIKE.md pin 13", + "recipe": "spikes/editor-toolkit/ROUND2_TEXT_RECIPE.md §8", + "platform": PLATFORM, + "source_fixtures_digest": self.VALID_DIGEST, + "fixtures": [ + self._fixture("F-A", "Allegro affettuoso — al fine"), + self._fixture("F-B", "Coro אבג", source_atoms=["Coro ", "אבג"]), + self._fixture("F-C", "Coro ا", source_atoms=["Coro ", "ا"]), + self._fixture( + "F-D", "Allegro אבג con brio", source_atoms=["Allegro ", "אבג", " con brio"] + ), + self._fixture("F-E", "Café"), + ], + } + + def _validate(self, file): + validate_expectations_file( + file, expected_platform=PLATFORM, expected_source_digest=self.VALID_DIGEST + ) + + # ---- baseline ---- + + def test_a_fully_valid_file_is_accepted(self): + self._validate(self._valid_file()) # must not raise + + # ---- platform ---- + + def test_a_wrong_platform_is_refused(self): + bad = self._valid_file() + bad["platform"] = "aria" + with self.assertRaises(ValueError) as ctx: + self._validate(bad) + msg = str(ctx.exception) + self.assertIn("aria", msg) + self.assertIn(PLATFORM, msg) + + # ---- source_fixtures_digest (B2) ---- + + def test_a_stale_source_digest_is_refused(self): + bad = self._valid_file() + bad["source_fixtures_digest"] = "stale" + "0" * 60 + with self.assertRaises(ValueError) as ctx: + self._validate(bad) + msg = str(ctx.exception) + self.assertIn("stale", msg) + self.assertIn(self.VALID_DIGEST, msg) + + # ---- fixture id completeness/uniqueness ---- + + def test_a_duplicate_fixture_id_is_refused(self): + bad = self._valid_file() + bad["fixtures"].append(self._fixture("F-A", "duplicate")) + with self.assertRaises(ValueError) as ctx: + self._validate(bad) + self.assertIn("duplicate", str(ctx.exception).lower()) + self.assertIn("F-A", str(ctx.exception)) + + def test_a_missing_fixture_is_refused(self): + bad = self._valid_file() + bad["fixtures"] = [f for f in bad["fixtures"] if f["fixture_id"] != "F-E"] + with self.assertRaises(ValueError) as ctx: + self._validate(bad) + self.assertIn("F-E", str(ctx.exception)) + + def test_an_extra_fixture_id_is_refused(self): + bad = self._valid_file() + bad["fixtures"].append(self._fixture("F-Z", "unexpected")) + with self.assertRaises(ValueError) as ctx: + self._validate(bad) + self.assertIn("F-Z", str(ctx.exception)) + + def test_the_expected_fixture_id_set_is_exactly_f_a_through_f_e(self): + """Anchors `EXPECTED_FIXTURE_IDS` itself, independent of + `validate_expectations_file` — if this constant silently gained or + lost an id, the two tests above could pass against the wrong set.""" + self.assertEqual(EXPECTED_FIXTURE_IDS, frozenset({"F-A", "F-B", "F-C", "F-D", "F-E"})) + + # ---- expected_name / expected_name_hex / expected_name_byte_len self-consistency ---- + + def test_a_wrong_hex_is_refused(self): + bad = self._valid_file() + bad["fixtures"][0]["expected_name_hex"] = "ff" * 10 + with self.assertRaises(ValueError) as ctx: + self._validate(bad) + msg = str(ctx.exception) + self.assertIn("F-A", msg) + self.assertIn("hex", msg) + + def test_an_uppercase_hex_is_refused(self): + """§8.1 specifically requires *lowercase* hex — an otherwise-correct + but uppercase rendering must still be refused, not accepted as + "close enough".""" + bad = self._valid_file() + bad["fixtures"][0]["expected_name_hex"] = bad["fixtures"][0]["expected_name_hex"].upper() + with self.assertRaises(ValueError) as ctx: + self._validate(bad) + self.assertIn("hex", str(ctx.exception)) + + def test_a_wrong_byte_length_is_refused(self): + bad = self._valid_file() + bad["fixtures"][0]["expected_name_byte_len"] += 1 + with self.assertRaises(ValueError) as ctx: + self._validate(bad) + msg = str(ctx.exception) + self.assertIn("F-A", msg) + self.assertIn("byte_len", msg) + + def test_a_correct_hex_and_length_pair_is_not_refused(self): + """Mutation guard: confirms the two tests above are checking the + actual computed hex/length, not merely "is a string of digits" or + some other weaker property.""" + self._validate(self._valid_file()) # must not raise + + # ---- O1: cross-outcome collision (unchanged, folded into this entry point) ---- + + def test_a_colliding_file_is_refused_naming_the_fixture_and_both_outcomes(self): + bad = self._valid_file() + bad["fixtures"][2]["alternative_forms"] = { # F-C + "name-drops-unresolved-codepoints": ["Coro "], + "name-is-shaped-glyphs": ["Coro "], + } + with self.assertRaises(ValueError) as ctx: + self._validate(bad) + msg = str(ctx.exception) + self.assertIn("F-C", msg) + self.assertIn("name-drops-unresolved-codepoints", msg) + self.assertIn("name-is-shaped-glyphs", msg) + + def test_a_collision_between_list_entries_is_also_refused(self): + """The collision can be between any entry in one outcome's list and + any entry in another's, not just single-value outcomes.""" + bad = self._valid_file() + bad["fixtures"][0]["alternative_forms"] = { # F-A + "name-normalized": ["alpha", "shared"], + "name-is-shaped-glyphs": ["beta", "shared"], + } + with self.assertRaises(ValueError) as ctx: + self._validate(bad) + self.assertIn("shared", str(ctx.exception)) + + def test_a_repeated_value_within_the_same_outcome_is_not_a_collision(self): + """Mutation guard: if the collision check fired on *any* repeated + value rather than specifically a *cross-outcome* one, this would + wrongly raise — two entries in one outcome's own list happening to + repeat is not the ambiguity O1 refuses.""" + fine = self._valid_file() + fine["fixtures"][0]["alternative_forms"] = {"name-normalized": ["same", "same"]} + self._validate(fine) # must not raise + + def test_the_real_committed_file_is_valid(self): + """Grounds the synthetic tests above in the actual generated + artifact — the file `run_check5` will really load. Uses the file's + own `platform`/`source_fixtures_digest` as the "expected" values + (this is the one place a real digest isn't known statically), so + this test is really only exercising the fixture-id/name-consistency/ + collision checks against real data, not the digest-mismatch check.""" + import json + import os + + path = os.path.join( + os.path.dirname(__file__), "..", "round2-a11y-oracle", "a11y_expectations.json" + ) + if not os.path.exists(path): + self.skipTest(f"{path} absent — run the round2-a11y-oracle generator first") + with open(path, "r", encoding="utf-8") as f: + real_file = json.load(f) + validate_expectations_file( + real_file, + expected_platform=real_file.get("platform"), + expected_source_digest=real_file.get("source_fixtures_digest"), + ) # must not raise + + +if __name__ == "__main__": + unittest.main() diff --git a/spikes/editor-toolkit/a11y-verifier/verify.py b/spikes/editor-toolkit/a11y-verifier/verify.py index 7addf6f..861dc38 100644 --- a/spikes/editor-toolkit/a11y-verifier/verify.py +++ b/spikes/editor-toolkit/a11y-verifier/verify.py @@ -1,42 +1,53 @@ #!/usr/bin/env python3 -"""Round 0 accessibility readback verifier. +"""AT-SPI2 accessibility verifier — Round 0 readback mode, and Round 2 check 5. -An AT-SPI client, independent of any candidate's own process, that walks -the live platform accessibility tree (via the AT-SPI2 registry over D-Bus) -looking for an accessible node with a given role and name. This is a real -client query of the tree, per CONTRACT_EDITOR_T4_SPIKE.md Round 0: "Setting -the node in your own process and printing your own struct is NOT a -readback." +An AT-SPI client, independent of any candidate's own process, that walks the +live platform accessibility tree (via the AT-SPI2 registry over D-Bus). Two +modes, selected by which flags are given: -Uses gi.repository.Atspi, the official GObject-introspection binding for -AT-SPI2 (the same library backing Orca and Accerciser). This is used in -place of the `atspi` Rust crate as an "equivalent AT-SPI client" (the -contract's own wording) — chosen because its API is stable, documented, and -already verified reachable on this machine, rather than reverse-engineering -an unfamiliar async zbus proxy API under this round's timebox. That -substitution is a named deviation, reported as such. +Round 0 mode — unchanged, byte-for-byte, from the version that produced +`round0-evidence/c1-egui-readback.txt` and `c2-vello-readback.txt`: -Usage: verify.py --role "push button" --name "EpiphanyProbeButton" [--app-name SUBSTR] [--max-depth N] [--timeout SECONDS] -Exit code 0 and prints "READBACK: PASS" with the path from desktop root to -the matched node, if found within the timeout. Exit code 1 and prints -"READBACK: FAIL" with a dump of what *was* found, if the bus is reachable -but no match appears before the timeout. Exit code 2 and prints -"READBACK: NOT RUN" if the AT-SPI bus itself cannot be reached at all. +Looks for one exact (role, name) match anywhere under the desktop (optionally +restricted to apps whose name contains --app-name). Exit 0 "READBACK: PASS", +exit 1 "READBACK: FAIL", exit 2 "READBACK: NOT RUN" (bus unreachable). + +Round 2 check 5 mode — `spikes/editor-toolkit/ROUND2_TEXT_RECIPE.md` §8, an +accessibility oracle packet 2B-A precommits (`round2-a11y-oracle`): + + verify.py --expectations round2-a11y-oracle/a11y_expectations.json --fixture F-A \ + --expect-source-digest \ + --app-name SUBSTR [--timeout N] [--json PATH] + +Scores one fixture's check 5 against the live tree under the candidate's +application (matched by --app-name, required in this mode). Exit 0 "CHECK5: +PASS", exit 1 "CHECK5: FAIL" (naming exactly one of +`round2_textkit::a11y::PROHIBITED_OUTCOMES`, or a role/composition-specific +diagnosis, when applicable), exit 2 "CHECK5: NOT RUN" — reserved *only* for +the AT-SPI bus itself being unreachable. A candidate that simply never built +an accessibility tree is `absent-from-tree`, which is a FAIL, not NOT RUN. + +Uses gi.repository.Atspi, the official GObject-introspection binding for +AT-SPI2 (the same library backing Orca and Accerciser). Used in place of the +`atspi` Rust crate as an "equivalent AT-SPI client" (the contract's own +wording) — chosen because its API is stable, documented, and already +verified reachable on this machine, rather than reverse-engineering an +unfamiliar async zbus proxy API under this round's timebox. That substitution +is a named deviation, reported as such. """ import argparse +import json import sys import time +from dataclasses import dataclass, field +from typing import Dict, List, Optional, Tuple -try: - import gi - - gi.require_version("Atspi", "2.0") - from gi.repository import Atspi -except Exception as exc: # pragma: no cover - environment probe - print(f"READBACK: NOT RUN — could not import gi.repository.Atspi: {exc}") - sys.exit(2) +# --------------------------------------------------------------------------- +# Round 0 mode — unmodified from the version that produced the committed +# round0-evidence transcripts. Do not change this function's behaviour. +# --------------------------------------------------------------------------- def walk(node, role, name, app_name_substr, max_depth, path, found, all_seen): @@ -77,16 +88,7 @@ def walk(node, role, name, app_name_substr, max_depth, path, found, all_seen): ) -def main(): - ap = argparse.ArgumentParser() - ap.add_argument("--role", required=True) - ap.add_argument("--name", required=True) - ap.add_argument("--app-name", default=None, help="only descend into apps whose name contains this substring") - ap.add_argument("--max-depth", type=int, default=12) - ap.add_argument("--timeout", type=float, default=20.0) - ap.add_argument("--poll-interval", type=float, default=0.5) - args = ap.parse_args() - +def run_round0(args, Atspi): try: Atspi.init() except Exception as exc: @@ -162,5 +164,939 @@ def main(): sys.exit(1) +# --------------------------------------------------------------------------- +# Round 2 check 5 mode. +# --------------------------------------------------------------------------- + +# A verifier-specific diagnostic name for the §8.1 composition trap — the +# concatenation matches `visual_order_name`, not `expected_name`. This is +# deliberately *not* one of `round2_textkit::a11y::PROHIBITED_OUTCOMES`: it is +# a structural composition failure (assembled in the wrong order), not one of +# the five name-transformation outcomes §8.3 pins. Naming it distinctly is +# the whole point of the requirement: "the report must name it rather than +# emit a generic mismatch." +VISUAL_ORDER_TRAP = "composed-in-visual-order" + +# The platform row this verifier scores against — this machine's live AT +# client is AT-SPI2 (recipe §8.2, round0-evidence's precedent), matching +# `round2_a11y_oracle::PLATFORM`. Used by `validate_expectations_file` (B2) +# to refuse an expectations file generated for a different platform, rather +# than silently scoring against the wrong role vocabulary. +PLATFORM = "at-spi2" + +# The exact five fixture ids the recipe names (ROUND2_TEXT_RECIPE.md §2), +# restated here — not read back out of the file being validated — the same +# discipline `round2_textkit::output::FixtureFile::validate`'s +# `EXPECTED_FIXTURES` uses, so a file missing one or carrying an extra id is +# caught against a literal, not against its own other contents. +EXPECTED_FIXTURE_IDS = frozenset({"F-A", "F-B", "F-C", "F-D", "F-E"}) + + +def validate_expectations_file( + expectations_file: dict, *, expected_platform: str, expected_source_digest: str +) -> None: + """B2/O1: fail closed on a malformed or stale oracle **before** any live + AT-SPI readback. Check 5 is disqualifying, so a defect in the oracle + artifact itself must never be silently absorbed into a candidate's + verdict — every check below raises `ValueError` (which the caller turns + into a usage error, exit 2, never a FAIL: a malformed oracle is not a + candidate defect) naming exactly what disagreed. + + - `platform` must equal `expected_platform` — scoring F-A's at-spi2 role + vocabulary against a file generated for a different platform would + silently check the wrong roles. + - `source_fixtures_digest` must equal `expected_source_digest` — the + caller passes `round2_textkit::output::expected_artifact_digest()` + (via `--expect-source-digest`), so an oracle generated against a + *different* `fixtures.json` (stale, or regenerated on a machine with + different fonts — recipe §1) cannot score a candidate under the + pretense of being current. + - The fixture id set is exactly `EXPECTED_FIXTURE_IDS`: no duplicates, none + missing, none extra. + - Per fixture, `expected_name` / `expected_name_hex` / `expected_name_byte_len` + are mutually consistent — recipe §8.1 carries the name three ways + specifically so a divergence between them is detectable; this is what + detects it. (lowercase hex, per §8.1's own "lowercase hex" wording.) + - D1: `source_atoms` is a list of strings whose concatenation, in order, + equals `expected_name` — the same partition property + `round2_a11y_oracle::source_atoms`'s own doc comment claims and tests + on the generation side; this is the verifier-side half of that same + check, so a hand-edited or differently-generated file cannot silently + carry atoms that no longer add up to the name they are supposed to be + components of. + - O1: no two different outcome names in one fixture's `alternative_forms` + produce the same string (unchanged from the earlier fix, folded into + this same fail-closed entry point). + + Does not touch AT-SPI or any live state, so it is testable without a bus, + the same as `classify`. + """ + actual_platform = expectations_file.get("platform") + if actual_platform != expected_platform: + raise ValueError( + f"platform is {actual_platform!r}, expected {expected_platform!r} — this oracle was " + "not generated for the platform being scored" + ) + + actual_digest = expectations_file.get("source_fixtures_digest") + if actual_digest != expected_source_digest: + raise ValueError( + f"source_fixtures_digest is {actual_digest!r}, expected {expected_source_digest!r} " + "(round2_textkit::output::expected_artifact_digest()) — this oracle may have been " + "generated against a different fixtures.json and must not score a candidate" + ) + + fixtures = expectations_file.get("fixtures", []) + ids = [fx.get("fixture_id") for fx in fixtures] + if len(ids) != len(set(ids)): + duplicates = sorted({i for i in ids if ids.count(i) > 1}) + raise ValueError(f"duplicate fixture_id(s) in expectations file: {duplicates}") + id_set = set(ids) + missing = sorted(EXPECTED_FIXTURE_IDS - id_set) + extra = sorted(id_set - EXPECTED_FIXTURE_IDS) + if missing or extra: + raise ValueError( + f"fixture id set is {sorted(id_set)}, expected exactly {sorted(EXPECTED_FIXTURE_IDS)} " + f"(missing: {missing}, extra: {extra})" + ) + + for fx in fixtures: + fixture_id = fx.get("fixture_id", "") + + name = fx.get("expected_name") + name_hex = fx.get("expected_name_hex") + name_byte_len = fx.get("expected_name_byte_len") + if not isinstance(name, str): + raise ValueError(f"{fixture_id!r}: expected_name is not a string: {name!r}") + actual_name_bytes = name.encode("utf-8") + actual_hex = actual_name_bytes.hex() # Python's .hex() is always lowercase + if name_hex != actual_hex: + raise ValueError( + f"{fixture_id!r}: expected_name_hex is {name_hex!r}, but the lowercase hex of " + f"expected_name's UTF-8 bytes is {actual_hex!r} — the name and its hex have " + "diverged" + ) + if name_byte_len != len(actual_name_bytes): + raise ValueError( + f"{fixture_id!r}: expected_name_byte_len is {name_byte_len!r}, but " + f"expected_name's UTF-8 byte length is {len(actual_name_bytes)}" + ) + + atoms = fx.get("source_atoms") + if not isinstance(atoms, list) or not all(isinstance(a, str) for a in atoms): + raise ValueError(f"{fixture_id!r}: source_atoms is not a list of strings: {atoms!r}") + joined_atoms = "".join(atoms) + if joined_atoms != name: + raise ValueError( + f"{fixture_id!r}: source_atoms {atoms!r} concatenate to {joined_atoms!r}, which " + f"does not equal expected_name {name!r} — the atoms no longer partition the name " + "they are supposed to be components of" + ) + + forms_by_outcome: Dict[str, List[str]] = fx.get("alternative_forms", {}) or {} + owner_of: Dict[str, str] = {} + for outcome, forms in forms_by_outcome.items(): + for form in forms: + existing = owner_of.get(form) + if existing is not None and existing != outcome: + raise ValueError( + f"{fixture_id!r}: alternative forms {existing!r} and {outcome!r} both " + f"produce {form!r} — an oracle that returns two different " + "classifications for the same observed string must fail closed, not " + "let iteration order pick one" + ) + owner_of[form] = outcome + + +@dataclass +class Verdict: + """One check-5 scoring outcome. `verdict` is always exactly one of + "PASS" / "FAIL" (`prohibited_outcome` distinguishes NOT RUN, which is + handled by the caller before a `Verdict` is ever constructed — NOT RUN is + reserved for the AT-SPI bus itself being unreachable, never for a + classification the tree walk produced).""" + + verdict: str + reason: str + observed_role: Optional[str] = None + observed_name: Optional[str] = None + prohibited_outcome: Optional[str] = None + + +@dataclass +class ObservedNode: + """One node of a live AT-SPI subtree, as walked by `walk_for_check5` — + role, name, and children, preserving the structure `classify` needs to + score §8.1 composition per-subtree (B1). Deliberately holds nothing else + (no live AT-SPI object reference): once built, this is inert data, which + is what lets `classify` stay pure and bus-free.""" + + role: str + name: str + children: List["ObservedNode"] = field(default_factory=list) + + +def _iter_nodes(node: ObservedNode): + """Every node in `node`'s subtree, `node` itself included, pre-order.""" + yield node + for child in node.children: + yield from _iter_nodes(child) + + +def _iter_forest(roots: List[ObservedNode]): + """Every node in every tree in `roots`, pre-order, roots first.""" + for root in roots: + yield from _iter_nodes(root) + + +def _flatten_candidates( + roots: List[ObservedNode], accepted: set, prohibited: set +) -> List[Tuple[str, str]]: + """Every `(role, name)` pair, for every node anywhere in the forest whose + role is a text-candidate (`accepted | prohibited`), in tree order. + + Used for exactly one thing now: `classify`'s final `name-empty` vs. + `absent-from-tree` decision (user ruling), reached only after every + source-bearing scan (which considers *every* role, not just + `accepted | prohibited`) has found nothing. `name-empty` is specifically + about accepted/prohibited-role candidates existing with no name, so it + is the one remaining check that legitimately wants this narrower, + role-filtered list rather than the whole forest. + """ + return [ + (n.role, n.name) for n in _iter_forest(roots) if n.role in accepted or n.role in prohibited + ] + + +def _all_descendants(root: ObservedNode) -> List[Tuple[str, str]]: + """Every `(role, name)` pair for **every** descendant of `root` **with a + non-empty name**, regardless of role — `root` itself excluded, since a + node's own name matching `expected_name` (or an alternative/visual-order + form) is the separate single-node case (§8.1's first alternative; this + is its second), in tree order. + + Deliberately **not** filtered by role before the caller concatenates: a + non-accepted-role contributor's name is still part of what the + subtree's composition actually says, and dropping it before summing + would let a subtree "pass" by silently ignoring a contributor it + doesn't like — precisely the wrong fix for B1. The caller concatenates + first, checks role-acceptability only once the concatenation is already + confirmed to equal `expected_name` (or a precommitted alternative/ + visual-order form). + + **Empty-named nodes are excluded entirely, not merely ignored when + picking whom to blame.** An empty name contributes zero bytes to the + concatenation — including or excluding it never changes `subtree_concat` + — so the only thing including it can do is let a purely structural + wrapper (a `frame` or `panel` around the real contributors, exposing no + name of its own) be *named* as the offending contributor merely because + it happens to sort first in tree order, hiding the actual, non-empty, + possibly prohibited-role contributor that is the real §8.2 violation. + Excluding it here, at the source, fixes this the same way regardless of + which subtree in `classify`'s scan happens to be tried (and matched) + first — relying on the wrapper's own name to corrupt a *different* + subtree's concatenation would only fix the cases where that subtree + happened to be visited later. + + Used by `classify`'s composition scan for **every** subtree, regardless + of which roles (if any) appear elsewhere in the tree — an earlier + version of this function only admitted unlisted-role contributors when + *no* accepted-or-prohibited-role node existed anywhere in the tree, + which is exactly the gating the "absent-from-tree vs. name-empty" fix + removed: a real application's window `label` must not prevent the run's + actual text, exposed under an unlisted role elsewhere in the same tree, + from being found. + """ + out: List[Tuple[str, str]] = [] + for child in root.children: + for n in _iter_nodes(child): + if n.name != "": + out.append((n.role, n.name)) + return out + + +def _is_source_bearing_fragment(name: str, targets) -> bool: + """**One of two additive paths** (D1) `classify`'s fragment scan uses to + decide "the run's text is present, even if not composed correctly" + (user ruling, following C3) — this is the general, heuristic, + coincidence-guarded substring rule; `source_atoms` exact matching (see + `classify`'s fragment scan) is the other, precommitted, no-length-floor + path. To distinguish real (if misordered or incomplete) evidence of the + run from an unrelated node's text that happens to share a coincidental + substring, `name` counts as a source-bearing fragment of one of + `targets` (`expected_name`, or a precommitted alternative/visual-order + form) only if it is: + + - non-empty and not whitespace-only (`name.strip()` is non-empty) — a + bare space is not evidence of anything, even though a space is + technically a substring of e.g. `"Coro "`; + - **at least two characters** after stripping — a single character is + not distinguishable from coincidence: almost any two unrelated + strings of ordinary language share *some* one character (a window + title and `"Coro אבג"` both very plausibly contain the letter `"o"`); + - a literal substring of at least one target, compared **as given** — + never normalized, and the containment test itself uses the raw + (unstripped) `name`, so incidental surrounding whitespace in `name` + that isn't present in the target correctly fails to match; only the + length/whitespace *gate* above is computed on the stripped form. + + **Stated limit, not hidden — this rule deliberately under-detects, and + is deliberately never loosened to cover it.** A genuine run fragment + shorter than two characters — F-C's unresolved segment `ا` is exactly + this case, a single character — is never caught by *this* function, on + purpose: loosening the floor to catch it would risk exactly what + `SourceBearingFragmentGuards`' guard tests exist to catch — an + application's ordinary window title coincidentally sharing a short + substring with `expected_name` and permanently disabling + `absent-from-tree` for that fixture, "which is a worse failure than the + one being fixed" (the ruling's own words). F-C's single-character + segment is instead caught by the *other* path — an exact match against + a precommitted `source_atoms` entry, which needs no length floor at all + because it is a comparison against precommitted data, not a heuristic + guess from length alone. The two paths are independent; this function's + own contract does not change. + """ + stripped = name.strip() + if len(stripped) < 2: + return False + return any(name in target for target in targets) + + +def classify(expectation: dict, roots: List[ObservedNode]) -> Verdict: + """The whole of check 5's scoring logic, and nothing else. + + `expectation` is one fixture's entry from `a11y_expectations.json` + (`round2-a11y-oracle`) — a plain dict with `expected_name`, + `accepted_roles`, `prohibited_roles`, `alternative_forms` (an outcome + name mapped to a **list** of precommitted forms — O2: one outcome can + have more than one plausible rendering, e.g. `name-is-shaped-glyphs` + carries both a cluster-collapse form and a ligature presentation-form + substitution for F-A; matched if the observed name equals *any* entry), + and (optionally) `visual_order_name`. The caller must have already run + this file through `validate_expectations_file` (O1/B2) — `classify` + itself does not re-check the oracle's own integrity, since a malformed + oracle is exactly what validation exists to refuse before this function + ever runs. + + `roots` is the forest of `ObservedNode` trees the live tree walk found + under the candidate's application (usually one tree, the matched app's + own node) — this function does not touch AT-SPI, D-Bus, or any live + state, which is what makes it testable without a bus. + + **Shape (user ruling): source-bearing detection runs first, in full, + across every role, before any absence or empty-name determination — + never the other way around.** An earlier version of this function only + looked for the run's text under unlisted roles when *no* + accepted-or-prohibited-role node existed anywhere in the tree at all. + That gate was wrong: a real application always has *some* accepted-role + node (a window title `label`, at minimum), so the run's actual text, + exposed under an unlisted role *alongside* that unrelated label, was + never even looked for — the tree fell straight into the ordinary + (non-source-bearing) scoring path and reported whatever that path says + for "some accepted-role text exists, none of it matches," which used to + be a generic mismatch and is now (see below) `absent-from-tree`. So + every check in this section runs over the **whole forest, every role, + unconditionally** — never against just the first match, and never + gated on whether some *other*, unrelated node happens to carry an + accepted or prohibited role. + + **PRECEDENCE (pinned, C1) — exact-name matches.** §8.1's rule — "the + run's own accessible name ... must equal the source string" — is + evaluated over **every** node in the forest, regardless of role, not the + first one found. If *any* node with an **accepted** role carries + `expected_name` byte-for-byte, the verdict is PASS, regardless of where + in the tree that node sits or whether some *other* node (prohibited- or + unlisted-role) also happens to carry it. Failing that, a **prohibited**- + role match is named preferentially over an **unlisted**-role one (more + specific, per §8.2's own vocabulary); failing that, an unlisted-role + match is named. This is a pinned rule, not an implementation shortcut: a + tree that lists a `canvas` node before the real `text` node is exactly + the same candidate as one that lists them in the other order, and must + score the same way. Do not "simplify" this back to returning on the + first exact-name match — that reintroduces order-dependence on a + disqualifying check. + + **B1/C2: composition and its alternative-form/visual-order diagnoses are + all scored per subtree, never against a whole-application + concatenation, and admit every role as a contributor.** §8.1's second + alternative — "the names of its text descendants concatenated in + logical order" — is a statement about *one run's* subtree, and nothing + in §8.1 restricts which roles may compose it (an unlisted role composing + correctly is still wrong — see the fragment/role check below — but that + is a role failure to report, not a reason to exclude the node from the + concatenation in the first place). This function tries every node in the + forest as a candidate "this is the run" subtree root in turn, and for + each one: + + - if that subtree's own descendants (any role) concatenate + byte-exactly to `expected_name` **and** every one of those + descendants has an accepted role, PASS; + - if they concatenate to `expected_name` but include a non-accepted-role + contributor, that is a FAIL naming that contributor specifically — an + otherwise-correct composition failed by one contributor's role, + **never** silently dropped from consideration or averaged away by + unrelated nodes elsewhere in the tree (B1's original bug: a stray + `canvas` node absorbed into a whole-application PASS; C2's bug on the + diagnosis side: a stray `label` node corrupting an F-D-style + visual-order composition into a generic mismatch instead of naming + `composed-in-visual-order`); + - if instead they concatenate to one of a `PROHIBITED_OUTCOMES` + alternative form, or to `visual_order_name`, that subtree's diagnosis + is recorded (not returned immediately — a PASS found in a *different* + subtree still wins, since a candidate that got it right anywhere in a + legitimate run subtree has satisfied §8.1). + + A single node's own name is also checked against every alternative form + and `visual_order_name` (not only `expected_name`), regardless of role — + that has no subtree/aggregation ambiguity (one node's own name is + unambiguous regardless of tree position), so it stays a simple + whole-forest scan. + + **PRECEDENCE (pinned, D2) — a byte-exact PASS outranks every + alternative-form or visual-order diagnosis, per-node or per-subtree.** + Both PASS checks above (exact-name, and composition) already scan the + *entire* forest before either can return a PASS, so evaluating them + first and in full is what makes this safe: nothing is skipped to get to + the diagnosis checks below them. Concretely, the single-node and + subtree-level alternative-form/visual-order checks run **only after** + both PASS checks have been exhausted with nothing found — never + interleaved with them. This is why F-C's legitimate two-node split + (`text:"Coro "` + `text:"ا"`, both accepted — exactly the "one text node + per direction run" composition §8.1 permits) PASSes even though + `"Coro "` alone happens to equal F-C's own precommitted + `name-drops-unresolved-codepoints` form: the composition check finds the + byte-exact two-node PASS first. Do not "simplify" this by moving an + alternative-form check earlier for convenience — doing so previously + turned a legitimate F-C composition into a false FAIL naming a + `PROHIBITED_OUTCOMES` name that did not apply. + + **C3: fragments of the run's text present anywhere, under any role, even + out of the logical order §8.1 requires, are still evidence against + absence.** Failing an exact single-node or composition match above, any + node meeting the narrow `_is_source_bearing_fragment` definition (see + its own doc comment for the rule and its stated limits) is still + evidence the run's text is present, however it is arranged — this is + the case an exact-match/composition scan alone cannot see: text that is + genuinely present but misordered or incomplete. + + **`absent-from-tree` vs. `name-empty` (user ruling, pinned) — decided + only after every check above has found nothing.** The distinction being + preserved: `name-empty` means an attempted **static-text exposure** + without a name (§8.3: "absence wearing a role"); `absent-from-tree` + covers a **drawing-only** tree or an **unrelated** one. Concretely: + + - `name-empty` fires **only** when both hold: at least one + **accepted**-role candidate node exists somewhere in the tree, *and* + every accepted-or-prohibited-role candidate's name is empty. A lone + empty **prohibited**-role node (a canvas that drew nothing and + exposed nothing) is *not* "wearing a role" in §8.3's sense — it is + the draw-and-stop case §8.3 calls "the one this check will most + likely actually catch," and it is `absent-from-tree`. + - every other case that reaches this point — a genuinely empty tree, a + drawing-only tree, or a tree whose only text (under any role) bears no + relation to the run at all — is `absent-from-tree`. + + The required regression lock for this exact distinction lives in + `AbsentFromTreeVsNameEmpty` (`test_verify.py`): + + unrelated UI text only -> absent-from-tree + empty prohibited canvas only -> absent-from-tree + empty accepted text/label node -> name-empty + misordered source fragments -> composition/role failure, never absence + + **Contributor order stays semantically significant everywhere in this + function** — only **non-contributor** permutations (an unrelated + sibling moving around the tree) are required to be verdict-invariant. + This function never "fixes" composition into an order-insensitive + match; that would defeat the entire point of the F-D visual-order trap + (§8.1). + + Comparisons are always on the Python `str` (which is Unicode + codepoints), never bytes directly, but every string compared here is + already the exact source string on the Rust side (`str == str` is + codepoint-exact, which for valid UTF-8 is byte-exact) — the caller is + responsible for hex-encoding whatever `observed_name` this returns if a + byte-level report is needed (see `run_check5`). + """ + expected_name = expectation["expected_name"] + accepted = set(expectation["accepted_roles"]) + prohibited = set(expectation["prohibited_roles"]) + alt_forms: Dict[str, List[str]] = expectation.get("alternative_forms", {}) or {} + visual_order_name = expectation.get("visual_order_name") + # D1: precommitted per-segment source atoms (`round2-a11y-oracle`'s + # `source_atoms`), e.g. F-C's `["Coro ", "ا"]`. A node name exactly + # matching one is source-bearing regardless of length — this is what + # catches F-C's single-character unresolved segment `ا`, which the + # length-2 `_is_source_bearing_fragment` substring rule cannot (and must + # not be loosened to) catch on its own. + source_atoms = set(expectation.get("source_atoms", []) or []) + + interesting_names = {expected_name} + for forms in alt_forms.values(): + interesting_names.update(forms) + if visual_order_name is not None: + interesting_names.add(visual_order_name) + + # Every node in the forest, any role — the source-bearing scans below + # are unconditional on role, per the user ruling: gating them on whether + # some *other*, unrelated node happens to carry an accepted/prohibited + # role is exactly the bug being fixed. + all_nodes: List[Tuple[str, str]] = [(n.role, n.name) for n in _iter_forest(roots)] + + # 1. C1: exact-name matches, evaluated over the *entire* forest, every + # role, before deciding anything — never the first match found, and + # never gated on some other node's role. + exact_matches = [(role, name) for role, name in all_nodes if name == expected_name] + if exact_matches: + accepted_matches = [rn for rn in exact_matches if rn[0] in accepted] + if accepted_matches: + role, name = accepted_matches[0] + return Verdict( + "PASS", + f"a node with an accepted role ({role!r}) carries the accessible name " + "byte-for-byte", + observed_role=role, + observed_name=name, + ) + prohibited_matches = [rn for rn in exact_matches if rn[0] in prohibited] + if prohibited_matches: + role, name = prohibited_matches[0] + return Verdict( + "FAIL", + f"a node's name matches expected_name byte-for-byte, but its role {role!r} " + "is in the at-spi2 prohibited set (no accepted-role node also carries it)", + observed_role=role, + observed_name=name, + ) + # Every remaining match's role is in neither accepted nor prohibited. + role, name = exact_matches[0] + return Verdict( + "FAIL", + f"a node's name matches expected_name byte-for-byte, but its role {role!r} is " + "neither accepted nor prohibited for at-spi2 (no accepted- or prohibited-role node " + "also carries it)", + observed_role=role, + observed_name=name, + ) + + # 2. B1/C2: composition and its alternative-form/visual-order diagnoses, + # all scored per subtree in one pass, every role admitted as a + # contributor. Try every node in the forest as a candidate run-subtree + # root; every check below is decided by that node's own descendants + # alone, never by nodes outside it. + first_bad_composition: Optional[Verdict] = None + first_alt_form_fail: Optional[Verdict] = None + first_visual_order_fail: Optional[Verdict] = None + for candidate_root in _iter_forest(roots): + contributors = _all_descendants(candidate_root) + if not contributors: + continue + subtree_concat = "".join(name for _, name in contributors) + + if subtree_concat == expected_name: + bad = [(role, name) for role, name in contributors if role not in accepted] + if not bad: + return Verdict( + "PASS", + "the descendants of one run subtree concatenate to expected_name " + "byte-for-byte, and every contributor's role is accepted", + observed_name=subtree_concat, + ) + if first_bad_composition is None: + # Prefer naming a prohibited-role contributor over a merely + # unlisted one: prohibited is the specific, named §8.2 + # divergence, and the report exists to say that, not the + # weaker "nobody listed this role" case — pick the first + # prohibited-role entry if any exists, else fall back to the + # first non-accepted entry (necessarily unlisted-role, since + # `bad` excludes accepted roles by construction). + prohibited_bad = [rn for rn in bad if rn[0] in prohibited] + bad_role, _bad_name = prohibited_bad[0] if prohibited_bad else bad[0] + classification = ( + "prohibited" if bad_role in prohibited else "neither accepted nor prohibited" + ) + other_count = len(bad) - 1 + mention_others = ( + f" ({other_count} other non-accepted contributor(s) also present)" + if other_count > 0 + else "" + ) + first_bad_composition = Verdict( + "FAIL", + "a run subtree's descendants concatenate to expected_name byte-for-byte, " + f"but contributor role {bad_role!r} is {classification} for at-spi2{mention_others} " + "— an otherwise-correct composition, failed by this contributor's role", + observed_role=bad_role, + observed_name=subtree_concat, + ) + continue + + if first_alt_form_fail is None: + for outcome, forms in alt_forms.items(): + if subtree_concat in forms: + first_alt_form_fail = Verdict( + "FAIL", + "one run subtree's concatenated contributors match a precommitted " + f"{outcome!r} alternative form byte-for-byte", + observed_name=subtree_concat, + prohibited_outcome=outcome, + ) + break + + if ( + first_visual_order_fail is None + and visual_order_name is not None + and subtree_concat == visual_order_name + ): + first_visual_order_fail = Verdict( + "FAIL", + "one run subtree's concatenated contributors match visual_order_name, not " + "expected_name — the tree was assembled by walking the visual runs left to " + "right instead of logical order", + observed_name=subtree_concat, + prohibited_outcome=VISUAL_ORDER_TRAP, + ) + + if first_bad_composition is not None: + return first_bad_composition + + # D2 (user ruling): a byte-exact PASS — single-node (step 1, above) or + # subtree composition (step 2, above) — outranks every alternative-form + # or visual-order diagnosis, per-node or per-subtree. Both PASS checks + # already scan the *entire* forest before this point is ever reached, so + # by construction nothing above this line has skipped a legitimate PASS + # to get here. Only now, with every PASS opportunity exhausted, do the + # alternative-form/visual-order diagnoses get a turn — starting with a + # single node's own name (no subtree ambiguity: one node's own name is + # unambiguous regardless of position or role, so this stays a flat, + # whole-forest scan), then the subtree-level matches the composition + # loop above already recorded. + # + # This ordering is why F-C's legitimate two-node split + # (`text:"Coro "` + `text:"ا"`, both accepted) now PASSes even though + # `"Coro "` alone is also F-C's precommitted `name-drops-unresolved- + # codepoints` form: the composition loop above finds the byte-exact PASS + # across both nodes and returns before this per-node check ever runs. A + # single `text:"Coro "` node with **no** second node still reaches this + # check (no composition to find), so the outcome stays named exactly as + # before — see `FCTwoSegmentComposition`'s regression group + # (`test_verify.py`) for both halves of that guarantee. + for role, name in all_nodes: + for outcome, forms in alt_forms.items(): + if name in forms: + return Verdict( + "FAIL", + f"a node's name matches a precommitted {outcome!r} alternative form " + "byte-for-byte", + observed_role=role, + observed_name=name, + prohibited_outcome=outcome, + ) + if visual_order_name is not None and name == visual_order_name: + return Verdict( + "FAIL", + "a node's name matches visual_order_name, not expected_name — the tree was " + "assembled by walking the visual runs left to right instead of logical order", + observed_role=role, + observed_name=name, + prohibited_outcome=VISUAL_ORDER_TRAP, + ) + if first_alt_form_fail is not None: + return first_alt_form_fail + if first_visual_order_fail is not None: + return first_visual_order_fail + + # 3. C3/D1: fragments of the run's text present anywhere, any role, even + # when they do not compose to any target string in the required + # logical order — the case an exact-match/composition scan alone + # cannot see: text that is genuinely present but misordered or + # incomplete. Whole-forest, not subtree-scoped: the safety valve here + # is the narrow fragment definition itself + # (`_is_source_bearing_fragment`), not tree structure — the policy is + # "any fragment anywhere is evidence against absence," which a + # subtree restriction would contradict. + # + # D1: a node counts as source-bearing via **either** of two additive + # paths — `_is_source_bearing_fragment`'s length-2-or-more substring + # rule, **or** an exact match against a precommitted `source_atoms` + # entry, regardless of length. The atom path is what catches F-C's + # unresolved segment `ا`: a single character, which the substring + # rule's coincidence guard correctly refuses (an unrelated stray "o" + # must never rescue a tree from absence) but which is nonetheless a + # real, precommitted, exact source component §8.3 requires to appear + # in the name. The two paths are independent and neither replaces the + # other — F-A (a single-segment run) has no atom shorter than its + # whole `expected_name`, so it depends entirely on the substring path, + # same as before D1. + fragments = [ + (role, name) + for role, name in all_nodes + if _is_source_bearing_fragment(name, interesting_names) or name in source_atoms + ] + if fragments: + roles = sorted({role for role, _ in fragments}) + fragment_concat = "".join(name for _, name in fragments) + return Verdict( + "FAIL", + f"{len(fragments)} fragment(s) of the run's text are present under role(s) {roles}, " + "but do not compose to expected_name or a precommitted form in the required logical " + "order — a role/composition failure, not absent-from-tree", + observed_role=roles[0] if len(roles) == 1 else None, + observed_name=fragment_concat, + ) + + # 4. Nothing above found any source-bearing evidence anywhere, under any + # role, in any shape. Only one distinction remains (user ruling, + # pinned in the docstring above): `name-empty` requires an attempted + # *static-text* exposure — at least one accepted-role candidate node + # — with every accepted-or-prohibited-role candidate's name empty. + # Every other no-source-bearing case, including a lone empty + # prohibited-role node (draw-and-stop, §8.3's own headline case) and + # unrelated text under any role, is `absent-from-tree`. + flat_candidates = _flatten_candidates(roots, accepted, prohibited) + has_accepted_candidate = any(role in accepted for role, _ in flat_candidates) + if ( + flat_candidates + and has_accepted_candidate + and all(name == "" for _, name in flat_candidates) + ): + return Verdict( + "FAIL", + "an accepted-role candidate node is present, but every accepted- or prohibited-role " + "candidate's accessible name is empty — an attempted static-text exposure with no " + "name", + observed_name="", + prohibited_outcome="name-empty", + ) + + return Verdict( + "FAIL", + "no accessible-text-candidate node (accepted or prohibited role) found under the " + "candidate's application, on any single node, composed across any subtree, or as a " + "source-bearing fragment, under any role", + prohibited_outcome="absent-from-tree", + ) + + +def walk_for_check5(node, path, all_seen, max_depth) -> Optional[ObservedNode]: + """Recursively mirrors the live AT-SPI subtree under `node` into an + `ObservedNode` tree, and records every node's `role:name` into + `all_seen` for the human/JSON "full tree" report — the same diagnostic + output this produced before B1, alongside a tree instead of a flat list. + + Unlike the pre-B1 version, this does **not** decide which nodes are + text-candidates — that decision now happens in `classify`, scoped per + subtree (B1): filtering roles *while* flattening the walk into a list is + exactly what threw away the subtree structure composition scoring needs. + """ + if node is None: + return None + try: + name = node.get_name() + except Exception: + name = "" + try: + role = node.get_role_name() + except Exception: + role = "" + all_seen.append(" / ".join(path + [f"{role}:{name!r}"])) + observed = ObservedNode(role=role, name=name) + if max_depth <= 0: + return observed + try: + n = node.get_child_count() + except Exception: + return observed + for i in range(n): + try: + child = node.get_child_at_index(i) + except Exception: + continue + child_observed = walk_for_check5( + child, path + [f"{role}:{name!r}"], all_seen, max_depth - 1 + ) + if child_observed is not None: + observed.children.append(child_observed) + return observed + + +def hex_lower(s: Optional[str]) -> Optional[str]: + if s is None: + return None + return s.encode("utf-8").hex() + + +def run_check5(args, Atspi): + try: + with open(args.expectations, "r", encoding="utf-8") as f: + expectations_file = json.load(f) + except Exception as exc: + print(f"CHECK5: usage error — could not read/parse {args.expectations!r}: {exc}") + sys.exit(2) + + try: + validate_expectations_file( + expectations_file, + expected_platform=PLATFORM, + expected_source_digest=args.expect_source_digest, + ) + except ValueError as exc: + print(f"CHECK5: usage error — {args.expectations!r} failed validation: {exc}") + sys.exit(2) + + expectation = next( + (f for f in expectations_file.get("fixtures", []) if f.get("fixture_id") == args.fixture), + None, + ) + if expectation is None: + print( + f"CHECK5: usage error — {args.fixture!r} is not a fixture in {args.expectations!r} " + f"(has: {[f.get('fixture_id') for f in expectations_file.get('fixtures', [])]})" + ) + sys.exit(2) + + try: + Atspi.init() + except Exception as exc: + print(f"CHECK5: NOT RUN — Atspi.init() failed: {exc}") + sys.exit(2) + + deadline = time.monotonic() + args.timeout + verdict = None + all_seen: List[str] = [] + attempt = 0 + while time.monotonic() < deadline: + attempt += 1 + try: + desktop = Atspi.get_desktop(0) + except Exception as exc: + print(f"CHECK5: NOT RUN — Atspi.get_desktop(0) failed: {exc}") + sys.exit(2) + if desktop is None: + print("CHECK5: NOT RUN — Atspi.get_desktop(0) returned None (no AT-SPI registry?)") + sys.exit(2) + + roots: List[ObservedNode] = [] + all_seen = [] + try: + n_apps = desktop.get_child_count() + except Exception as exc: + print(f"CHECK5: NOT RUN — desktop.get_child_count() failed: {exc}") + sys.exit(2) + + for i in range(n_apps): + try: + app = desktop.get_child_at_index(i) + except Exception: + continue + if app is None: + continue + try: + app_name = app.get_name() + except Exception: + app_name = "" + if args.app_name not in app_name: + continue + app_observed = walk_for_check5(app, ["desktop"], all_seen, args.max_depth) + if app_observed is not None: + roots.append(app_observed) + + verdict = classify(expectation, roots) + if verdict.verdict == "PASS": + break + time.sleep(args.poll_interval) + + assert verdict is not None # the while loop above always runs at least once before a timeout + + print(f"CHECK5: {verdict.verdict}") + print(f"fixture: {args.fixture}") + print(f"attempt: {attempt}, timeout: {args.timeout}s") + print(f"reason: {verdict.reason}") + if verdict.observed_role is not None: + print(f"observed role: {verdict.observed_role}") + if verdict.observed_name is not None: + print(f"observed name: {verdict.observed_name!r}") + print(f"observed name (hex): {hex_lower(verdict.observed_name)}") + if verdict.prohibited_outcome is not None: + print(f"prohibited outcome: {verdict.prohibited_outcome}") + print("full tree (role:name) seen during the last walk:") + if not all_seen: + print(" ") + for line in all_seen: + print(" " + line) + + if args.json: + payload = { + "fixture_id": args.fixture, + "verdict": verdict.verdict, + "reason": verdict.reason, + "observed_role": verdict.observed_role, + "observed_name": verdict.observed_name, + "observed_name_hex": hex_lower(verdict.observed_name), + "prohibited_outcome": verdict.prohibited_outcome, + "walked_tree": all_seen, + } + with open(args.json, "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2) + f.write("\n") + + sys.exit({"PASS": 0, "FAIL": 1}[verdict.verdict]) + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument("--role", default=None, help="Round 0 mode: exact role to match") + ap.add_argument("--name", default=None, help="Round 0 mode: exact name to match") + ap.add_argument( + "--expectations", + default=None, + help="Round 2 check 5 mode: path to round2-a11y-oracle's a11y_expectations.json", + ) + ap.add_argument("--fixture", default=None, help="Round 2 check 5 mode: fixture id (e.g. F-A)") + ap.add_argument( + "--expect-source-digest", + default=None, + help="Round 2 check 5 mode (required): round2_textkit::output::expected_artifact_digest() " + "— refuses the expectations file (usage error, exit 2) if its source_fixtures_digest " + "disagrees, so a stale oracle cannot score a candidate (B2)", + ) + ap.add_argument("--json", default=None, help="Round 2 check 5 mode: write the machine-readable verdict here") + ap.add_argument("--app-name", default=None, help="only descend into apps whose name contains this substring") + ap.add_argument("--max-depth", type=int, default=12) + ap.add_argument("--timeout", type=float, default=20.0) + ap.add_argument("--poll-interval", type=float, default=0.5) + args = ap.parse_args() + + round0_mode = args.role is not None and args.name is not None + check5_mode = args.expectations is not None and args.fixture is not None + + if round0_mode and check5_mode: + ap.error("--role/--name (Round 0 mode) and --expectations/--fixture (check 5 mode) are mutually exclusive") + if not round0_mode and not check5_mode: + ap.error("either --role and --name, or --expectations and --fixture, must be given") + if check5_mode and not args.app_name: + ap.error("--app-name is required in check 5 mode, to scope the walk to the candidate's application") + if check5_mode and not args.expect_source_digest: + ap.error( + "--expect-source-digest is required in check 5 mode (B2) — pass " + "round2_textkit::output::expected_artifact_digest()" + ) + + try: + import gi + + gi.require_version("Atspi", "2.0") + from gi.repository import Atspi + except Exception as exc: # pragma: no cover - environment probe + label = "READBACK" if round0_mode else "CHECK5" + print(f"{label}: NOT RUN — could not import gi.repository.Atspi: {exc}") + sys.exit(2) + + if round0_mode: + run_round0(args, Atspi) + else: + run_check5(args, Atspi) + + if __name__ == "__main__": main() diff --git a/spikes/editor-toolkit/round2-a11y-oracle/Cargo.toml b/spikes/editor-toolkit/round2-a11y-oracle/Cargo.toml new file mode 100644 index 0000000..97afa95 --- /dev/null +++ b/spikes/editor-toolkit/round2-a11y-oracle/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "round2-a11y-oracle" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +# Packet 2B-A (ROUND2_TEXT_RECIPE.md §8, spec/CONTRACT_EDITOR_T4_SPIKE.md pin +# 13): the check-5 accessibility oracle's *comparison-data* half. This crate +# reads the already-committed, already-validated `round2-textkit/fixtures.json` +# (via `round2_textkit::output::load_fixtures`, which validates it against its +# embedded digest — see that crate) and writes +# `round2-a11y-oracle/a11y_expectations.json`: per fixture, the exact accepted/ +# prohibited at-spi2 roles and the exact byte strings each `PROHIBITED_OUTCOMES` +# classification would produce, so `a11y-verifier/verify.py`'s live-tree mode +# compares observed bytes against precommitted bytes rather than guessing what +# a wrong name "looks like". +# +# This crate must exist and be reviewed *before* either Round 2 candidate +# builds a tree (pin 13): if a candidate's own tree shaped what this oracle +# expects, the oracle would no longer be neutral evidence. +# +# Deliberately no rendering, windowing, or accessibility crate here — this +# crate never touches a live tree; that is `a11y-verifier/verify.py`'s job. + +[dependencies] +round2-textkit = { path = "../round2-textkit" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +# Pinned to the same version already resolved transitively (epiphany-core / +# epiphany-ops both already pull it), so adding this dependency changes +# nothing in Cargo.lock's resolution. +unicode-normalization = "=0.1.25" +# Same version round2-textkit shapes fixtures with, for the same reason it +# pins it: grapheme-boundary derivation here must agree with the grapheme +# boundaries `crate::hittest`'s caret stops were built from. +unicode-segmentation = "=1.13.3" + +[[bin]] +name = "generate_a11y_expectations" +path = "src/bin/generate_a11y_expectations.rs" diff --git a/spikes/editor-toolkit/round2-a11y-oracle/a11y_expectations.json b/spikes/editor-toolkit/round2-a11y-oracle/a11y_expectations.json new file mode 100644 index 0000000..b5f1ab5 --- /dev/null +++ b/spikes/editor-toolkit/round2-a11y-oracle/a11y_expectations.json @@ -0,0 +1,144 @@ +{ + "contract": "spec/CONTRACT_EDITOR_T4_SPIKE.md pin 13", + "recipe": "spikes/editor-toolkit/ROUND2_TEXT_RECIPE.md §8", + "platform": "at-spi2", + "source_fixtures_digest": "acc13c0d02624a0741cca5dffa7470a8971d3ecef5c6fb6f9e533ded684e7ed1", + "fixtures": [ + { + "fixture_id": "F-A", + "expected_name": "Allegro affettuoso — al fine", + "expected_name_hex": "416c6c6567726f20616666657474756f736f20e2809420616c2066696e65", + "expected_name_byte_len": 30, + "accepted_roles": [ + "label", + "static", + "text", + "paragraph" + ], + "prohibited_roles": [ + "image", + "canvas", + "filler", + "panel", + "unknown" + ], + "source_atoms": [ + "Allegro affettuoso — al fine" + ], + "alternative_forms": { + "name-is-shaped-glyphs": [ + "Allegro afettuoso — al fne", + "Allegro affettuoso — al fine" + ] + } + }, + { + "fixture_id": "F-B", + "expected_name": "Coro אבג", + "expected_name_hex": "436f726f20d790d791d792", + "expected_name_byte_len": 11, + "accepted_roles": [ + "label", + "static", + "text", + "paragraph" + ], + "prohibited_roles": [ + "image", + "canvas", + "filler", + "panel", + "unknown" + ], + "source_atoms": [ + "Coro ", + "אבג" + ], + "alternative_forms": {}, + "visual_order_name": "Coro גבא", + "visual_order_name_hex": "436f726f20d792d791d790" + }, + { + "fixture_id": "F-C", + "expected_name": "Coro ا", + "expected_name_hex": "436f726f20d8a7", + "expected_name_byte_len": 7, + "accepted_roles": [ + "label", + "static", + "text", + "paragraph" + ], + "prohibited_roles": [ + "image", + "canvas", + "filler", + "panel", + "unknown" + ], + "source_atoms": [ + "Coro ", + "ا" + ], + "alternative_forms": { + "name-drops-unresolved-codepoints": [ + "Coro " + ] + } + }, + { + "fixture_id": "F-D", + "expected_name": "Allegro אבג con brio", + "expected_name_hex": "416c6c6567726f20d790d791d79220636f6e206272696f", + "expected_name_byte_len": 23, + "accepted_roles": [ + "label", + "static", + "text", + "paragraph" + ], + "prohibited_roles": [ + "image", + "canvas", + "filler", + "panel", + "unknown" + ], + "source_atoms": [ + "Allegro ", + "אבג", + " con brio" + ], + "alternative_forms": {}, + "visual_order_name": "Allegro גבא con brio", + "visual_order_name_hex": "416c6c6567726f20d792d791d79020636f6e206272696f" + }, + { + "fixture_id": "F-E", + "expected_name": "Café — resumé", + "expected_name_hex": "43616665cc8120e2809420726573756d65cc81", + "expected_name_byte_len": 19, + "accepted_roles": [ + "label", + "static", + "text", + "paragraph" + ], + "prohibited_roles": [ + "image", + "canvas", + "filler", + "panel", + "unknown" + ], + "source_atoms": [ + "Café — resumé" + ], + "alternative_forms": { + "name-normalized": [ + "Café — resumé" + ] + } + } + ] +} diff --git a/spikes/editor-toolkit/round2-a11y-oracle/src/bin/generate_a11y_expectations.rs b/spikes/editor-toolkit/round2-a11y-oracle/src/bin/generate_a11y_expectations.rs new file mode 100644 index 0000000..a27a0b4 --- /dev/null +++ b/spikes/editor-toolkit/round2-a11y-oracle/src/bin/generate_a11y_expectations.rs @@ -0,0 +1,58 @@ +//! `generate_a11y_expectations` — Packet 2B-A's entry point. +//! +//! Loads `round2-textkit/fixtures.json` (validating it against its own +//! embedded digest via `round2_textkit::output::load_fixtures`), derives this +//! machine's check-5 comparison data for all five fixtures, and writes +//! `round2-a11y-oracle/a11y_expectations.json`. +//! +//! Exit behavior mirrors `round2-textkit`'s own `bin/generate`: a missing +//! `fixtures.json` (never generated, or generated on a machine without the +//! declared faces) is reported and this binary exits non-zero rather than +//! writing a partial or empty file — pin 13's ordering requires the oracle to +//! exist and be reviewed before a candidate consumes it, so silently writing +//! nothing would be worse than a loud failure. + +use std::path::PathBuf; + +fn main() { + let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let textkit_dir = manifest_dir + .parent() + .expect("round2-a11y-oracle has a parent directory") + .join("round2-textkit"); + let fixtures_path = textkit_dir.join("fixtures.json"); + + let fixtures = round2_textkit::output::load_fixtures(&fixtures_path).unwrap_or_else(|e| { + panic!( + "{}: {e} — run `cargo run -p round2-textkit --bin generate` first", + fixtures_path.display() + ) + }); + + let expectations = round2_a11y_oracle::build_expectations_file(&fixtures); + + let out_path = manifest_dir.join("a11y_expectations.json"); + let json = serde_json::to_string_pretty(&expectations) + .expect("ExpectationsFile is always serializable"); + std::fs::write(&out_path, format!("{json}\n")) + .unwrap_or_else(|e| panic!("failed to write {}: {e}", out_path.display())); + + println!( + "wrote {} ({} fixtures, platform {})", + out_path.display(), + expectations.fixtures.len(), + expectations.platform + ); + for f in &expectations.fixtures { + println!( + " {}: {} alternative form(s), visual_order_name {}", + f.fixture_id, + f.alternative_forms.len(), + if f.visual_order_name.is_some() { + "present" + } else { + "omitted (identical to expected_name)" + } + ); + } +} diff --git a/spikes/editor-toolkit/round2-a11y-oracle/src/findings.rs b/spikes/editor-toolkit/round2-a11y-oracle/src/findings.rs new file mode 100644 index 0000000..3de019c --- /dev/null +++ b/spikes/editor-toolkit/round2-a11y-oracle/src/findings.rs @@ -0,0 +1,113 @@ +//! Findings routed back to `spikes/editor-toolkit/ROUND2_TEXT_RECIPE.md`, +//! discovered while building this crate. +//! +//! Recorded here — not only in a review conversation — so whoever next +//! revises the recipe finds it in the artifact rather than a chat transcript, +//! the same discipline `round2_textkit::findings` uses for the findings it +//! routes back to the W3 `.tex` amendment. These are findings *about the +//! recipe's own prose*, not about `epiphany-layout-ir`, so they are recorded +//! here rather than in `round2_textkit::findings`. +//! +//! This crate does not edit `ROUND2_TEXT_RECIPE.md` — that document belongs +//! to the coordinator's commit and a separate review thread. + +/// Recipe §8.1 claims: "a tree assembled by walking the visual runs left to +/// right produces a different string, and only there \[F-D\]." +/// +/// That is false under this crate's own generated data. F-B diverges the +/// same way: its logical name is `"Coro אבג"` and +/// `round2_a11y_oracle::visual_order_form` produces `"Coro גבא"` for it — a +/// real, non-empty `visual_order_name` entry in `a11y_expectations.json`, +/// exactly the same mechanism F-D exercises. +/// +/// The general shape, not just the one counterexample: under +/// [`crate::visual_order_form`]'s model (concatenate segments in stored +/// order, reversing an `Rtl` segment's own text by grapheme), **any +/// non-palindromic** RTL run of two or more graphemes diverges under a +/// visual-order walk, because reversing a grapheme sequence is a no-op +/// exactly when that sequence is a palindrome (a repeated single grapheme, +/// e.g. `"aa"`, is a palindrome and is therefore **not** a counterexample to +/// this narrower claim — it was a counterexample to the unqualified "any RTL +/// run of two or more graphemes" claim an earlier revision of this finding +/// made). F-D is not the *unique* case; it is the case where the RTL run is +/// *interior* to the string (`"Allegro "` ... `"אבג"` ... `" con brio"`) +/// rather than trailing (`"Coro "` ... `"אבג"`), which is why F-D's +/// divergence reads as obviously wrong to a human glancing at it and F-B's — +/// a suffix silently reversed — reads as more easily missed. That +/// readability difference is a real reason to prefer F-D as the check-5 +/// accessibility exemplar; it is not a reason to claim F-B does not exhibit +/// the same property. +/// +/// The recipe should either say "F-D and F-B" at §8.1, or drop the +/// uniqueness claim and state the actual distinguishing property: F-D is the +/// fixture where the RTL run is interior, not the fixture where the +/// divergence uniquely occurs. +/// +/// ## The same stale claim is also baked into a digest-bound artifact +/// +/// The recipe's prose is not the only place this claim lives. +/// `round2-textkit/src/a11y.rs`'s `note_for("F-D")` reads: "the concatenation +/// is logical-order, so a tree built by walking the visual runs left to +/// right fails here and only here" — the identical uniqueness claim, in +/// code. That note is compiled into every generated `fixtures.json` as +/// `fixtures[3].accessibility.note`, and `fixtures.json`'s own +/// `EXPECTED_ARTIFACT_DIGEST_HEX` (`round2_textkit::output`) binds the whole +/// serialized file, note text included, to `acc13c0d…` — a frozen, +/// user-reviewed artifact (Packet 2A). Editing the note's wording to correct +/// the claim would change that digest and break every consumer pinned to it, +/// which is a strictly larger and differently-scoped change than this +/// finding. +/// +/// **This half of the finding is tracked, not fixed**, and is recorded +/// explicitly so a later reader does not "helpfully" edit +/// `round2-textkit/src/a11y.rs`'s F-D note on the strength of this finding +/// alone and silently move `acc13c0d…` out from under Packet 2A. Fixing it +/// is a decision for whoever owns that digest and that packet's re-freeze, +/// not a drive-by edit from this crate. +pub const RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D: &str = "ROUND2_TEXT_RECIPE.md §8.1 claims \ + visual-order-walk assembly produces a different string \"and only there [F-D]\". It does \ + not: F-B's visual_order_name (\"Coro גבא\") also differs from its expected_name (\"Coro \ + אבג\"), and under this crate's visual_order_form, any non-palindromic RTL run of two or more \ + graphemes diverges the same way (a repeated-grapheme run like \"aa\" is a palindrome and does \ + not diverge, which is why the claim is qualified). F-D is not unique in exhibiting the \ + divergence; it is the fixture where the RTL run is interior to the string rather than \ + trailing, which is why the divergence is more obviously wrong to a reader. The recipe should \ + say \"F-D and F-B\" or state the interior-run property instead of a uniqueness claim. The \ + identical stale claim is also baked into round2-textkit/src/a11y.rs's note_for(\"F-D\") \ + (\"fails here and only here\"), which is compiled into fixtures.json and covered by its \ + frozen EXPECTED_ARTIFACT_DIGEST_HEX (acc13c0d...) — that half is TRACKED, NOT FIXED here, \ + because correcting it would move the digest and break Packet 2A; do not edit that note on \ + the strength of this finding alone."; + +#[cfg(test)] +mod tests { + use super::*; + + /// The finding must actually name both fixtures — a mutation that + /// silently dropped one of them from the constant would still compile + /// and would still "record a finding," just not the right one. + #[test] + fn the_finding_names_both_f_d_and_f_b() { + assert!(RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D.contains("F-D")); + assert!(RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D.contains("F-B")); + } + + /// The universal claim must be qualified — an unqualified "any RTL run + /// of two or more graphemes diverges" is false (a palindromic run does + /// not), which is exactly the over-claim B3 asked to be narrowed. + #[test] + fn the_finding_qualifies_the_claim_as_non_palindromic() { + assert!(RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D.contains("non-palindromic")); + } + + /// The digest-bound, tracked-not-fixed half of the finding must name the + /// actual frozen digest prefix and say explicitly that it is not fixed + /// here — a reader skimming only for "is this fixed" must not be able to + /// mistake "recorded" for "corrected." + #[test] + fn the_finding_names_the_frozen_digest_and_says_tracked_not_fixed() { + assert!(RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D.contains("acc13c0d")); + assert!(RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D.contains("TRACKED, NOT FIXED")); + assert!(RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D.contains("a11y.rs")); + } +} diff --git a/spikes/editor-toolkit/round2-a11y-oracle/src/lib.rs b/spikes/editor-toolkit/round2-a11y-oracle/src/lib.rs new file mode 100644 index 0000000..acd6e29 --- /dev/null +++ b/spikes/editor-toolkit/round2-a11y-oracle/src/lib.rs @@ -0,0 +1,971 @@ +//! Packet 2B-A: the check-5 accessibility oracle's comparison-data half. +//! +//! `spec/CONTRACT_EDITOR_T4_SPIKE.md` pin 13 requires this oracle to be +//! committed and reviewed **before any candidate builds a tree against it** — +//! if a candidate wrote the verifier, the oracle would be shaped by that +//! candidate's tree, which is exactly what pin 13 forbids. This crate is +//! therefore a separate, candidate-neutral packet from either Round 2 +//! candidate: it reads the already-committed, already-validated +//! `round2-textkit/fixtures.json` (`ROUND2_TEXT_RECIPE.md` §8; +//! `round2_textkit::a11y`) and derives every byte string a live AT-SPI +//! readback would need to compare against, so the verifier's classification +//! is a comparison against precommitted data, never a heuristic guess about +//! what a wrong name "looks like" (`ROUND2_TEXT_RECIPE.md` §8.1). +//! +//! ## What is derived, and what is restated +//! +//! `expected_name` / `expected_name_hex` / `expected_name_byte_len` and the +//! at-spi2 accepted/prohibited role sets are **restated** — they already +//! exist verbatim on each fixture's `SpikeAccessibilityExpectation` +//! (`round2_textkit::a11y`), computed and validated there. This crate does +//! not recompute them from `resolved.text` a second time; it reads the +//! oracle's own already-validated fields, the same discipline +//! `round2-textkit::output::FixtureFile::validate` uses for everything else. +//! +//! `alternative_forms` and `visual_order_name` are **derived** here, from +//! `SpikeResolvedText`'s own segment and cluster data — never hard-coded to a +//! particular codepoint or glyph id, so the derivation is reproducible from +//! `fixtures.json` alone and does not silently drift from it: +//! +//! * **`name-normalized`** — the NFC normalization of `text` +//! (`unicode-normalization`). Differs only for F-E (recipe §2: F-E is +//! deliberately NFD). +//! * **`name-drops-unresolved-codepoints`** — `text` with every segment whose +//! `face` is `None` removed (`SpikeShapedSegment::face`, `W3-F3`). Differs +//! only for F-C, whose U+0627 is covered by neither declared face. +//! * **`name-is-shaped-glyphs`** — two independently derived forms, both +//! modelling "the tree exposes what was drawn rather than what was said": +//! a cluster-collapse form ([`shaped_glyphs_form`]) and, where derivable, a +//! standard-ligature presentation-form substitution +//! ([`shaped_glyphs_presentation_form`]). F-A's `ff`/`fi` ligatures are the +//! case this fixture set exercises for both. See each function's doc +//! comment for exactly what it does and does not derive from the fixture +//! record. +//! * **`visual_order_name`** — concatenates every segment's source text in +//! the *stored* (logical) segment order, but reverses an `Rtl` segment's +//! own text by extended grapheme cluster before appending it. This +//! reproduces "a tree assembled by walking the visual runs left to right" +//! (recipe §8.1) for every fixture in this set, all of which nest at most +//! one `Rtl` run inside an `Ltr` base paragraph (recipe §4: base level 0, +//! Hebrew segments at level 1) — a single odd-level run does not change +//! the *order* of the run sequence under UAX#9 reordering, only the +//! *internal* order of that run's own text. **This is not a general bidi +//! run-reordering implementation**; it is correct for this fixture set and +//! would need revisiting for a fixture with nested embedding levels beyond +//! 0/1, which none of F-A..F-E have (measured, recipe §4). It also +//! diverges from the recipe's own claim about which fixture this +//! is unique to — see [`findings::RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D`]. +//! +//! ## Fail-closed on a colliding classification (O1) +//! +//! An earlier version of this crate could emit the *same string* under two +//! different `PROHIBITED_OUTCOMES` names for one fixture — F-C's unresolved +//! cluster produced `"Coro "` under both `name-drops-unresolved-codepoints` +//! and `name-is-shaped-glyphs`, because "drop the unresolved codepoint" and +//! "collapse a zero-glyph cluster" were, for that cluster, the same +//! operation. Which classification a verifier reported was then an artifact +//! of `BTreeMap` iteration (alphabetical) order, not a property of the +//! observation — the oracle was returning two different confident answers +//! for one input. That is fixed two ways, and both are load-bearing: +//! +//! 1. [`shaped_glyphs_form`] no longer collapses a *fully* unresolved +//! cluster (zero glyphs) — collapsing to "what was drawn" presumes +//! something was drawn; a wholly unresolved cluster's only legitimate +//! classification is `name-drops-unresolved-codepoints`. This is enough +//! to make F-C's two forms genuinely equal to `expected_name` again (no +//! codepoint was shape-collapsed), so `name-is-shaped-glyphs` is correctly +//! omitted for F-C by the ordinary omit-if-identical rule. +//! 2. [`build_expectation`] additionally **refuses to build** a fixture whose +//! candidate forms collide across two different outcome names, panicking +//! and naming the fixture and both outcomes — a generation-time backstop +//! for any future fixture or derivation that reintroduces the same +//! ambiguity, independent of whether fix 1 above happens to prevent it. + +use std::collections::BTreeMap; + +use round2_textkit::a11y::PROHIBITED_OUTCOMES; +use round2_textkit::output::{FixtureFile, FixtureRecord}; +use round2_textkit::types::{SpikeResolvedText, SpikeTextDirection}; +use serde::{Deserialize, Serialize}; +use unicode_normalization::UnicodeNormalization; +use unicode_segmentation::UnicodeSegmentation; + +pub mod findings; + +/// The one platform row this oracle emits: this machine's live AT client is +/// AT-SPI2 (recipe §8.2, round0-evidence's precedent). Candidates targeting +/// another platform stay covered by the recipe's own table; encoding all five +/// rows here would not make them checkable on a machine that cannot reach +/// them. +pub const PLATFORM: &str = "at-spi2"; + +/// One fixture's precommitted check-5 comparison data. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct FixtureExpectation { + pub fixture_id: String, + /// The exact source string, restated from + /// `SpikeAccessibilityExpectation::name` (`round2_textkit::a11y`), not + /// recomputed — see the module doc comment. + pub expected_name: String, + pub expected_name_hex: String, + pub expected_name_byte_len: usize, + /// This machine's platform row (`at-spi2`) of recipe §8.2's accepted-role + /// table, restated from the fixture's own + /// `SpikeAccessibilityExpectation::accepted_roles`. + pub accepted_roles: Vec, + pub prohibited_roles: Vec, + /// D1: the per-segment source texts, in the segments' own stored + /// (logical, ascending-source) order — e.g. F-C's `["Coro ", "ا"]`, + /// F-D's `["Allegro ", "אבג", " con brio"]`. §8.1 explicitly permits "a + /// tree that exposes one text node per direction run," and §8.3 + /// requires an unresolved codepoint (F-C's `ا`) to appear in the name + /// regardless of whether it drew ink — but a lone unresolved segment can + /// be a single character, which falls below any reasonable + /// coincidence-guarded length floor a verifier-side substring rule would + /// use. This field lets the verifier match a node's name against a + /// precommitted exact component instead of guessing from length alone — + /// the same "precommitted comparison data, not a heuristic" discipline + /// this whole struct already uses everywhere else. `"".join(source_atoms) + /// == expected_name` always holds (see `source_atoms`'s own doc comment + /// and its test coverage). + pub source_atoms: Vec, + /// Keyed by a `PROHIBITED_OUTCOMES` name; every precommitted string that + /// classification would produce for this fixture, matched if the + /// observed name equals **any** entry in the list (O2: a single outcome + /// can have more than one plausible precommitted rendering — e.g. + /// `name-is-shaped-glyphs` carries both a cluster-collapse form and a + /// standard-ligature presentation-form substitution for F-A). An outcome + /// absent from this map produced no form distinguishable from + /// `expected_name` for this fixture (see the module doc comment) and so + /// cannot classify anything. The same string never appears under two + /// different outcome keys for one fixture — [`build_expectation`] + /// refuses to build a file where it would (O1). + pub alternative_forms: BTreeMap>, + /// The concatenation a tree assembled by walking visual runs left to + /// right would produce, only when it differs from `expected_name` (§8.1). + #[serde(skip_serializing_if = "Option::is_none")] + pub visual_order_name: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub visual_order_name_hex: Option, +} + +/// The complete artifact `a11y_expectations.json` carries. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ExpectationsFile { + pub contract: String, + pub recipe: String, + pub platform: String, + /// Traceability to the exact `fixtures.json` this file was derived from + /// (`round2_textkit::output::artifact_digest`) — so a verifier run + /// against a stale copy of either file is a detectable mismatch rather + /// than a silent one, the same discipline `fixtures.json` itself uses for + /// the two declared face hashes. + pub source_fixtures_digest: String, + pub fixtures: Vec, +} + +fn hex_lower(bytes: &[u8]) -> String { + use std::fmt::Write as _; + let mut s = String::with_capacity(bytes.len() * 2); + for b in bytes { + let _ = write!(s, "{b:02x}"); + } + s +} + +/// NFC normalization of `text`. §8.3's `name-normalized`: F-E's NFD source is +/// the only fixture where this differs from `text`. +pub fn nfc_form(text: &str) -> String { + text.nfc().collect() +} + +/// `text` with every segment whose `face` is `None` removed, in the +/// segments' own stored (logical, ascending-source) order. §8.3's +/// `name-drops-unresolved-codepoints`: F-C's U+0627 (covered by neither +/// declared face) is the only case in this fixture set. +/// +/// Derived entirely from `resolved.segments[*].face` and `.source` — never +/// from a hard-coded codepoint, so a future fixture with a different +/// uncovered span is handled the same way without a code change. +pub fn drop_unresolved_codepoints_form(resolved: &SpikeResolvedText) -> String { + let mut out = String::new(); + for seg in &resolved.segments { + if seg.face.is_none() { + continue; + } + let start = seg.source.start as usize; + let end = seg.source.end as usize; + out.push_str(&resolved.text[start..end]); + } + out +} + +/// The per-segment source texts, in the segments' own stored (logical, +/// ascending-source) order (D1). Every segment contributes an atom, +/// resolved or not — F-C's unresolved `ا` is included exactly like any +/// other segment, because the property this field exists to let a verifier +/// check ("does some node's name match one exact source component") is +/// just as true for an unresolved segment as a resolved one, and singling +/// it out would be exactly the kind of per-fixture special case this crate +/// avoids elsewhere. +/// +/// Derived entirely from `resolved.segments[*].source` — never from a +/// hard-coded codepoint or fixture id, so a future fixture's own segment +/// boundaries are picked up the same way without a code change. +/// `source_atoms(resolved).concat() == resolved.text` always holds, because +/// W3 invariant 2 (asserted elsewhere in this pipeline) requires segment +/// source ranges to partition the whole string totally, in logical order. +pub fn source_atoms(resolved: &SpikeResolvedText) -> Vec { + resolved + .segments + .iter() + .map(|seg| { + let start = seg.source.start as usize; + let end = seg.source.end as usize; + resolved.text[start..end].to_string() + }) + .collect() +} + +/// The run's text as a tree exposing "what was drawn" rather than "what was +/// said" would read it, by collapsing each cluster to as many leading +/// graphemes as it has glyphs. §8.3's `name-is-shaped-glyphs`: F-A's `ff`/`fi` +/// ligatures are the case this fixture set exercises. +/// +/// Walks `resolved.clusters.clusters` in its own documented ascending-source +/// order (`SpikeClusterMap`'s doc comment). For a cluster whose glyph count is +/// **strictly between zero and** its `grapheme_count` — a genuine ligature +/// drew fewer, but more than zero, glyphs than there are graphemes to +/// report — only that many leading graphemes of the cluster's own source text +/// are kept. A cluster with `glyphs == graphemes` (ordinary) or `glyphs == 0` +/// (**wholly unresolved** — O1: nothing was drawn, so there is no partial +/// "what was drawn" to report; that is `name-drops-unresolved-codepoints`'s +/// classification, not this one) contributes its whole source text unchanged. +/// Nothing here is specific to `ff`/`fi`: the rule is "one reportable unit per +/// glyph, when at least one glyph exists," derived purely from each cluster's +/// own `glyph_indices.len()` and `grapheme_count`. +pub fn shaped_glyphs_form(resolved: &SpikeResolvedText) -> String { + let mut out = String::new(); + for cluster in &resolved.clusters.clusters { + let start = cluster.source.start as usize; + let end = cluster.source.end as usize; + let chunk = &resolved.text[start..end]; + let glyph_count = cluster.glyph_indices.len() as u32; + if glyph_count > 0 && glyph_count < cluster.grapheme_count { + let kept: String = chunk.graphemes(true).take(glyph_count as usize).collect(); + out.push_str(&kept); + } else { + out.push_str(chunk); + } + } + out +} + +/// The standard Unicode Latin ligature presentation forms (Alphabetic +/// Presentation Forms block, U+FB00-U+FB06) that a shaper's default `liga` +/// feature can produce. This table is **fixed Unicode data, not derived from +/// `fixtures.json`** — this crate deliberately carries no font/cmap +/// dependency (see the crate doc comment on why: it never touches a live +/// tree, and adding one here would be the wrong layer for it), so there is no +/// way to derive "this glyph id denotes U+FB00" from the fixture record +/// alone. What **is** derived from the fixture, for every entry +/// [`shaped_glyphs_presentation_form`] produces, is *which* clusters this +/// table applies to (the same glyph-count-vs-grapheme-count ligature +/// detection [`shaped_glyphs_form`] uses) and *what source text* each one +/// spans; the table is only ever consulted as a lookup keyed by that +/// already-derived source text, never used to invent a cluster boundary of +/// its own. +const LATIN_LIGATURE_PRESENTATION_FORMS: &[(&str, char)] = &[ + ("ff", '\u{FB00}'), + ("fi", '\u{FB01}'), + ("fl", '\u{FB02}'), + ("ffi", '\u{FB03}'), + ("ffl", '\u{FB04}'), + ("st", '\u{FB06}'), +]; + +/// A second, independently plausible rendering of "the tree exposes what was +/// drawn" (§8.3's `name-is-shaped-glyphs`): a tree that reverse-mapped glyph +/// ids through a cmap would most plausibly emit the *standard ligature +/// presentation-form codepoint* (e.g. U+FB00 for `ff`) rather than +/// [`shaped_glyphs_form`]'s truncate-to-glyph-count text. Returns `None` if +/// this fixture has no ligature cluster, **or** if it has one whose source +/// text is not in [`LATIN_LIGATURE_PRESENTATION_FORMS`] — this function never +/// guesses a codepoint it cannot look up. +pub fn shaped_glyphs_presentation_form(resolved: &SpikeResolvedText) -> Option { + let mut out = String::new(); + let mut substituted_any = false; + for cluster in &resolved.clusters.clusters { + let start = cluster.source.start as usize; + let end = cluster.source.end as usize; + let chunk = &resolved.text[start..end]; + let glyph_count = cluster.glyph_indices.len() as u32; + let is_ligature = glyph_count > 0 && glyph_count < cluster.grapheme_count; + if is_ligature { + match LATIN_LIGATURE_PRESENTATION_FORMS + .iter() + .find(|(seq, _)| *seq == chunk) + { + Some((_, presentation_char)) => { + out.push(*presentation_char); + substituted_any = true; + } + // A ligature cluster whose source text has no known + // presentation-form codepoint: this function cannot honestly + // produce a full-string answer, so it produces none at all + // rather than a partially-substituted guess. + None => return None, + } + } else { + out.push_str(chunk); + } + } + substituted_any.then_some(out) +} + +/// The concatenation a tree assembled by walking the run's visual runs left +/// to right would produce (§8.1). See the module doc comment for exactly +/// what this does and does not model. +pub fn visual_order_form(resolved: &SpikeResolvedText) -> String { + let mut out = String::new(); + for seg in &resolved.segments { + let start = seg.source.start as usize; + let end = seg.source.end as usize; + let chunk = &resolved.text[start..end]; + match seg.direction { + SpikeTextDirection::Rtl => { + let reversed: String = chunk.graphemes(true).rev().collect(); + out.push_str(&reversed); + } + SpikeTextDirection::Ltr => out.push_str(chunk), + } + } + out +} + +/// Groups a fixture's candidate `(outcome, form)` pairs into +/// `alternative_forms`, in three steps: +/// +/// 1. drop any candidate whose form is byte-identical to `expected_name` (it +/// cannot classify anything — see the module doc comment); +/// 2. **refuse** (panic, naming `fixture_id` and both outcomes) if the same +/// remaining form string is produced by two *different* outcome names — +/// O1's fail-closed backstop, independent of whichever derivation bug did +/// or did not cause it; +/// 3. otherwise group by outcome, deduplicating repeated identical forms +/// within one outcome's own list (the same classification derived twice is +/// not a collision), and drop any outcome left with an empty list. +/// +/// Kept as its own function, separate from [`build_expectation`], so it has a +/// unit test that can hand-construct a collision without needing a real +/// `FixtureRecord` to provoke one. +fn group_alternative_forms( + fixture_id: &str, + expected_name: &str, + candidates: Vec<(&'static str, String)>, +) -> BTreeMap> { + let mut owner_of: BTreeMap = BTreeMap::new(); + let mut grouped: BTreeMap> = BTreeMap::new(); + + for (outcome, form) in candidates { + debug_assert!( + PROHIBITED_OUTCOMES.contains(&outcome), + "{outcome} must be one of round2_textkit::a11y::PROHIBITED_OUTCOMES" + ); + if form == expected_name { + continue; + } + match owner_of.get(&form) { + Some(&existing_outcome) if existing_outcome != outcome => { + panic!( + "{fixture_id}: alternative forms {existing_outcome:?} and {outcome:?} both \ + produce {form:?} — an oracle that returns two different classifications for \ + the same observed string must fail closed, not pick one by BTreeMap \ + iteration order (O1). Fix the derivation so the two outcomes do not collide, \ + or establish that only one of them legitimately applies to this fixture." + ); + } + Some(_) => { + // Same outcome producing an identical form a second time + // (e.g. two independent derivations that happen to agree) — + // not a collision, just redundant; skip the duplicate. + } + None => { + owner_of.insert(form.clone(), outcome); + grouped.entry(outcome.to_string()).or_default().push(form); + } + } + } + grouped +} + +/// Builds one fixture's [`FixtureExpectation`] from its already-validated +/// `FixtureRecord`. +/// +/// `expected_name` and the role sets are restated from +/// `record.accessibility`, not recomputed from `record.resolved.text` — that +/// field was already checked against the recipe §2 literal by +/// `FixtureFile::validate` (via `load_fixtures`) before this function ever +/// runs, so re-deriving it here would be a second, redundant source of +/// truth rather than a check. +/// +/// Panics (via [`group_alternative_forms`]) if two different outcome names +/// would classify the same observed string for this fixture (O1). +pub fn build_expectation(record: &FixtureRecord) -> FixtureExpectation { + let a = &record.accessibility; + let resolved = &record.resolved; + + let accepted_roles = a + .accepted_roles + .iter() + .find(|m| m.platform == PLATFORM) + .unwrap_or_else(|| panic!("{}: no {PLATFORM} row in accepted_roles", record.id)) + .tokens + .clone(); + let prohibited_roles = a + .prohibited_roles + .iter() + .find(|m| m.platform == PLATFORM) + .unwrap_or_else(|| panic!("{}: no {PLATFORM} row in prohibited_roles", record.id)) + .tokens + .clone(); + + let mut candidates: Vec<(&'static str, String)> = vec![ + ("name-normalized", nfc_form(&a.name)), + ( + "name-drops-unresolved-codepoints", + drop_unresolved_codepoints_form(resolved), + ), + ("name-is-shaped-glyphs", shaped_glyphs_form(resolved)), + ]; + if let Some(presentation) = shaped_glyphs_presentation_form(resolved) { + candidates.push(("name-is-shaped-glyphs", presentation)); + } + let alternative_forms = group_alternative_forms(&record.id, &a.name, candidates); + + let visual = visual_order_form(resolved); + let (visual_order_name, visual_order_name_hex) = if visual != a.name { + let hex = hex_lower(visual.as_bytes()); + (Some(visual), Some(hex)) + } else { + (None, None) + }; + + FixtureExpectation { + fixture_id: record.id.clone(), + expected_name: a.name.clone(), + expected_name_hex: a.name_bytes_hex.clone(), + expected_name_byte_len: a.name_byte_len, + accepted_roles, + prohibited_roles, + source_atoms: source_atoms(resolved), + alternative_forms, + visual_order_name, + visual_order_name_hex, + } +} + +/// Builds the complete [`ExpectationsFile`] from an already-loaded, already- +/// validated `FixtureFile` (`round2_textkit::output::load_fixtures`). +pub fn build_expectations_file(file: &FixtureFile) -> ExpectationsFile { + ExpectationsFile { + contract: "spec/CONTRACT_EDITOR_T4_SPIKE.md pin 13".to_string(), + recipe: "spikes/editor-toolkit/ROUND2_TEXT_RECIPE.md §8".to_string(), + platform: PLATFORM.to_string(), + source_fixtures_digest: round2_textkit::output::artifact_digest(file), + fixtures: file.fixtures.iter().map(build_expectation).collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use round2_textkit::faces::{resolve_declared_chain, FaceResolution, LoadedFace}; + use round2_textkit::fixtures::{build_fixture, FIXTURES}; + use round2_textkit::output::build_fixture_file; + + /// Builds a real `FixtureFile` end to end against the actual declared + /// faces on this machine, the same path `round2-textkit`'s own tests and + /// `bin/generate.rs` take. `None` (test skipped, not failed — pin 14) if + /// either declared face is absent; on this development machine both are + /// present. + fn real_fixture_file() -> Option { + let resolved = resolve_declared_chain(); + let mut loaded: Vec = Vec::new(); + for r in resolved { + match r { + FaceResolution::Loaded(lf) => loaded.push(lf), + FaceResolution::Missing { .. } => return None, + } + } + let built: Vec<(String, String, SpikeResolvedText)> = FIXTURES + .iter() + .enumerate() + .map(|(i, def)| { + let rt = build_fixture(def, &loaded, i as u64); + (def.id.to_string(), def.purpose.to_string(), rt) + }) + .collect(); + Some(build_fixture_file(&loaded, built).expect("every fixture has a precommitted note")) + } + + fn require_file() -> FixtureFile { + real_fixture_file().expect( + "this test requires the two round2-textkit declared faces to be present on the \ + machine running it", + ) + } + + fn expectation_for<'a>(exp: &'a ExpectationsFile, id: &str) -> &'a FixtureExpectation { + exp.fixtures + .iter() + .find(|f| f.fixture_id == id) + .unwrap_or_else(|| panic!("no expectation built for {id}")) + } + + /// F-E's NFC form must differ from its (NFD) source text — the whole + /// reason F-E exists (recipe §2). If `nfc_form` stopped normalizing, or + /// F-E's source stopped being NFD, this fails. + #[test] + fn f_e_nfc_form_differs_from_its_text() { + let file = require_file(); + let f_e = file.fixtures.iter().find(|f| f.id == "F-E").unwrap(); + let nfc = nfc_form(&f_e.resolved.text); + assert_ne!( + nfc, f_e.resolved.text, + "F-E's NFC form must differ from its NFD source" + ); + // And it must actually surface in alternative_forms, keyed correctly. + let exp = build_expectations_file(&file); + let e = expectation_for(&exp, "F-E"); + assert_eq!( + e.alternative_forms.get("name-normalized"), + Some(&vec![nfc]), + "F-E must carry a name-normalized alternative form equal to its NFC" + ); + } + + /// F-C's dropped-codepoint form must be shorter than its source by + /// *exactly* the byte span of its unresolved (face: None) segment — not + /// merely shorter by some amount. + #[test] + fn f_c_dropped_codepoint_form_is_shorter_by_exactly_the_unresolved_span() { + let file = require_file(); + let f_c = file.fixtures.iter().find(|f| f.id == "F-C").unwrap(); + let unresolved_span: usize = f_c + .resolved + .segments + .iter() + .filter(|s| s.face.is_none()) + .map(|s| (s.source.end - s.source.start) as usize) + .sum(); + assert!( + unresolved_span > 0, + "anchor: F-C must have at least one unresolved segment" + ); + let dropped = drop_unresolved_codepoints_form(&f_c.resolved); + assert_eq!( + f_c.resolved.text.len() - dropped.len(), + unresolved_span, + "F-C's dropped-codepoint form must be shorter by exactly its unresolved span" + ); + assert_ne!(dropped, f_c.resolved.text); + + let exp = build_expectations_file(&file); + let e = expectation_for(&exp, "F-C"); + assert_eq!( + e.alternative_forms.get("name-drops-unresolved-codepoints"), + Some(&vec![dropped]) + ); + } + + /// O1's regression lock: F-C must carry exactly one alternative-outcome + /// classification (`name-drops-unresolved-codepoints`), never a second, + /// colliding `name-is-shaped-glyphs` entry for the same string. Before + /// the O1 fix, [`shaped_glyphs_form`] collapsed F-C's wholly-unresolved + /// cluster to nothing, which is byte-identical to the dropped-codepoint + /// form — this pins that `name-is-shaped-glyphs` is now correctly absent + /// for F-C (because it is byte-identical to `expected_name` once + /// zero-glyph clusters are left untouched), not merely that it happens + /// to agree with the other outcome. + #[test] + fn f_c_carries_no_shaped_glyphs_alternative_form() { + let file = require_file(); + let f_c = file.fixtures.iter().find(|f| f.id == "F-C").unwrap(); + assert_eq!( + shaped_glyphs_form(&f_c.resolved), + f_c.resolved.text, + "anchor: with the O1 fix, F-C's cluster-collapse form must equal its source text" + ); + let exp = build_expectations_file(&file); + let e = expectation_for(&exp, "F-C"); + assert!( + !e.alternative_forms.contains_key("name-is-shaped-glyphs"), + "F-C must not carry a name-is-shaped-glyphs alternative form: {:?}", + e.alternative_forms + ); + assert_eq!(e.alternative_forms.len(), 1); + } + + /// F-A's shaped-glyphs forms must differ from its source text — the + /// `ff`/`fi` ligature case §8.3 names — and both the cluster-collapse + /// form and the standard-ligature presentation-form substitution (O2) + /// must be present in the list. + #[test] + fn f_a_shaped_glyphs_forms_differ_from_its_text() { + let file = require_file(); + let f_a = file.fixtures.iter().find(|f| f.id == "F-A").unwrap(); + let has_ligature_cluster = f_a + .resolved + .clusters + .clusters + .iter() + .any(|c| (c.glyph_indices.len() as u32) < c.grapheme_count); + assert!( + has_ligature_cluster, + "anchor: F-A must have at least one cluster with fewer glyphs than graphemes" + ); + let collapsed = shaped_glyphs_form(&f_a.resolved); + assert_ne!(collapsed, f_a.resolved.text); + let presentation = shaped_glyphs_presentation_form(&f_a.resolved).expect( + "F-A's ligature clusters (ff, fi) are both in LATIN_LIGATURE_PRESENTATION_FORMS", + ); + assert_ne!(presentation, f_a.resolved.text); + assert_ne!( + presentation, collapsed, + "the two shaped-glyphs forms must be genuinely distinct renderings" + ); + assert!( + presentation.contains('\u{FB00}'), + "F-A's presentation form must substitute U+FB00 for the ff ligature: {presentation:?}" + ); + assert!( + presentation.contains('\u{FB01}'), + "F-A's presentation form must substitute U+FB01 for the fi ligature: {presentation:?}" + ); + + let exp = build_expectations_file(&file); + let e = expectation_for(&exp, "F-A"); + let forms = e + .alternative_forms + .get("name-is-shaped-glyphs") + .expect("F-A must carry a name-is-shaped-glyphs entry"); + assert!(forms.contains(&collapsed), "{forms:?}"); + assert!(forms.contains(&presentation), "{forms:?}"); + assert_eq!(forms.len(), 2, "{forms:?}"); + } + + /// F-D's visual-order form must differ from its logical text — the + /// composition trap §8.1 names. + #[test] + fn f_d_visual_order_form_differs_from_its_logical_text() { + let file = require_file(); + let f_d = file.fixtures.iter().find(|f| f.id == "F-D").unwrap(); + let has_rtl_segment = f_d + .resolved + .segments + .iter() + .any(|s| matches!(s.direction, SpikeTextDirection::Rtl)); + assert!(has_rtl_segment, "anchor: F-D must have an Rtl segment"); + let visual = visual_order_form(&f_d.resolved); + assert_ne!(visual, f_d.resolved.text); + + let exp = build_expectations_file(&file); + let e = expectation_for(&exp, "F-D"); + assert_eq!(e.visual_order_name.as_ref(), Some(&visual)); + assert_eq!( + e.visual_order_name_hex.as_deref(), + Some(hex_lower(visual.as_bytes()).as_str()) + ); + } + + /// D1: F-C's source atoms must be exactly its two segments, and the + /// unresolved one must stand alone as a single character — the specific + /// case a verifier-side length-2 substring rule cannot catch, and the + /// whole reason this field exists. + #[test] + fn f_c_source_atoms_are_its_two_segments_one_of_them_single_character() { + let file = require_file(); + let f_c = file.fixtures.iter().find(|f| f.id == "F-C").unwrap(); + assert_eq!( + f_c.resolved.segments.len(), + 2, + "anchor: F-C must have two segments" + ); + let expected: Vec = f_c + .resolved + .segments + .iter() + .map(|s| f_c.resolved.text[s.source.start as usize..s.source.end as usize].to_string()) + .collect(); + let atoms = source_atoms(&f_c.resolved); + assert_eq!(atoms, expected); + + let (_, unresolved_atom) = f_c + .resolved + .segments + .iter() + .zip(atoms.iter()) + .find(|(s, _)| s.face.is_none()) + .expect("anchor: F-C must have an unresolved segment"); + assert_eq!( + unresolved_atom.chars().count(), + 1, + "F-C's unresolved atom must be exactly one character: {unresolved_atom:?}" + ); + + let exp = build_expectations_file(&file); + let e = expectation_for(&exp, "F-C"); + assert_eq!(e.source_atoms, atoms); + } + + /// D1: F-D's source atoms must be exactly its three segments. + #[test] + fn f_d_source_atoms_are_its_three_segments() { + let file = require_file(); + let f_d = file.fixtures.iter().find(|f| f.id == "F-D").unwrap(); + assert_eq!( + f_d.resolved.segments.len(), + 3, + "anchor: F-D must have three segments" + ); + let expected: Vec = f_d + .resolved + .segments + .iter() + .map(|s| f_d.resolved.text[s.source.start as usize..s.source.end as usize].to_string()) + .collect(); + let atoms = source_atoms(&f_d.resolved); + assert_eq!(atoms, expected); + + let exp = build_expectations_file(&file); + let e = expectation_for(&exp, "F-D"); + assert_eq!(e.source_atoms, atoms); + } + + /// Every fixture's source atoms must concatenate back to its own + /// `expected_name` — the general partition property `source_atoms`'s own + /// doc comment claims, checked here on the real generated data rather + /// than only asserted in prose. + #[test] + fn source_atoms_concatenate_to_expected_name_for_every_fixture() { + let file = require_file(); + let exp = build_expectations_file(&file); + for f in &exp.fixtures { + let joined: String = f.source_atoms.concat(); + assert_eq!( + joined, f.expected_name, + "{}: source_atoms must concatenate to expected_name", + f.fixture_id + ); + } + } + + /// An alternative form byte-identical to `expected_name` must be omitted + /// entirely, never present with a value equal to the expectation — the + /// mutation this guards against is a verifier that reports a "match" as + /// a diagnosed FAIL because a no-op entry happened to be present. + #[test] + fn identical_alternative_forms_are_omitted_not_recorded_as_equal() { + let file = require_file(); + let exp = build_expectations_file(&file); + for f in &exp.fixtures { + for (outcome, forms) in &f.alternative_forms { + assert!( + !forms.is_empty(), + "{}: {outcome} must not be present with an empty list", + f.fixture_id + ); + for form in forms { + assert_ne!( + form, &f.expected_name, + "{}: alternative form {outcome} must not be recorded when byte-identical \ + to expected_name", + f.fixture_id + ); + } + } + if let Some(v) = &f.visual_order_name { + assert_ne!(v, &f.expected_name, "{}: visual_order_name", f.fixture_id); + } + } + } + + /// No fixture's `alternative_forms` may contain the same string under two + /// different outcome keys (O1) — re-checked here on the real, generated + /// data, in addition to [`group_alternative_forms_refuses_a_collision`]'s + /// synthetic unit test. + #[test] + fn no_fixture_has_the_same_form_under_two_outcomes() { + let file = require_file(); + let exp = build_expectations_file(&file); + for f in &exp.fixtures { + let mut seen: BTreeMap<&String, &String> = BTreeMap::new(); + for (outcome, forms) in &f.alternative_forms { + for form in forms { + if let Some(existing) = seen.insert(form, outcome) { + panic!( + "{}: {form:?} appears under both {existing:?} and {outcome:?}", + f.fixture_id + ); + } + } + } + } + } + + /// Every alternative-form key must be one of `PROHIBITED_OUTCOMES` — a + /// typo'd or invented key would silently fail to classify anything the + /// verifier actually checks for. + #[test] + fn every_alternative_form_key_is_a_prohibited_outcome() { + let file = require_file(); + let exp = build_expectations_file(&file); + for f in &exp.fixtures { + for outcome in f.alternative_forms.keys() { + assert!( + PROHIBITED_OUTCOMES.contains(&outcome.as_str()), + "{}: {outcome:?} is not in PROHIBITED_OUTCOMES", + f.fixture_id + ); + } + } + } + + /// The at-spi2 role rows restated here must equal + /// `round2_textkit::a11y`'s own at-spi2 row — this is the platform this + /// machine's live AT-SPI2 client actually queries (recipe §8.2, + /// round0-evidence's precedent). + #[test] + fn accepted_and_prohibited_roles_match_the_atspi2_row() { + let file = require_file(); + let exp = build_expectations_file(&file); + let expected_accepted: Vec = round2_textkit::a11y::ACCEPTED_ROLE_TABLE + .iter() + .find(|(p, _)| *p == PLATFORM) + .unwrap() + .1 + .iter() + .map(|s| s.to_string()) + .collect(); + let expected_prohibited: Vec = round2_textkit::a11y::PROHIBITED_ROLE_TABLE + .iter() + .find(|(p, _)| *p == PLATFORM) + .unwrap() + .1 + .iter() + .map(|s| s.to_string()) + .collect(); + for f in &exp.fixtures { + assert_eq!(f.accepted_roles, expected_accepted); + assert_eq!(f.prohibited_roles, expected_prohibited); + } + } + + /// JSON round-trips without loss — the shape a consumer other than this + /// crate (`a11y-verifier/verify.py`) will actually read. + #[test] + fn json_round_trip_preserves_the_expectations() { + let file = require_file(); + let exp = build_expectations_file(&file); + let json = serde_json::to_string_pretty(&exp).unwrap(); + let reloaded: ExpectationsFile = serde_json::from_str(&json).unwrap(); + assert_eq!(reloaded, exp); + } + + /// All five fixtures must be present, in order. + #[test] + fn all_five_fixtures_are_present_in_order() { + let file = require_file(); + let exp = build_expectations_file(&file); + let ids: Vec<&str> = exp.fixtures.iter().map(|f| f.fixture_id.as_str()).collect(); + assert_eq!(ids, vec!["F-A", "F-B", "F-C", "F-D", "F-E"]); + } + + /// B2: `source_fixtures_digest` must equal + /// `round2_textkit::output::expected_artifact_digest()` — the same + /// literal `round2-textkit`'s own `bin/generate` prints and its + /// `FixtureFile::validate` checks the *loaded* file against. This is the + /// generation-time half of B2's staleness guard: if `fixtures.json` ever + /// legitimately changes (a new frozen digest), this test catches that + /// `round2-a11y-oracle` was not regenerated against it, at test time, + /// before `a11y-verifier/verify.py`'s `--expect-source-digest` check + /// would ever catch it live. + #[test] + fn source_fixtures_digest_matches_round2_textkit_expected_digest() { + let file = require_file(); + let exp = build_expectations_file(&file); + assert_eq!( + exp.source_fixtures_digest, + round2_textkit::output::expected_artifact_digest() + ); + } + + // ---- O1: group_alternative_forms, exercised directly (no live fixture + // data required, so the collision-refusal logic itself is under test + // regardless of whether any current fixture happens to trigger it). ---- + + #[test] + fn group_alternative_forms_refuses_a_collision() { + let result = std::panic::catch_unwind(|| { + group_alternative_forms( + "F-TEST", + "expected", + vec![ + ("name-normalized", "same-string".to_string()), + ("name-is-shaped-glyphs", "same-string".to_string()), + ], + ) + }); + let err = result.expect_err("a collision between two outcomes must panic"); + let msg = err + .downcast_ref::() + .cloned() + .or_else(|| err.downcast_ref::<&str>().map(|s| s.to_string())) + .expect("panic payload must be a string"); + assert!(msg.contains("F-TEST"), "{msg}"); + assert!(msg.contains("name-normalized"), "{msg}"); + assert!(msg.contains("name-is-shaped-glyphs"), "{msg}"); + } + + /// Mutation guard: the same outcome producing the same form twice (e.g. + /// two derivations that happen to agree) must NOT panic — only a + /// cross-outcome collision is refused. Without this test, a mutation that + /// made the collision check fire on any duplicate (not just a + /// cross-outcome one) would still pass + /// `group_alternative_forms_refuses_a_collision` above. + #[test] + fn group_alternative_forms_deduplicates_a_same_outcome_repeat_without_panicking() { + let grouped = group_alternative_forms( + "F-TEST", + "expected", + vec![ + ("name-normalized", "same-string".to_string()), + ("name-normalized", "same-string".to_string()), + ], + ); + assert_eq!( + grouped.get("name-normalized"), + Some(&vec!["same-string".to_string()]) + ); + } + + #[test] + fn group_alternative_forms_omits_forms_identical_to_expected_name() { + let grouped = group_alternative_forms( + "F-TEST", + "expected", + vec![ + ("name-normalized", "expected".to_string()), + ("name-is-shaped-glyphs", "different".to_string()), + ], + ); + assert!(!grouped.contains_key("name-normalized")); + assert_eq!( + grouped.get("name-is-shaped-glyphs"), + Some(&vec!["different".to_string()]) + ); + } +} diff --git a/spikes/editor-toolkit/round2-candidatekit/Cargo.toml b/spikes/editor-toolkit/round2-candidatekit/Cargo.toml new file mode 100644 index 0000000..900dd04 --- /dev/null +++ b/spikes/editor-toolkit/round2-candidatekit/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "round2-candidatekit" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +# Packet 2B-0 (ROUND2_TEXT_RECIPE.md, spec/CONTRACT_EDITOR_T4_SPIKE.md pins +# 8, 9, 10, 13, 14): the ONLY code shared between the two Round 2 text +# candidates (C1 = egui+lyon, C2 = vello). See src/lib.rs's crate doc +# comment for the neutrality boundary this crate exists to hold — it loads +# and validates Packet 2A's fixtures/probes/reference apparatus, and defines +# the shared report shape and scoring rule both candidates are measured +# against. It does NOT render, resolve hit tests, or build accessibility +# trees. +# +# tests/dependency_deny_list.rs enforces the boundary by reading THIS file +# at test time, not by convention: it fails if a rendering, windowing, GPU, +# or platform-accessibility crate is ever added to [dependencies] below. + +[dependencies] +serde = { version = "1", features = ["derive"] } +serde_json = "1" +round2-textkit = { path = "../round2-textkit" } +round2-diff = { path = "../round2-diff" } diff --git a/spikes/editor-toolkit/round2-candidatekit/src/inputs.rs b/spikes/editor-toolkit/round2-candidatekit/src/inputs.rs new file mode 100644 index 0000000..b471ac7 --- /dev/null +++ b/spikes/editor-toolkit/round2-candidatekit/src/inputs.rs @@ -0,0 +1,332 @@ +//! Loads and validates the candidate-neutral apparatus Packet 2A built: +//! fixtures, the hit-test probe table, and the per-fixture reference raster +//! + regions. Every failure here names the specific file and what was wrong +//! with it — see [`load_all`]. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use round2_diff::GlyphRegion; +use round2_textkit::hittest::HitTestProbeFile; +use round2_textkit::output::FixtureFile; + +/// Pin 4's offscreen target, restated as a literal (the same discipline +/// every other crate in this workspace uses: a loader checks a file against +/// a stated constant, never trusts the file to agree with itself). +pub const WIDTH: u32 = 1920; +pub const HEIGHT: u32 = 1080; +const EXPECTED_RGBA_LEN: usize = (WIDTH as usize) * (HEIGHT as usize) * 4; + +/// The on-disk shape of one entry in `.regions.json` +/// (`round2-reference/output/`), matching the fields `round2-reference`'s +/// own `RegionRecord` writes. Deserialized here rather than depended on +/// directly, because `round2-reference` pulls in `round2-svgref`, which +/// pulls in `resvg`/`usvg`/`tiny-skia` — exactly the rendering dependencies +/// this crate's neutrality boundary forbids. The region *files* are neutral +/// data; the crate that produced them is not. +/// +/// **This is an implicit cross-crate schema with no shared type** — +/// `round2-reference`'s own `RegionRecord` and this one are two +/// independent hand-written structs that happen to agree on field names. +/// `deny_unknown_fields` is what turns a future drift between them into a +/// *named parse error at this crate's boundary* rather than a silently +/// ignored field: without it, serde drops unknown fields by default, and a +/// field `round2-reference` starts writing (or renames) would pass through +/// here unnoticed. +#[derive(Clone, Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +struct RegionRecord { + label: String, + x0: u32, + y0: u32, + x1: u32, + y1: u32, +} + +impl From for GlyphRegion { + fn from(r: RegionRecord) -> Self { + GlyphRegion { + label: r.label, + x0: r.x0, + y0: r.y0, + x1: r.x1, + y1: r.y1, + } + } +} + +/// One fixture's reference apparatus: the rasterized reference image +/// (already length-checked), its D4 regions (already checked non-empty), +/// and the paths they were loaded from (traceability for a `FAIL`). +#[derive(Clone, Debug)] +pub struct ReferenceFixture { + pub fixture_id: String, + pub reference_rgba: Vec, + pub regions: Vec, + pub rgba_path: PathBuf, + pub regions_path: PathBuf, +} + +/// Every candidate-neutral input Packet 2A built, loaded and validated in +/// one call ([`load_all`]). +#[derive(Debug)] +pub struct NeutralInputs { + pub fixtures: FixtureFile, + pub hittest_probes: HitTestProbeFile, + /// Keyed by fixture id (`F-A`..`F-E`). + pub reference: BTreeMap, +} + +/// Loads `fixtures.json`, `hittest_probes.json`, and every fixture's +/// reference raster + regions, from the standard Packet 2A layout under +/// `spike_root` (`round2-textkit/fixtures.json`, +/// `round2-textkit/hittest_probes.json`, +/// `round2-reference/output/.rgba`, +/// `round2-reference/output/.regions.json`). +/// +/// Every failure names the specific file and what was wrong with it: +/// +/// - `fixtures.json` / `hittest_probes.json`: read/parse errors, or a +/// [`round2_textkit::output::FixtureFile::validate`] / +/// [`round2_textkit::hittest::HitTestProbeFile::validate`] failure +/// (digest mismatch, probe-table drift, ...) — propagated verbatim; those +/// loaders already name the path and the specific disagreement. +/// - `.rgba`: refused if its length is not exactly `1920 * 1080 * 4` +/// bytes ([`WIDTH`] x [`HEIGHT`] x 4 RGBA8), naming the file and the +/// actual length. +/// - `.regions.json`: refused if missing, unparsable, or **empty**. +/// This crate refuses an empty region list itself, naming the file, +/// rather than silently handing it to `round2_diff::diff` — which also +/// refuses an empty list (`diff` panics on nothing, it returns an `Err`), +/// but with a message that has no idea which file on disk was empty. +pub fn load_all(spike_root: &Path) -> Result { + let fixtures_path = spike_root.join("round2-textkit/fixtures.json"); + let fixtures = round2_textkit::output::load_fixtures(&fixtures_path)?; + + let hittest_path = spike_root.join("round2-textkit/hittest_probes.json"); + let hittest_probes = round2_textkit::hittest::load_hittest_probes(&hittest_path, &fixtures)?; + + let mut reference = BTreeMap::new(); + for f in &fixtures.fixtures { + let rgba_path = spike_root + .join("round2-reference/output") + .join(format!("{}.rgba", f.id)); + let rgba = std::fs::read(&rgba_path).map_err(|e| { + format!( + "{}: failed to read reference raster: {e}", + rgba_path.display() + ) + })?; + if rgba.len() != EXPECTED_RGBA_LEN { + return Err(format!( + "{}: reference raster is {} bytes, expected exactly {EXPECTED_RGBA_LEN} \ + ({WIDTH}x{HEIGHT} RGBA8) — a short or padded buffer cannot be sampled safely", + rgba_path.display(), + rgba.len() + )); + } + + let regions_path = spike_root + .join("round2-reference/output") + .join(format!("{}.regions.json", f.id)); + let regions_text = std::fs::read_to_string(®ions_path).map_err(|e| { + format!( + "{}: failed to read region file: {e}", + regions_path.display() + ) + })?; + let records: Vec = serde_json::from_str(®ions_text).map_err(|e| { + format!( + "{}: failed to parse region file: {e}", + regions_path.display() + ) + })?; + if records.is_empty() { + return Err(format!( + "{}: region list is empty — refusing here, before this could reach \ + round2_diff::diff (which also refuses an empty region list, but with a message \ + that does not name which file on disk was empty)", + regions_path.display() + )); + } + let regions: Vec = records.into_iter().map(GlyphRegion::from).collect(); + + reference.insert( + f.id.clone(), + ReferenceFixture { + fixture_id: f.id.clone(), + reference_rgba: rgba, + regions, + rgba_path, + regions_path, + }, + ); + } + + Ok(NeutralInputs { + fixtures, + hittest_probes, + reference, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The real spike workspace root: this crate's manifest directory is + /// `spikes/editor-toolkit/round2-candidatekit`, one level below root. + fn real_spike_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..") + } + + fn read_real(rel: &str) -> Vec { + std::fs::read(real_spike_root().join(rel)) + .unwrap_or_else(|e| panic!("failed to read real {rel}: {e}")) + } + + /// A fresh, uniquely named directory under the OS temp dir (never under + /// the repo working tree, so these tests cannot leave stray files for + /// `git status` to notice), laid out like a spike root's + /// `round2-textkit/` + `round2-reference/output/` — enough for + /// `load_all` to be pointed at it. + fn scratch_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "round2-candidatekit-test-{name}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("round2-textkit")).unwrap(); + std::fs::create_dir_all(dir.join("round2-reference/output")).unwrap(); + dir + } + + fn write(path: &Path, bytes: &[u8]) { + std::fs::write(path, bytes) + .unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display())); + } + + /// Copies the real, committed, valid `fixtures.json` and + /// `hittest_probes.json` into `dir` — the two files every scenario + /// below needs unmutated so the failure under test is isolated to the + /// one file each test actually breaks. + fn seed_valid_fixtures_and_hittest(dir: &Path) { + write( + &dir.join("round2-textkit/fixtures.json"), + &read_real("round2-textkit/fixtures.json"), + ); + write( + &dir.join("round2-textkit/hittest_probes.json"), + &read_real("round2-textkit/hittest_probes.json"), + ); + } + + #[test] + fn load_all_succeeds_against_the_real_committed_apparatus() { + let inputs = load_all(&real_spike_root()).expect("real apparatus must load"); + assert_eq!(inputs.fixtures.fixtures.len(), 5); + assert_eq!(inputs.reference.len(), 5); + for id in ["F-A", "F-B", "F-C", "F-D", "F-E"] { + assert!(inputs.reference.contains_key(id), "missing {id}"); + let rf = &inputs.reference[id]; + assert_eq!(rf.reference_rgba.len(), EXPECTED_RGBA_LEN); + assert!(!rf.regions.is_empty()); + } + } + + /// Required kill: a `.rgba` of the wrong length is refused, naming the + /// file. + #[test] + fn a_wrong_length_rgba_is_refused_and_the_file_is_named() { + let dir = scratch_dir("wrong-length-rgba"); + seed_valid_fixtures_and_hittest(&dir); + write( + &dir.join("round2-reference/output/F-A.rgba"), + &vec![0u8; 100], + ); + let err = load_all(&dir).unwrap_err(); + assert!(err.contains("F-A.rgba"), "{err}"); + assert!(err.contains("100 bytes"), "{err}"); + assert!(err.contains("8294400"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Required kill: an empty region list is refused here — with a message + /// naming this file — rather than silently reaching + /// `round2_diff::diff`. + #[test] + fn an_empty_region_list_is_refused_before_it_could_reach_diff() { + let dir = scratch_dir("empty-regions"); + seed_valid_fixtures_and_hittest(&dir); + write( + &dir.join("round2-reference/output/F-A.rgba"), + &vec![0u8; EXPECTED_RGBA_LEN], + ); + write(&dir.join("round2-reference/output/F-A.regions.json"), b"[]"); + let err = load_all(&dir).unwrap_err(); + assert!(err.contains("F-A.regions.json"), "{err}"); + assert!(err.contains("empty"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A missing region file (never written at all, as opposed to written + /// empty) is refused and named — the other half of "missing region + /// file" in the required API's failure list, distinct from the + /// empty-but-present case above. + #[test] + fn a_missing_region_file_is_refused_and_named() { + let dir = scratch_dir("missing-regions"); + seed_valid_fixtures_and_hittest(&dir); + write( + &dir.join("round2-reference/output/F-A.rgba"), + &vec![0u8; EXPECTED_RGBA_LEN], + ); + // F-A.regions.json is deliberately never written. + let err = load_all(&dir).unwrap_err(); + assert!(err.contains("F-A.regions.json"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Required kill: a tampered `fixtures.json` digest is refused. Uses + /// the same mutation `round2-textkit`'s own + /// `validate_kills_a_changed_glyph_id` test does (change one glyph id + /// deep inside a fixture, leaving every named/counted field valid) — + /// only the whole-artifact digest catches it, which is exactly why this + /// crate's loader must not skip that check. + #[test] + fn a_tampered_fixtures_digest_is_refused() { + let dir = scratch_dir("tampered-digest"); + let mut tampered = round2_textkit::output::load_fixtures( + &real_spike_root().join("round2-textkit/fixtures.json"), + ) + .expect("real fixtures.json must load"); + let g = &mut tampered.fixtures[0].resolved.segments[0].glyphs[3]; + g.glyph_id = 9999; + let json = serde_json::to_string_pretty(&tampered).unwrap(); + write(&dir.join("round2-textkit/fixtures.json"), json.as_bytes()); + let err = load_all(&dir).unwrap_err(); + assert!(err.contains("digest"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// F5: `.regions.json` is an implicit contract between + /// `round2-reference` (which writes it) and this crate (which reads + /// it), with no shared type. An extra field must be refused **by + /// name**, not silently dropped — that is what turns a future schema + /// drift into a named parse error here instead of quiet data loss. + #[test] + fn an_unknown_field_in_a_region_record_is_refused_by_name() { + let json = serde_json::json!([{ + "label": "x", + "x0": 0, + "y0": 0, + "x1": 1, + "y1": 1, + "smuggled_field": 1 + }]); + let err = serde_json::from_value::>(json) + .unwrap_err() + .to_string(); + assert!(err.contains("smuggled_field"), "{err}"); + } +} diff --git a/spikes/editor-toolkit/round2-candidatekit/src/lib.rs b/spikes/editor-toolkit/round2-candidatekit/src/lib.rs new file mode 100644 index 0000000..9f9ee66 --- /dev/null +++ b/spikes/editor-toolkit/round2-candidatekit/src/lib.rs @@ -0,0 +1,71 @@ +//! # round2-candidatekit — Packet 2B-0: the candidate-neutral apparatus, and +//! **nothing else**. +//! +//! `spec/CONTRACT_EDITOR_T4_SPIKE.md` Round 2 scores criterion 3 (text) via +//! the five checks `spec/ANALYSIS_TEXT_RUN_PRIMITIVES.md` (W3) §5 names. +//! Packet 2A built every piece of candidate-neutral apparatus those checks +//! are measured against (fixtures, the hit-test probe table, the reference +//! rasters and D4 regions, the accessibility oracle). This crate is Packet +//! 2B-0: it is what the two Round 2 candidates — **C1** (egui + lyon) and +//! **C2** (vello) — both depend on, so that neither one re-derives fixture +//! loading, and neither one gets to define the scoring rule for itself. +//! +//! ## The neutrality boundary — this is the point of the crate +//! +//! The user's ruling, verbatim: **"Share only neutral fixture/oracle +//! loading. Rendering, hit testing, and accessibility integration remain +//! candidate-owned."** +//! +//! This crate **MAY** contain: +//! +//! - Loading and validating fixtures, the probe table, the reference +//! rasters and region files, and the a11y expectations +//! ([`inputs::load_all`]). +//! - The shared *report* data shape both candidates emit, and its +//! serialization ([`report::CandidateReport`] and its constituent types). +//! - The scoring rule that turns per-check outcomes into the criterion cell +//! ([`scoring::criterion_cell`], [`scoring::is_eligible`]). +//! +//! This crate **MUST NOT** contain: +//! +//! - Any rendering, rasterization, path/outline conversion, or +//! tessellation. +//! - Any hit-test *resolution* — i.e. nothing that answers "which byte +//! offset does this device point select". Loading the expected answers +//! ([`round2_textkit::hittest::HitTestProbeFile`]) is neutral; computing +//! them is the candidate's job and the thing check 4 measures. This crate +//! only carries the *shape* of a recorded comparison +//! ([`report::HitTestProbeResult`]) — it never resolves one. +//! - Any accessibility node construction or platform-adapter code. This +//! crate only carries the *shape* of observed evidence +//! ([`report::A11yEvidence`]) against the precommitted oracle +//! ([`round2_textkit::a11y`]) — it never builds a tree. +//! +//! `tests/dependency_deny_list.rs` enforces what code review can miss: it +//! reads this crate's own `Cargo.toml` at test time and fails if `egui`, +//! `eframe`, `egui-wgpu`, `lyon`, `lyon_path`, `lyon_tessellation`, `vello`, +//! `wgpu`, `winit`, `accesskit`, `accesskit_winit`, `tiny-skia`, `resvg`, or +//! `usvg` is ever named in `[dependencies]`. +//! +//! ## What this crate does not decide +//! +//! [`scoring::criterion_cell`] implements the contract's outcome rule; it +//! does not implement W3 §5 itself, and it is not the place check 3's +//! `NOT RUN` ruling was *made* — that ruling is `ROUND2_TEXT_RECIPE.md` +//! §1.2, and this crate only encodes and enforces its consequences. + +pub mod inputs; +pub mod outcome; +pub mod report; +pub mod scoring; + +pub use inputs::{load_all, NeutralInputs, ReferenceFixture}; +pub use outcome::CheckOutcome; +pub use report::{ + A11yEvidence, AdapterStatus, BusUnreachableEvidence, CandidateReport, CostRecord, + DependencyDelta, DiffReportRecord, HitTestProbeResult, LocByPart, RegionMassRecord, ReportPart, +}; +pub use scoring::{ + criterion_cell, is_eligible, CellOutcome, CHECK_3_RULING, DISQUALIFYING_CHECKS, + ROUND0_READBACK_EVIDENCE, ROUND_PLATFORM, +}; diff --git a/spikes/editor-toolkit/round2-candidatekit/src/outcome.rs b/spikes/editor-toolkit/round2-candidatekit/src/outcome.rs new file mode 100644 index 0000000..faea637 --- /dev/null +++ b/spikes/editor-toolkit/round2-candidatekit/src/outcome.rs @@ -0,0 +1,270 @@ +//! The per-check outcome type both candidates report against. + +use serde::{Deserialize, Deserializer, Serialize}; + +/// One check's outcome. Exactly three states, and **both non-`Pass` states +/// carry a reason**: pin 14 requires an environmental `NotRun` to record +/// *why* it could not run, and a bare `Fail` with no reason would be +/// exactly the unfalsifiable report `round1-oracle`'s discipline exists to +/// forbid. There is deliberately no unit-only `NotRun` or `Fail` variant — +/// a candidate cannot report "did not pass" without saying why. +/// +/// **An empty or whitespace-only reason is a bare reason wearing a +/// string.** The checked constructors ([`CheckOutcome::fail`], +/// [`CheckOutcome::not_run`]) and this type's `Deserialize` impl both +/// reject one — those are the two paths a candidate actually uses to +/// produce a `CandidateReport` (build it in Rust, or read one back from +/// JSON). The variants' payloads stay `pub` because a fully private field +/// would need a getter/setter pair that adds ceremony without closing any +/// path a candidate is expected to take; the invalid state is +/// unconstructible through construction *and* deserialization, which is +/// what "a reason is required" needs to mean in practice. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub enum CheckOutcome { + Pass, + Fail(String), + NotRun(String), +} + +impl CheckOutcome { + /// Checked constructor: rejects an empty-or-whitespace-only reason. + pub fn fail(reason: impl Into) -> Result { + let reason = reason.into(); + if reason.trim().is_empty() { + return Err( + "CheckOutcome::fail: reason must not be empty or whitespace-only — a Fail with \ + no reason is exactly the unfalsifiable report this type exists to forbid" + .to_string(), + ); + } + Ok(CheckOutcome::Fail(reason)) + } + + /// Checked constructor: rejects an empty-or-whitespace-only reason. + pub fn not_run(reason: impl Into) -> Result { + let reason = reason.into(); + if reason.trim().is_empty() { + return Err( + "CheckOutcome::not_run: reason must not be empty or whitespace-only — pin 14 \ + requires the environmental cause to be recorded, not merely gestured at" + .to_string(), + ); + } + Ok(CheckOutcome::NotRun(reason)) + } + + /// Ordering used by [`crate::scoring::criterion_cell`]'s worst-of-five + /// rule: `Pass` < `NotRun` < `Fail`. Higher is worse. + pub(crate) fn severity_rank(&self) -> u8 { + match self { + CheckOutcome::Pass => 0, + CheckOutcome::NotRun(_) => 1, + CheckOutcome::Fail(_) => 2, + } + } + + pub fn is_pass(&self) -> bool { + matches!(self, CheckOutcome::Pass) + } + + pub fn is_fail(&self) -> bool { + matches!(self, CheckOutcome::Fail(_)) + } + + pub fn is_not_run(&self) -> bool { + matches!(self, CheckOutcome::NotRun(_)) + } +} + +/// The wire shape `CheckOutcome` deserializes through — identical variants +/// and payloads, `#[serde(deny_unknown_fields)]` for the same structural- +/// drift reason every deserializable type in this workspace uses it, kept +/// as a **separate, private** type so [`CheckOutcome`]'s own `Deserialize` +/// impl can run [`CheckOutcome::fail`]/[`CheckOutcome::not_run`]'s +/// empty-reason check on the way through, which `#[derive(Deserialize)]` +/// on `CheckOutcome` directly could not do. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +enum CheckOutcomeWire { + Pass, + Fail(String), + NotRun(String), +} + +impl TryFrom for CheckOutcome { + type Error = String; + + fn try_from(wire: CheckOutcomeWire) -> Result { + match wire { + CheckOutcomeWire::Pass => Ok(CheckOutcome::Pass), + CheckOutcomeWire::Fail(reason) => CheckOutcome::fail(reason), + CheckOutcomeWire::NotRun(reason) => CheckOutcome::not_run(reason), + } + } +} + +impl<'de> Deserialize<'de> for CheckOutcome { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let wire = CheckOutcomeWire::deserialize(deserializer)?; + CheckOutcome::try_from(wire).map_err(serde::de::Error::custom) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn severity_orders_pass_below_not_run_below_fail() { + assert!( + CheckOutcome::Pass.severity_rank() < CheckOutcome::NotRun("x".into()).severity_rank() + ); + assert!( + CheckOutcome::NotRun("x".into()).severity_rank() + < CheckOutcome::Fail("x".into()).severity_rank() + ); + } + + #[test] + fn predicates_agree_with_the_variant() { + assert!(CheckOutcome::Pass.is_pass()); + assert!(!CheckOutcome::Pass.is_fail()); + assert!(!CheckOutcome::Pass.is_not_run()); + + assert!(CheckOutcome::Fail("x".into()).is_fail()); + assert!(!CheckOutcome::Fail("x".into()).is_pass()); + + assert!(CheckOutcome::NotRun("x".into()).is_not_run()); + assert!(!CheckOutcome::NotRun("x".into()).is_pass()); + } + + #[test] + fn round_trips_through_json() { + for outcome in [ + CheckOutcome::Pass, + CheckOutcome::Fail("reason".to_string()), + CheckOutcome::NotRun("reason".to_string()), + ] { + let json = serde_json::to_string(&outcome).unwrap(); + let back: CheckOutcome = serde_json::from_str(&json).unwrap(); + assert_eq!(outcome, back); + } + } + + // ---- F6: a real distinguishing assertion, not `len() > 0` ---- + + /// A bare JSON string `"NotRun"` does not match the tuple-variant shape + /// `NotRun(String)` at all (that shape serializes as + /// `{"NotRun": "..."}`), so this is a **structural** deserialize + /// failure — distinct from the empty-reason rejection below, which + /// targets a `NotRun` that *does* carry a payload, just an empty one. + /// Asserts on serde's actual reported type mismatch (a unit-shaped + /// value where a payload-carrying variant was required), which is what + /// actually distinguishes this rejection from every other kind of + /// deserialize failure this file tests — not on "some error happened" + /// (measured: `err.to_string()` is `"invalid type: unit variant, + /// expected newtype variant"`, which names neither `NotRun` nor `Fail` + /// by name, so asserting on the variant name would itself have been + /// wrong). + #[test] + fn a_bare_string_not_run_with_no_payload_fails_to_deserialize() { + let bad = serde_json::json!("NotRun"); + let err = serde_json::from_value::(bad) + .unwrap_err() + .to_string(); + assert!(err.contains("unit variant"), "{err}"); + assert!(err.contains("newtype variant"), "{err}"); + } + + // ---- F3: an empty or whitespace-only reason is refused ---- + + #[test] + fn the_fail_constructor_rejects_an_empty_reason() { + let err = CheckOutcome::fail("").unwrap_err(); + assert!(err.contains("empty"), "{err}"); + } + + #[test] + fn the_fail_constructor_rejects_a_whitespace_only_reason() { + let err = CheckOutcome::fail(" \t ").unwrap_err(); + assert!(err.contains("empty"), "{err}"); + } + + #[test] + fn the_fail_constructor_accepts_a_real_reason() { + let outcome = CheckOutcome::fail("host-substituted the Hebrew segment").unwrap(); + assert_eq!( + outcome, + CheckOutcome::Fail("host-substituted the Hebrew segment".to_string()) + ); + } + + #[test] + fn the_not_run_constructor_rejects_an_empty_reason() { + let err = CheckOutcome::not_run("").unwrap_err(); + assert!(err.contains("empty"), "{err}"); + } + + #[test] + fn the_not_run_constructor_rejects_a_whitespace_only_reason() { + let err = CheckOutcome::not_run("\n").unwrap_err(); + assert!(err.contains("empty"), "{err}"); + } + + #[test] + fn the_not_run_constructor_accepts_a_real_reason() { + let outcome = CheckOutcome::not_run("no Arabic-capable face installed").unwrap(); + assert_eq!( + outcome, + CheckOutcome::NotRun("no Arabic-capable face installed".to_string()) + ); + } + + /// Guards the deserialize path the same way the constructors guard + /// direct construction: a `Fail` with an empty string payload must be + /// refused on the way in from JSON, not merely by a constructor a + /// candidate could route around by deserializing instead. + #[test] + fn deserializing_an_empty_reason_fail_is_refused() { + let bad = serde_json::json!({"Fail": ""}); + let err = serde_json::from_value::(bad) + .unwrap_err() + .to_string(); + assert!(err.contains("empty"), "{err}"); + } + + #[test] + fn deserializing_a_whitespace_only_reason_not_run_is_refused() { + let bad = serde_json::json!({"NotRun": " "}); + let err = serde_json::from_value::(bad) + .unwrap_err() + .to_string(); + assert!(err.contains("empty"), "{err}"); + } + + #[test] + fn deserializing_a_real_reason_still_works() { + let good = serde_json::json!({"Fail": "a real reason"}); + let outcome: CheckOutcome = serde_json::from_value(good).unwrap(); + assert_eq!(outcome, CheckOutcome::Fail("a real reason".to_string())); + } + + /// An unknown variant name must still be refused — `CheckOutcomeWire`'s + /// own shape carries forward through the custom `Deserialize` impl + /// rather than being silently lost when `CheckOutcome` stopped deriving + /// it directly. Measured: `err.to_string()` is `"unknown variant + /// \`Passed\`, expected one of \`Pass\`, \`Fail\`, \`NotRun\`"`, so the + /// specific bad name is named in the message. + #[test] + fn an_unknown_variant_name_is_refused() { + let bad = serde_json::json!({"Passed": null}); + let err = serde_json::from_value::(bad) + .unwrap_err() + .to_string(); + assert!(err.contains("unknown variant"), "{err}"); + assert!(err.contains("Passed"), "{err}"); + } +} diff --git a/spikes/editor-toolkit/round2-candidatekit/src/report.rs b/spikes/editor-toolkit/round2-candidatekit/src/report.rs new file mode 100644 index 0000000..3ed0b41 --- /dev/null +++ b/spikes/editor-toolkit/round2-candidatekit/src/report.rs @@ -0,0 +1,486 @@ +//! The shared report shape both candidates emit ([`CandidateReport`]), plus +//! serializable mirrors of `round2-diff`'s pass/fail types. +//! +//! `round2-diff` is a reviewed, frozen packet — its own `Cargo.toml` doc +//! comment states it is "deliberately zero dependencies", and this crate +//! does not modify it to add a `serde` derive it does not otherwise need. +//! [`DiffReportRecord`] and [`RegionMassRecord`] are lossless mirrors, with +//! an infallible `From` conversion, of `round2_diff::DiffReport` and +//! `round2_diff::RegionMass` — the same pattern `round2-reference`'s +//! `RegionRecord` uses for `round2_diff::GlyphRegion`. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; + +use round2_diff::{DiffReport, RegionMass}; +use round2_textkit::hittest::DevicePoint; +use round2_textkit::types::SpikeCaretAffinity; + +use crate::outcome::CheckOutcome; + +/// Serializable mirror of `round2_diff::RegionMass`. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RegionMassRecord { + pub label: String, + pub reference_mass: f64, + pub candidate_mass: f64, + pub relative_delta: f64, + pub pass: bool, +} + +impl From<&RegionMass> for RegionMassRecord { + fn from(r: &RegionMass) -> Self { + RegionMassRecord { + label: r.label.clone(), + reference_mass: r.reference_mass, + candidate_mass: r.candidate_mass, + relative_delta: r.relative_delta, + pass: r.pass, + } + } +} + +/// Serializable mirror of `round2_diff::DiffReport` — see this module's doc +/// comment for why this crate mirrors rather than modifies `round2-diff`. +/// `pass` is [`DiffReport::pass`]'s own computed verdict, stored rather than +/// re-derived, so a report read back from JSON does not need the four +/// D-rule fields recomputed by hand to know its own outcome. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DiffReportRecord { + pub width: u32, + pub height: u32, + pub band_pixel_count: u64, + pub d1_pixels_outside_band_differing: u64, + pub d1_pass: bool, + pub reference_ink_mass: f64, + pub candidate_ink_mass: f64, + pub d2_relative_delta: f64, + pub d2_pass: bool, + pub reference_centroid: Option<(f64, f64)>, + pub candidate_centroid: Option<(f64, f64)>, + pub d3_delta: Option<(f64, f64)>, + pub d3_pass: Option, + pub in_band_max_abs_delta_luma: u8, + pub in_band_count_delta_gt_report_threshold: u64, + pub d4_regions: Vec, + pub d4_pass: bool, + pub d4_worst: Option, + /// [`DiffReport::pass`]'s overall verdict: D1, D2, D4 must all hold, + /// and D3 must either hold or be inapplicable. + pub pass: bool, +} + +impl From<&DiffReport> for DiffReportRecord { + fn from(r: &DiffReport) -> Self { + DiffReportRecord { + width: r.width, + height: r.height, + band_pixel_count: r.band_pixel_count, + d1_pixels_outside_band_differing: r.d1_pixels_outside_band_differing, + d1_pass: r.d1_pass, + reference_ink_mass: r.reference_ink_mass, + candidate_ink_mass: r.candidate_ink_mass, + d2_relative_delta: r.d2_relative_delta, + d2_pass: r.d2_pass, + reference_centroid: r.reference_centroid, + candidate_centroid: r.candidate_centroid, + d3_delta: r.d3_delta, + d3_pass: r.d3_pass, + in_band_max_abs_delta_luma: r.in_band_max_abs_delta_luma, + in_band_count_delta_gt_report_threshold: r.in_band_count_delta_gt_report_threshold, + d4_regions: r.d4_regions.iter().map(RegionMassRecord::from).collect(), + d4_pass: r.d4_pass, + d4_worst: r.d4_worst.as_ref().map(RegionMassRecord::from), + pass: r.pass(), + } + } +} + +/// One hit-test probe's recorded comparison. The device point and expected +/// answer come straight from `round2-textkit`'s committed +/// `hittest_probes.json` (`round2_textkit::hittest::HitTestProbe`); +/// resolving *which* byte offset and affinity a candidate's renderer +/// actually returns for that point is the candidate's own job — check 4's +/// entire subject — so this type only carries the recorded outcome of that +/// resolution, never performs it. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HitTestProbeResult { + pub fixture_id: String, + pub point: DevicePoint, + pub expected_source_offset: u32, + pub expected_affinity: SpikeCaretAffinity, + pub actual_source_offset: u32, + pub actual_affinity: SpikeCaretAffinity, + pub pass: bool, +} + +/// One fixture's observed accessibility evidence — what the candidate's own +/// tree (or its absence) actually looked like, compared against +/// `round2-textkit`'s precommitted +/// `round2_textkit::a11y::SpikeAccessibilityExpectation`. Building the tree +/// is the candidate's job (recipe §8.4: "nothing here says *how* a +/// candidate builds the tree, on which thread, or through which crate"); +/// this type only carries what was observed. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct A11yEvidence { + pub fixture_id: String, + /// The platform row (recipe §8.2 table key, e.g. `"accesskit-0.24"`) + /// this evidence was collected against — a candidate satisfies check 5 + /// by matching one row, the platform it actually exposes a tree on. + pub platform: String, + /// `None` when the run is absent from the tree entirely (the + /// `absent-from-tree` prohibited outcome) — a distinct state from an + /// empty-but-present name (`name-empty`), which is `Some("")`. + pub observed_name: Option, + pub observed_name_bytes_hex: Option, + pub observed_role: Option, + /// One of `round2_textkit::a11y::PROHIBITED_OUTCOMES`, or `None` if no + /// prohibited outcome applies. + pub prohibited_outcome: Option, + pub pass: bool, + pub notes: String, +} + +/// Positive evidence that the platform accessibility bus itself was +/// unreachable — the *only* thing that can make +/// [`CandidateReport::check5_accessibility`] `NotRun` admissible on the +/// round's own platform (AT-SPI2, on this machine); see +/// `crate::scoring::ROUND0_READBACK_EVIDENCE` for why "we did not build a +/// bridge" is not, by itself, an environmental cause here. +/// +/// A **typed** field rather than folding this into `CheckOutcome::NotRun`'s +/// free-text reason on purpose: a free-text reason is something a candidate +/// can write anything into ("bus unreachable" typed by hand proves +/// nothing), while this type asks for the specific thing that would make +/// the claim checkable — what was attempted, and what was actually +/// observed. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct BusUnreachableEvidence { + /// How the candidate attempted to reach the platform accessibility bus + /// before concluding it was unreachable (e.g. "connected to the AT-SPI2 + /// session bus via `atspi::Bus::connect`"). + pub probe_description: String, + /// What was actually observed — the failure itself, not a restatement + /// of "unreachable" (e.g. the connection error message). + pub probe_output: String, +} + +/// One dependency added to the candidate's own crate(s) over the Round 1 +/// baseline. `reason` is a one-line justification a reader can check +/// against what the candidate actually needed to build. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct DependencyDelta { + pub name: String, + pub version: String, + pub reason: String, +} + +/// One platform accessibility adapter's status. +/// +/// `NotBuilt` is a distinct variant from a failing status **on purpose** — +/// the user's ruling is that an adapter the candidate chose not to build is +/// **scope, not a hidden failure**. A string convention (e.g. a `notes` +/// field reading `"not built"`) could be typo'd, omitted, or silently +/// absorbed into a `PASS`; making it a variant the compiler enforces means +/// a report can never accidentally claim a platform is covered by leaving +/// its status ambiguous. +/// +/// **This variant covers *other* platforms only** (Windows UIA, macOS AX, +/// ...) — it must never be used to excuse an unbuilt bridge on the round's +/// own platform (AT-SPI2, on this machine); see +/// [`crate::scoring::ROUND0_READBACK_EVIDENCE`] and +/// [`CandidateReport::check5_bus_unreachable_evidence`] for the field that +/// actually governs whether check 5 is allowed to be `NotRun`. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum AdapterStatus { + /// The candidate built and exercised an adapter for this platform. + Implemented { platform: String, notes: String }, + /// The candidate did not build an adapter for this platform. + /// **Scope not covered — not a failure.** + NotBuilt { platform: String, reason: String }, +} + +/// A shared part of the candidate's own integration work, common to both C1 +/// and C2 so their per-part LOC tables can be read **side by side** — the +/// one thing the user's ruling on cost tables asks of this record. +/// +/// Replaces an earlier free-text `part: String` design: free text let each +/// candidate invent its own vocabulary, which produced two tables that +/// could not be compared directly. `Other(String)` is the escape hatch for +/// a genuinely candidate-specific seam that none of the five shared rows +/// describes (e.g. egui's immediate-mode re-layout-per-frame glue, or +/// vello's scene-graph diffing) — the divergence between the two +/// candidates is still expressible, just visibly, instead of silently +/// fragmenting every row. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum ReportPart { + TextRendering, + HitTestResolution, + AccessibilityTreeConstruction, + AccessibilityIntegrationWiring, + FixtureAndReportPlumbing, + /// A seam that is genuinely candidate-specific — not one of the five + /// shared rows above. + Other(String), +} + +/// LOC for one part of the candidate's own integration work. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct LocByPart { + pub part: ReportPart, + pub lines: u64, +} + +/// Observed cost facts, reported at the same granularity by both +/// candidates — never a subjective score, only what was actually added, +/// built, or left as scope. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CostRecord { + /// The Round 1 baseline commit this delta is measured against. + pub baseline_commit: String, + pub dependencies_added: Vec, + /// One entry per platform row in `round2_textkit::a11y::ACCEPTED_ROLE_TABLE` + /// — every platform gets a status, `Implemented` or `NotBuilt`, never an + /// absent entry (an absent entry is indistinguishable from "forgot to + /// report", which is exactly what `NotBuilt` exists to make explicit). + pub adapters: Vec, + /// Free-text bullets describing integration/wiring the candidate wrote + /// itself — glue code, not vendored or generated. + pub integration_wiring: Vec, + pub loc_by_part: Vec, +} + +/// The shape both Round 2 text candidates emit. +/// +/// `check1`..`check5` are the five checks [`crate::scoring::criterion_cell`] +/// reduces to the criterion cell. `supplementary_f_d_bidi` is deliberately +/// **not** one of them — see that function's doc comment for why it is +/// structurally incapable of reaching the cell. +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CandidateReport { + pub candidate_id: String, + + pub check1_faithful_consumption: CheckOutcome, + pub check2_fallback: CheckOutcome, + /// Must be `CheckOutcome::NotRun(_)` by the standing ruling + /// (`ROUND2_TEXT_RECIPE.md` §1.2) — enforced in + /// `crate::scoring::criterion_cell` (by panic, not silent acceptance), + /// not at construction time here, so a report can still be built and + /// inspected before that function ever runs. + pub check3_bidi: CheckOutcome, + pub check4_hit_testing: CheckOutcome, + pub check5_accessibility: CheckOutcome, + /// Present only when `check5_accessibility` is `NotRun` **and** that + /// `NotRun` is claimed to be caused by the platform accessibility bus + /// itself being unreachable — the only cause + /// `crate::scoring::criterion_cell`/`crate::scoring::is_eligible` + /// accept for a check-5 `NotRun` on this round's own platform. `None` + /// whenever `check5_accessibility` is `Pass` or `Fail`. + pub check5_bus_unreachable_evidence: Option, + + /// F-D's supplementary Hebrew/Latin bidi evidence (recipe §1.2) — a + /// separate field, never merged into the five above and never read by + /// `crate::scoring::criterion_cell`. + pub supplementary_f_d_bidi: CheckOutcome, + + /// Keyed by fixture id (`F-A`..`F-E`). + pub per_fixture_diffs: BTreeMap, + pub hittest_probe_results: Vec, + pub a11y_evidence: Vec, + pub cost: CostRecord, +} + +#[cfg(test)] +mod tests { + use super::*; + use round2_diff::GlyphRegion; + + fn solid(width: u32, height: u32, rgb: [u8; 3]) -> Vec { + let mut buf = vec![0u8; (width as usize) * (height as usize) * 4]; + for px in buf.chunks_mut(4) { + px[0] = rgb[0]; + px[1] = rgb[1]; + px[2] = rgb[2]; + px[3] = 255; + } + buf + } + + #[test] + fn diff_report_record_mirrors_every_field_and_the_computed_verdict() { + let reference = solid(8, 8, [255, 255, 255]); + let candidate = reference.clone(); + let region = GlyphRegion { + label: "x".to_string(), + x0: 2, + y0: 2, + x1: 6, + y1: 6, + }; + let report = round2_diff::diff(&reference, &candidate, 8, 8, &[region]).unwrap(); + let record = DiffReportRecord::from(&report); + assert_eq!(record.width, report.width); + assert_eq!(record.height, report.height); + assert_eq!(record.d1_pass, report.d1_pass); + assert_eq!(record.d2_pass, report.d2_pass); + assert_eq!(record.d3_pass, report.d3_pass); + assert_eq!(record.d4_pass, report.d4_pass); + assert_eq!(record.pass, report.pass()); + assert_eq!(record.d4_regions.len(), report.d4_regions.len()); + } + + fn empty_cost() -> CostRecord { + CostRecord { + baseline_commit: "abc1234".to_string(), + dependencies_added: Vec::new(), + adapters: vec![AdapterStatus::NotBuilt { + platform: "windows-uia".to_string(), + reason: "no Windows CI runner for this spike".to_string(), + }], + integration_wiring: Vec::new(), + loc_by_part: Vec::new(), + } + } + + fn base_candidate_report() -> CandidateReport { + CandidateReport { + candidate_id: "C-TEST".to_string(), + check1_faithful_consumption: CheckOutcome::Pass, + check2_fallback: CheckOutcome::Pass, + check3_bidi: CheckOutcome::NotRun("x".to_string()), + check4_hit_testing: CheckOutcome::Pass, + check5_accessibility: CheckOutcome::Pass, + check5_bus_unreachable_evidence: None, + supplementary_f_d_bidi: CheckOutcome::Pass, + per_fixture_diffs: BTreeMap::new(), + hittest_probe_results: Vec::new(), + a11y_evidence: Vec::new(), + cost: empty_cost(), + } + } + + #[test] + fn candidate_report_round_trips_through_json() { + let report = base_candidate_report(); + let json = serde_json::to_string_pretty(&report).unwrap(); + let reloaded: CandidateReport = serde_json::from_str(&json).unwrap(); + assert_eq!(reloaded.candidate_id, "C-TEST"); + assert!(matches!( + reloaded.cost.adapters[0], + AdapterStatus::NotBuilt { .. } + )); + assert!(reloaded.check5_bus_unreachable_evidence.is_none()); + } + + /// `check5_bus_unreachable_evidence` must round-trip when present, not + /// just when `None` — the field the review named is exactly the one a + /// lossy round trip would silently drop. + #[test] + fn bus_unreachable_evidence_round_trips_through_json() { + let mut report = base_candidate_report(); + report.check5_accessibility = CheckOutcome::not_run("bus unreachable").unwrap(); + report.check5_bus_unreachable_evidence = Some(BusUnreachableEvidence { + probe_description: "connected to the AT-SPI2 session bus".to_string(), + probe_output: "org.freedesktop.DBus.Error.ServiceUnknown".to_string(), + }); + let json = serde_json::to_string_pretty(&report).unwrap(); + let reloaded: CandidateReport = serde_json::from_str(&json).unwrap(); + let evidence = reloaded + .check5_bus_unreachable_evidence + .expect("evidence must survive the round trip"); + assert_eq!( + evidence.probe_output, + "org.freedesktop.DBus.Error.ServiceUnknown" + ); + } + + /// `NotBuilt` must not be interchangeable with `Implemented` — the + /// compiler-enforced distinction the doc comment claims. + #[test] + fn not_built_adapter_is_a_distinct_variant_from_implemented() { + let a = AdapterStatus::NotBuilt { + platform: "macos-nsaccessibility".to_string(), + reason: "no macOS runner".to_string(), + }; + assert!(matches!(a, AdapterStatus::NotBuilt { .. })); + assert!(!matches!(a, AdapterStatus::Implemented { .. })); + } + + /// An unknown field on the wire must be refused, not ignored — the same + /// discipline every deserializable type in this workspace uses. + #[test] + fn an_unknown_field_on_cost_record_is_refused() { + let mut v = serde_json::to_value(empty_cost()).unwrap(); + v.as_object_mut() + .unwrap() + .insert("smuggled_field".into(), serde_json::json!(1)); + let err = serde_json::from_value::(v) + .unwrap_err() + .to_string(); + assert!(err.contains("smuggled_field"), "{err}"); + } + + // ---- F4: the five shared ReportPart rows compare directly ---- + + /// The whole point of replacing free-text `part: String` with a fixed + /// enum: two candidates' `LocByPart` rows for the same shared part are + /// now directly comparable (`==`), which a free-text label (e.g. "text + /// rendering" vs. "rendering text") could never guarantee. + #[test] + fn the_same_shared_part_from_two_candidates_compares_equal() { + let c1_row = LocByPart { + part: ReportPart::HitTestResolution, + lines: 340, + }; + let c2_row = LocByPart { + part: ReportPart::HitTestResolution, + lines: 210, + }; + assert_eq!(c1_row.part, c2_row.part); + assert_ne!( + c1_row.lines, c2_row.lines, + "the LOC counts may legitimately differ" + ); + } + + /// `Other` stays the escape hatch: two candidate-specific seams with + /// different labels remain distinguishable, unlike the five fixed rows. + #[test] + fn other_parts_with_different_labels_are_not_conflated() { + let egui_seam = ReportPart::Other("immediate-mode re-layout per frame".to_string()); + let vello_seam = ReportPart::Other("scene-graph diffing".to_string()); + assert_ne!(egui_seam, vello_seam); + } + + /// All five shared rows round-trip, and `Other` carries its label + /// through — a lossy `Serialize`/`Deserialize` impl on the enum would + /// silently collapse rows that must stay comparable. + #[test] + fn every_report_part_round_trips_through_json() { + let parts = [ + ReportPart::TextRendering, + ReportPart::HitTestResolution, + ReportPart::AccessibilityTreeConstruction, + ReportPart::AccessibilityIntegrationWiring, + ReportPart::FixtureAndReportPlumbing, + ReportPart::Other("candidate-specific seam".to_string()), + ]; + for part in parts { + let json = serde_json::to_string(&part).unwrap(); + let back: ReportPart = serde_json::from_str(&json).unwrap(); + assert_eq!(part, back); + } + } +} diff --git a/spikes/editor-toolkit/round2-candidatekit/src/scoring.rs b/spikes/editor-toolkit/round2-candidatekit/src/scoring.rs new file mode 100644 index 0000000..0769b4f --- /dev/null +++ b/spikes/editor-toolkit/round2-candidatekit/src/scoring.rs @@ -0,0 +1,368 @@ +//! Turns a [`CandidateReport`]'s five check outcomes into the Round 2 +//! criterion cell, and reports eligibility separately +//! (`ROUND2_TEXT_RECIPE.md` §1.2). + +use crate::outcome::CheckOutcome; +use crate::report::CandidateReport; + +/// The round's own platform — `round2_textkit::a11y::ACCEPTED_ROLE_TABLE`'s +/// `"at-spi2"` row — restated here so [`ROUND0_READBACK_EVIDENCE`]'s doc +/// comment and [`require_check5_not_run_is_admissible`]'s panic can name it +/// precisely. +pub const ROUND_PLATFORM: &str = "at-spi2"; + +/// Round 0's own readback evidence, quoted verbatim in the panic +/// [`criterion_cell`]/[`is_eligible`] raise for a check-5 `NotRun` that +/// carries no [`crate::report::BusUnreachableEvidence`]. +/// +/// `round0-evidence/c1-egui-readback.txt` and +/// `round0-evidence/c2-vello-readback.txt` both record `READBACK: PASS` — a +/// live, out-of-process AT-SPI2 tree walk that succeeded for **both** +/// candidates on this machine. So on [`ROUND_PLATFORM`], "we did not build +/// an accessibility bridge" is not an environmental cause: the bus is +/// reachable, and check 5 is the round's own platform, not declared +/// out-of-scope adapter coverage. `AdapterStatus::NotBuilt` still covers +/// *other* platforms (Windows UIA, macOS AX, ...) as declared scope; it +/// must not be used to excuse the platform the round actually runs on. +pub const ROUND0_READBACK_EVIDENCE: &str = "round0-evidence/c1-egui-readback.txt and \ + round0-evidence/c2-vello-readback.txt both record READBACK: PASS — a live, out-of-process \ + AT-SPI2 tree walk succeeded for both candidates on this machine, so the platform \ + accessibility bus is reachable here and an unbuilt accessibility bridge is not \ + environmental NOT RUN on this platform. AdapterStatus::NotBuilt covers OTHER platforms \ + (Windows UIA, macOS AX, ...) as declared scope; it does not, by itself, excuse the \ + platform the round actually runs on."; + +/// Panics if `report.check5_accessibility` is `NotRun` without +/// `report.check5_bus_unreachable_evidence` present — see +/// [`ROUND0_READBACK_EVIDENCE`]. A no-op for `Pass`/`Fail`, and a no-op for +/// a `NotRun` that *does* carry evidence. Deliberately does **not** inspect +/// `report.cost.adapters` — an `AdapterStatus::NotBuilt` entry for +/// [`ROUND_PLATFORM`] must not, by itself, satisfy this check (that is the +/// exact loophole the review named). +fn require_check5_not_run_is_admissible(report: &CandidateReport) { + if report.check5_accessibility.is_not_run() && report.check5_bus_unreachable_evidence.is_none() + { + panic!( + "candidate {:?} reported check5_accessibility = NotRun(_) with no \ + check5_bus_unreachable_evidence — {ROUND0_READBACK_EVIDENCE}", + report.candidate_id + ); + } +} + +/// The Round 2 criterion cell for check 3 (bidi / text-run primitives) is +/// structurally identical to [`CheckOutcome`] — a cell is the worst of the +/// five checks, which is itself just a `CheckOutcome` — kept as a distinct +/// name so a reader is never unsure whether a value in hand is *one check's +/// own* outcome or *the criterion cell* five checks reduce to. +pub type CellOutcome = CheckOutcome; + +/// The standing ruling [`criterion_cell`] enforces (`ROUND2_TEXT_RECIPE.md` +/// §1.2, 2026-07-29): no Arabic-capable face is installed on the round's +/// declared machine, and pin 9 makes an absent required face environmental +/// `NOT RUN`. Quoted verbatim in the panic [`criterion_cell`] raises for a +/// report that disagrees with it. +pub const CHECK_3_RULING: &str = "ROUND2_TEXT_RECIPE.md §1.2 (2026-07-29 ruling): check 3 is \ + NOT RUN for every candidate, on both adapters — no Arabic-capable face is installed, and \ + pin 9 makes an absent required face environmental NOT RUN. F-D's supplementary Hebrew/Latin \ + bidi evidence is recorded separately and must never upgrade check 3 to PASS."; + +/// Reduces a [`CandidateReport`]'s five check outcomes to the Round 2 +/// criterion cell: the **worst of the five**, ordered `Pass` < `NotRun` < +/// `Fail` ([`CheckOutcome::severity_rank`]). +/// +/// The supplementary F-D bidi result +/// ([`CandidateReport::supplementary_f_d_bidi`]) is a separate field on +/// `CandidateReport` and this function never reads it — that is what makes +/// it **structurally** incapable of reaching the cell (recipe §1.2: "it +/// must not upgrade check 3 to PASS"), rather than merely conventionally +/// excluded by a check this function could someday grow to include by +/// accident. +/// +/// # Panics +/// +/// Panics if `report.check3_bidi` is anything other than `NotRun` — a +/// candidate reporting `Pass` or `Fail` for check 3 has violated the +/// standing ruling ([`CHECK_3_RULING`]), which this function treats as a +/// programming error in how the candidate assembled its report, not a value +/// a scoring rule is allowed to interpret. (Not every environmental +/// deviation deserves a panic; this one does, because pin 9's face-absence +/// fact does not vary between the two candidates or between runs — a +/// non-`NotRun` value here can only mean the report was built wrong.) +pub fn criterion_cell(report: &CandidateReport) -> CellOutcome { + if !report.check3_bidi.is_not_run() { + panic!( + "candidate {:?} reported check 3 as {:?}, not NotRun(_) — {CHECK_3_RULING}", + report.candidate_id, report.check3_bidi + ); + } + require_check5_not_run_is_admissible(report); + + let checks = [ + &report.check1_faithful_consumption, + &report.check2_fallback, + &report.check3_bidi, + &report.check4_hit_testing, + &report.check5_accessibility, + ]; + checks + .into_iter() + .max_by_key(|c| c.severity_rank()) + .cloned() + .expect("`checks` is a fixed non-empty array of five elements") +} + +/// The two disqualifying checks (recipe §1.2: "checks 2 and 5 are the +/// disqualifying set"). Named so the disqualifying set is a fact a reader +/// (and a grep) can find, not a claim buried in a comment beside +/// [`is_eligible`]. +pub const DISQUALIFYING_CHECKS: &str = "check2_fallback, check5_accessibility"; + +/// Whether `report` remains a candidate at all — reported **separately** +/// from [`criterion_cell`], because the two questions are different: the +/// cell is what the criterion 3 table shows, eligibility is whether the +/// candidate survives at all. +/// +/// Failing check 2 or check 5 disqualifies. Check 3's `NotRun` (the only +/// state it is ever allowed to carry — see [`criterion_cell`]) does **not** +/// disqualify, because check 3 is not in the disqualifying set +/// ([`DISQUALIFYING_CHECKS`]). +/// +/// # Panics +/// +/// Panics under the same condition [`criterion_cell`] does for check 5 — +/// see [`require_check5_not_run_is_admissible`] / [`ROUND0_READBACK_EVIDENCE`]. +/// Without this, a candidate that never wired an accessibility bridge could +/// report `check5_accessibility = NotRun("we did not build it")`, and +/// `is_eligible` would return `true` because `NotRun` is not `Fail` — the +/// exact loophole the ruling this function enforces exists to close. This +/// function does not merely return `false` for that case, because the +/// report itself is malformed (an inadmissible claim), not merely +/// disqualifying: a malformed report should not be silently readable as "at +/// least eligible." +pub fn is_eligible(report: &CandidateReport) -> bool { + require_check5_not_run_is_admissible(report); + !report.check2_fallback.is_fail() && !report.check5_accessibility.is_fail() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::report::{AdapterStatus, BusUnreachableEvidence, CostRecord}; + use std::collections::BTreeMap; + + fn base_report() -> CandidateReport { + CandidateReport { + candidate_id: "C-TEST".to_string(), + check1_faithful_consumption: CheckOutcome::Pass, + check2_fallback: CheckOutcome::Pass, + check3_bidi: CheckOutcome::NotRun(CHECK_3_RULING.to_string()), + check4_hit_testing: CheckOutcome::Pass, + check5_accessibility: CheckOutcome::Pass, + check5_bus_unreachable_evidence: None, + supplementary_f_d_bidi: CheckOutcome::Pass, + per_fixture_diffs: BTreeMap::new(), + hittest_probe_results: Vec::new(), + a11y_evidence: Vec::new(), + cost: CostRecord { + baseline_commit: "0000000".to_string(), + dependencies_added: Vec::new(), + adapters: Vec::new(), + integration_wiring: Vec::new(), + loc_by_part: Vec::new(), + }, + } + } + + fn some_evidence() -> BusUnreachableEvidence { + BusUnreachableEvidence { + probe_description: "connected to the AT-SPI2 session bus".to_string(), + probe_output: "org.freedesktop.DBus.Error.ServiceUnknown".to_string(), + } + } + + // ---- criterion_cell: the worst-of-five rule ---- + + #[test] + fn all_pass_except_the_pinned_check_3_yields_a_not_run_cell() { + let cell = criterion_cell(&base_report()); + assert!(matches!(cell, CheckOutcome::NotRun(_)), "{cell:?}"); + } + + /// Required kill: a `CandidateReport` claiming check 3 `Pass` is + /// rejected, naming the §1.2 ruling. + #[test] + #[should_panic(expected = "§1.2")] + fn a_check_3_pass_is_rejected_naming_the_ruling() { + let mut report = base_report(); + report.check3_bidi = CheckOutcome::Pass; + let _ = criterion_cell(&report); + } + + /// Same requirement, the other disallowed value: a `Fail` for check 3 + /// is rejected exactly as a `Pass` is — the ruling pins check 3 to + /// `NotRun` specifically, not merely "not Pass". + #[test] + #[should_panic(expected = "§1.2")] + fn a_check_3_fail_is_also_rejected_naming_the_ruling() { + let mut report = base_report(); + report.check3_bidi = CheckOutcome::Fail("pretend Arabic shaping worked".to_string()); + let _ = criterion_cell(&report); + } + + /// Required kill: a supplementary F-D `Pass` does not move the cell off + /// `NotRun`. + #[test] + fn a_supplementary_f_d_pass_does_not_move_the_cell_off_not_run() { + let mut report = base_report(); + report.supplementary_f_d_bidi = CheckOutcome::Pass; + assert!(matches!(criterion_cell(&report), CheckOutcome::NotRun(_))); + } + + /// Required kill: a supplementary F-D `Fail` does not move the cell + /// either — in particular it must not turn `NotRun` into `Fail`, which + /// is the direction a naive "worst of six" implementation would break. + #[test] + fn a_supplementary_f_d_fail_does_not_move_the_cell_either() { + let mut report = base_report(); + report.supplementary_f_d_bidi = + CheckOutcome::Fail("Hebrew segment drawn in the wrong face".to_string()); + let cell = criterion_cell(&report); + assert!( + matches!(cell, CheckOutcome::NotRun(_)), + "a FAIL on the supplementary row must not reach the cell at all: got {cell:?}" + ); + } + + /// A genuine check-2 FAIL must still win the worst-of-five over the + /// pinned check-3 NotRun — confirms the ordering is real, not just + /// "always NotRun". + #[test] + fn a_check_2_failure_outranks_the_pinned_not_run_in_the_cell() { + let mut report = base_report(); + report.check2_fallback = + CheckOutcome::Fail("host-substituted the Hebrew segment".to_string()); + let cell = criterion_cell(&report); + assert!(matches!(cell, CheckOutcome::Fail(_)), "{cell:?}"); + } + + // ---- is_eligible: the disqualifying set is {check2, check5} only ---- + + /// Required kill: a candidate failing check 2 is ineligible. + #[test] + fn failing_check_2_makes_a_candidate_ineligible() { + let mut report = base_report(); + report.check2_fallback = CheckOutcome::Fail("...".to_string()); + assert!(!is_eligible(&report)); + } + + /// Required kill: a candidate failing check 5 is ineligible. + #[test] + fn failing_check_5_makes_a_candidate_ineligible() { + let mut report = base_report(); + report.check5_accessibility = CheckOutcome::Fail("...".to_string()); + assert!(!is_eligible(&report)); + } + + /// Required kill: a candidate whose only non-Pass is check 3 `NotRun` + /// is eligible. + #[test] + fn a_candidate_whose_only_non_pass_is_check_3_not_run_is_eligible() { + let report = base_report(); // check3 is NotRun; everything else Pass. + assert!( + is_eligible(&report), + "check 3 is not in the disqualifying set" + ); + } + + /// Checks 1 and 4 are not disqualifying either — only 2 and 5 are. This + /// distinguishes "affects the cell" from "affects eligibility": a + /// check-1 FAIL sinks the cell to FAIL but must not, by itself, remove + /// the candidate from the round. + #[test] + fn failing_check_1_or_4_sinks_the_cell_but_not_eligibility() { + let mut report = base_report(); + report.check1_faithful_consumption = CheckOutcome::Fail("...".to_string()); + assert!( + is_eligible(&report), + "checks 1 and 4 are not in the disqualifying set" + ); + assert!(matches!(criterion_cell(&report), CheckOutcome::Fail(_))); + } + + // ---- F1: a check-5 NotRun is admissible only with bus-unreachable evidence ---- + + /// Required kill: a check-5 `NotRun` with no unreachable-bus evidence is + /// rejected, naming Round 0's readback evidence. + #[test] + #[should_panic(expected = "READBACK: PASS")] + fn a_check_5_not_run_with_no_evidence_is_rejected_by_is_eligible() { + let mut report = base_report(); + report.check5_accessibility = CheckOutcome::not_run("we did not build it").unwrap(); + report.check5_bus_unreachable_evidence = None; + let _ = is_eligible(&report); + } + + /// Same rejection, reached through `criterion_cell` instead of + /// `is_eligible` — both are "the scoring path" the review named. + #[test] + #[should_panic(expected = "READBACK: PASS")] + fn a_check_5_not_run_with_no_evidence_is_rejected_by_criterion_cell() { + let mut report = base_report(); + report.check5_accessibility = CheckOutcome::not_run("we did not build it").unwrap(); + report.check5_bus_unreachable_evidence = None; + let _ = criterion_cell(&report); + } + + /// Required kill: a check-5 `NotRun` **with** unreachable-bus evidence + /// is accepted, and does not disqualify the candidate (NotRun is not in + /// the disqualifying set — see [`DISQUALIFYING_CHECKS`]). + #[test] + fn a_check_5_not_run_with_evidence_is_accepted_and_does_not_disqualify() { + let mut report = base_report(); + report.check5_accessibility = CheckOutcome::not_run("bus unreachable").unwrap(); + report.check5_bus_unreachable_evidence = Some(some_evidence()); + assert!( + is_eligible(&report), + "a legitimately NotRun check 5 must not disqualify" + ); + let cell = criterion_cell(&report); + assert!(matches!(cell, CheckOutcome::NotRun(_)), "{cell:?}"); + } + + /// Required kill: an `AdapterStatus::NotBuilt` entry for the round's own + /// platform does not, by itself, make a check-5 `NotRun` admissible — + /// `NotBuilt` covers *other* platforms as declared scope, and must not + /// be usable to excuse AT-SPI2, the platform this round actually runs + /// on. The admissibility check must ignore `cost.adapters` entirely. + #[test] + #[should_panic(expected = "READBACK: PASS")] + fn a_not_built_adapter_for_the_rounds_own_platform_does_not_grant_admissibility() { + let mut report = base_report(); + report.check5_accessibility = CheckOutcome::not_run("we did not build it").unwrap(); + report.check5_bus_unreachable_evidence = None; + report.cost.adapters.push(AdapterStatus::NotBuilt { + platform: ROUND_PLATFORM.to_string(), + reason: "ran out of time".to_string(), + }); + let _ = is_eligible(&report); + } + + /// A check-5 `Pass` or `Fail` never triggers the admissibility check at + /// all — it exists only to gate `NotRun`, and must not fire on a report + /// that never claimed environmental absence. + #[test] + fn a_check_5_pass_or_fail_never_needs_bus_unreachable_evidence() { + let mut report = base_report(); + report.check5_accessibility = CheckOutcome::Pass; + report.check5_bus_unreachable_evidence = None; + assert!(is_eligible(&report)); + let _ = criterion_cell(&report); + + let mut report = base_report(); + report.check5_accessibility = CheckOutcome::fail("absent-from-tree").unwrap(); + report.check5_bus_unreachable_evidence = None; + assert!(!is_eligible(&report)); + let _ = criterion_cell(&report); + } +} diff --git a/spikes/editor-toolkit/round2-candidatekit/tests/dependency_deny_list.rs b/spikes/editor-toolkit/round2-candidatekit/tests/dependency_deny_list.rs new file mode 100644 index 0000000..5897d33 --- /dev/null +++ b/spikes/editor-toolkit/round2-candidatekit/tests/dependency_deny_list.rs @@ -0,0 +1,223 @@ +//! Enforces the neutrality boundary `src/lib.rs`'s crate doc comment states: +//! `round2-candidatekit` must not depend on any rendering, windowing, GPU, +//! or platform-accessibility crate — those stay candidate-owned (C1 = +//! egui + lyon, C2 = vello). This test reads this crate's own `Cargo.toml` +//! **at test time** rather than hard-coding "the current dependency list is +//! X" — the point is to catch a *future* dependency add, not merely to +//! assert today's file is fine. + +/// Rendering, windowing, GPU, and platform-accessibility crates that must +/// never appear in `round2-candidatekit`'s own `[dependencies]`. This list +/// is the thing under test — it is deliberately hard-coded, unlike the +/// dependency names it is checked against, which are always read fresh from +/// the manifest. +const DENY_LIST: &[&str] = &[ + "egui", + "eframe", + "egui-wgpu", + "lyon", + "lyon_path", + "lyon_tessellation", + "vello", + "wgpu", + "winit", + "accesskit", + "accesskit_winit", + "tiny-skia", + "resvg", + "usvg", +]; + +/// If `header` (the contents of a `[...]` line, already trimmed) names a +/// dependency **sub-table** — TOML's `[dependencies.name]` form, or the +/// same thing nested under a target, `[target.'cfg(...)'.dependencies.name]` +/// — returns `name`. `Cargo.toml` lets a single dependency spread across +/// its own `[...]` header when it needs more than a version string (e.g. +/// `[dependencies.wgpu]\nversion = "0.19"`), and that header names the +/// dependency directly rather than introducing a block of `key = value` +/// lines the way `[dependencies]` does — a scanner that only recognizes the +/// block form misses this shape entirely (confirmed empirically: it +/// returned `[]` for a manifest whose only dependency used this form). +fn dependency_subtable_name(header: &str) -> Option { + let rest = if let Some(r) = header.strip_prefix("dependencies.") { + r + } else if let Some(idx) = header.find(".dependencies.") { + &header[idx + ".dependencies.".len()..] + } else { + return None; + }; + // A dependency's own sub-table (e.g. hand-spread build metadata) would + // add a further dot, as in `dependencies.foo.metadata`; only the first + // segment is the crate name. + let name = rest.split('.').next().unwrap_or(rest); + Some(name.trim_matches('"').trim_matches('\'').to_string()) +} + +/// True if `header` opens a **block** of `key = value` dependency lines — +/// `[dependencies]` itself, or the same thing nested under a target +/// (`[target.'cfg(unix)'.dependencies]`). Deliberately does not match +/// `dev-dependencies` or `build-dependencies`: both end in "dependencies" +/// but with a hyphen, not a dot, immediately before it, so +/// `.ends_with(".dependencies")` is false for them — those tables are out +/// of scope for this guard on purpose (see +/// `the_line_scanner_finds_dependencies_and_ignores_other_sections`). +fn opens_dependency_block(header: &str) -> bool { + header == "dependencies" || header.ends_with(".dependencies") +} + +/// Extracts dependency names from a `Cargo.toml`, covering both shapes +/// Cargo accepts: the block form (`[dependencies]` followed by `key = +/// value` lines) and the sub-table form (`[dependencies.name]`), each +/// optionally nested under `[target.'cfg(...)'. ...]`. Deliberately not a +/// TOML parser — pulling one in as a dependency of a crate whose whole +/// point is a short, auditable dependency list would be self-defeating — +/// but a plain line scan that recognizes both header shapes, not just the +/// block one. +fn dependency_names(manifest: &str) -> Vec { + let mut names = Vec::new(); + let mut in_dependency_block = false; + for raw_line in manifest.lines() { + let line = raw_line.trim(); + if let Some(header) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) { + let header = header.trim(); + if let Some(name) = dependency_subtable_name(header) { + names.push(name); + in_dependency_block = false; + continue; + } + in_dependency_block = opens_dependency_block(header); + continue; + } + if !in_dependency_block || line.is_empty() || line.starts_with('#') { + continue; + } + if let Some((key, _)) = line.split_once('=') { + names.push(key.trim().trim_matches('"').to_string()); + } + } + names +} + +fn manifest_path() -> std::path::PathBuf { + std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml") +} + +#[test] +fn dependencies_do_not_include_a_denied_rendering_windowing_or_a11y_crate() { + let manifest = std::fs::read_to_string(manifest_path()) + .unwrap_or_else(|e| panic!("failed to read {}: {e}", manifest_path().display())); + let names = dependency_names(&manifest); + assert!( + !names.is_empty(), + "the line scanner found zero dependencies in {} — that means this test is vacuous, not \ + that the crate has no dependencies (it depends on round2-textkit, round2-diff, serde, \ + serde_json); check the scanner, not the crate", + manifest_path().display() + ); + let violations: Vec<&String> = names + .iter() + .filter(|n| DENY_LIST.contains(&n.as_str())) + .collect(); + assert!( + violations.is_empty(), + "round2-candidatekit/Cargo.toml [dependencies] names denied crate(s) {violations:?} — \ + this crate is candidate-neutral apparatus only (rendering, hit-test resolution, and \ + accessibility integration are candidate-owned; see src/lib.rs's crate doc comment for \ + the ruling this enforces). Denied list: {DENY_LIST:?}" + ); +} + +/// Sanity check on the scanner itself, against a synthetic manifest +/// fragment: if this fails, the test above could be silently vacuous no +/// matter what `[dependencies]` actually contains. Also confirms +/// `[dev-dependencies]` is not scanned — a denied crate under dev-only use +/// (impossible here, since this crate declares none, but stated as a +/// property of the scanner) must not trip the production-dependency check. +#[test] +fn the_line_scanner_finds_dependencies_and_ignores_other_sections() { + let synthetic = "[package]\nname = \"x\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \ + \"1\"\nwgpu = \"0.19\"\n\n[dev-dependencies]\nwgpu = \"0.19\"\n"; + let names = dependency_names(synthetic); + assert_eq!(names, vec!["serde".to_string(), "wgpu".to_string()]); +} + +/// Confirms the scanner (and by extension the test above) actually flags a +/// denied name when one is present — otherwise `violations.is_empty()` +/// could be vacuously true because the scanner finds nothing, not because +/// the manifest is clean. +#[test] +fn a_synthetic_manifest_with_a_denied_dependency_is_flagged() { + let synthetic = "[dependencies]\nserde = \"1\"\ntiny-skia = \"0.11\"\n"; + let names = dependency_names(synthetic); + let violations: Vec<&String> = names + .iter() + .filter(|n| DENY_LIST.contains(&n.as_str())) + .collect(); + assert_eq!(violations, vec![&"tiny-skia".to_string()]); +} + +// ---- F2: the dotted sub-table form, confirmed empirically to be missed ---- +// +// Feeding the original scanner +// `"[dependencies]\nserde = \"1\"\n\n[dependencies.wgpu]\nversion = \"0.19\"\n"` +// returned `["serde"]` — `wgpu` never appeared, because the scanner only +// recognized `[dependencies]` as a block header and had no notion of a +// dependency named directly by its own `[...]` header. Each test below +// would fail if `dependency_subtable_name`'s handling were removed (i.e. +// if `dependency_names` fell back to the old block-only logic). + +/// The bare sub-table form: `[dependencies.wgpu]`. +#[test] +fn the_scanner_detects_a_dependency_named_via_a_dotted_subtable_header() { + let synthetic = "[package]\nname = \"x\"\n\n[dependencies]\nserde = \"1\"\n\n\ + [dependencies.wgpu]\nversion = \"0.19\"\n"; + let names = dependency_names(synthetic); + assert!( + names.contains(&"wgpu".to_string()), + "sub-table form missed: {names:?}" + ); + let violations: Vec<&String> = names + .iter() + .filter(|n| DENY_LIST.contains(&n.as_str())) + .collect(); + assert_eq!(violations, vec![&"wgpu".to_string()]); +} + +/// The block form nested under a target: `[target.'cfg(unix)'.dependencies]`. +#[test] +fn the_scanner_detects_a_dependency_block_under_a_target_cfg_table() { + let synthetic = + "[dependencies]\nserde = \"1\"\n\n[target.'cfg(unix)'.dependencies]\nwgpu = \"0.19\"\n"; + let names = dependency_names(synthetic); + assert!( + names.contains(&"wgpu".to_string()), + "target-cfg block form missed: {names:?}" + ); + let violations: Vec<&String> = names + .iter() + .filter(|n| DENY_LIST.contains(&n.as_str())) + .collect(); + assert_eq!(violations, vec![&"wgpu".to_string()]); +} + +/// Both forms combined: the sub-table form nested under a target, +/// `[target.'cfg(windows)'.dependencies.tiny-skia]`. +#[test] +fn the_scanner_detects_a_dotted_subtable_header_under_a_target_cfg_table() { + let synthetic = "[target.'cfg(windows)'.dependencies.tiny-skia]\nversion = \"0.11\"\n"; + let names = dependency_names(synthetic); + assert!( + names.contains(&"tiny-skia".to_string()), + "target-cfg sub-table form missed: {names:?}" + ); +} + +/// A dependency's own further sub-table (e.g. a spread-out `package` +/// rename) must still resolve to the crate name, the first dotted segment +/// after `dependencies.`, not the whole trailing path. +#[test] +fn a_deeper_dotted_path_still_resolves_to_the_leading_crate_name() { + let synthetic = "[dependencies.serde.metadata]\nfoo = 1\n"; + let names = dependency_names(synthetic); + assert_eq!(names, vec!["serde".to_string()]); +} From d76287cd68d0ab56c2c1d6673730a382f6618587 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 30 Jul 2026 18:46:52 -0400 Subject: [PATCH 2/5] Pin-13 schema amendment: adapter rows carry typed integration ownership Append-only amendment to 694d135. The oracle, the verifier, the scoring rule, the PASS results and eligibility are all untouched; this changes only what a cost row is able to say. The first pair of real candidate reports proved the schema could not carry what Packet 2B was chartered to record. Both candidates reached AT-SPI through AccessKit -- C1 via the integration bundled with eframe, C2 via wiring it wrote by hand -- and they reported that as opposite rows: C1: NotBuilt accesskit-0.24 "no separate AccessKit-native readback was built" C2: Implemented accesskit-0.24 "the in-process tree this binary constructs by hand" C1's row was false as written. The AccessKit path was present and exercised; every one of its five check-5 readbacks travelled it. NotBuilt means uncovered scope, and that scope was covered. Relabelling it Implemented would have fixed the falsehood and still lost the measurement, because "inherited or candidate-owned?" would have survived only as prose in notes -- which is precisely how the two candidates diverged in the first place. So AdapterStatus::Implemented now requires a typed integration_ownership: Inherited { provider } or CandidateOwned. Inherited names its provider so the claim can be checked against the dependency graph instead of taken on trust. NotBuilt keeps no ownership field at all: a platform reached by any route is Implemented. Inheriting an integration is not inheriting the semantics drawn on top of it. A candidate that inherits a bridge still writes the accessible nodes for whatever it painted itself, and that work is counted under ReportPart::AccessibilityTreeConstruction, not here -- which is what makes the two rows comparable rather than a single "has accessibility" flag. Five tests, each of which fails if its guard is removed: the two-candidate comparison the old schema could not express, Inherited carrying a checkable provider, an Implemented row missing integration_ownership refused at deserialize, NotBuilt refusing an ownership field, and a round trip that must not alter a row. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RSX4zSLgKvtiXaPjnMqLGz --- .../round2-candidatekit/src/lib.rs | 3 +- .../round2-candidatekit/src/report.rs | 162 +++++++++++++++++- 2 files changed, 163 insertions(+), 2 deletions(-) diff --git a/spikes/editor-toolkit/round2-candidatekit/src/lib.rs b/spikes/editor-toolkit/round2-candidatekit/src/lib.rs index 9f9ee66..b18617d 100644 --- a/spikes/editor-toolkit/round2-candidatekit/src/lib.rs +++ b/spikes/editor-toolkit/round2-candidatekit/src/lib.rs @@ -63,7 +63,8 @@ pub use inputs::{load_all, NeutralInputs, ReferenceFixture}; pub use outcome::CheckOutcome; pub use report::{ A11yEvidence, AdapterStatus, BusUnreachableEvidence, CandidateReport, CostRecord, - DependencyDelta, DiffReportRecord, HitTestProbeResult, LocByPart, RegionMassRecord, ReportPart, + DependencyDelta, DiffReportRecord, HitTestProbeResult, IntegrationOwnership, LocByPart, + RegionMassRecord, ReportPart, }; pub use scoring::{ criterion_cell, is_eligible, CellOutcome, CHECK_3_RULING, DISQUALIFYING_CHECKS, diff --git a/spikes/editor-toolkit/round2-candidatekit/src/report.rs b/spikes/editor-toolkit/round2-candidatekit/src/report.rs index 3ed0b41..6ad1f94 100644 --- a/spikes/editor-toolkit/round2-candidatekit/src/report.rs +++ b/spikes/editor-toolkit/round2-candidatekit/src/report.rs @@ -198,13 +198,61 @@ pub struct DependencyDelta { /// [`crate::scoring::ROUND0_READBACK_EVIDENCE`] and /// [`CandidateReport::check5_bus_unreachable_evidence`] for the field that /// actually governs whether check 5 is allowed to be `NotRun`. +/// Who *owns* the integration behind an [`AdapterStatus::Implemented`] row. +/// +/// Added by the pin-13 schema amendment (2026-07-30), because the first +/// pair of real reports proved that `Implemented`/`NotBuilt` alone cannot +/// carry what Packet 2B was chartered to record. C1 reached AT-SPI through +/// AccessKit **inherited from eframe** and reported that platform as +/// `NotBuilt` ("no separate AccessKit-native readback was built"); C2 +/// reached AT-SPI through AccessKit **it wired by hand** and reported +/// `Implemented`. Same underlying fact, opposite rows — and the one that +/// said `NotBuilt` was simply false, since the AccessKit path was present +/// and exercised. +/// +/// Relabelling C1's row `Implemented` would have fixed the falsehood and +/// still lost the distinction, because "inherited or candidate-owned?" +/// would have survived only as prose in `notes` — which is exactly how the +/// two candidates diverged in the first place. So it is typed, required on +/// every `Implemented` row, and therefore impossible to omit. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub enum IntegrationOwnership { + /// The integration came with a dependency the candidate adopted; the + /// candidate did not write it. `provider` names what supplied it (e.g. + /// `"eframe 0.35 (bundled AccessKit integration)"`), so a reader can + /// check the claim against the dependency graph rather than take it. + /// + /// Inheriting an integration is **not** inheriting the semantics drawn + /// on top of it: a candidate that inherits a bridge still writes the + /// accessible nodes for anything it painted itself, and that work shows + /// up in [`ReportPart::AccessibilityTreeConstruction`], not here. + Inherited { provider: String }, + /// The candidate wrote the integration itself — the bridge wiring, the + /// event plumbing, the adapter lifecycle. + CandidateOwned, +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub enum AdapterStatus { /// The candidate built and exercised an adapter for this platform. - Implemented { platform: String, notes: String }, + /// + /// `integration_ownership` is **required**: a platform the candidate + /// actually exposed a tree on is `Implemented` whether the integration + /// was inherited or written, and the difference between those two is + /// the measurement, not a footnote. + Implemented { + platform: String, + notes: String, + integration_ownership: IntegrationOwnership, + }, /// The candidate did not build an adapter for this platform. /// **Scope not covered — not a failure.** + /// + /// Reserved **exclusively** for uncovered scope. A platform the + /// candidate reached — by any route, inherited or its own — is + /// `Implemented`, never this. NotBuilt { platform: String, reason: String }, } @@ -418,6 +466,118 @@ mod tests { assert!(!matches!(a, AdapterStatus::Implemented { .. })); } + // ---- pin-13 schema amendment: integration ownership ---- + + fn implemented(platform: &str, own: IntegrationOwnership) -> AdapterStatus { + AdapterStatus::Implemented { + platform: platform.to_string(), + notes: "n".to_string(), + integration_ownership: own, + } + } + + /// The amendment's whole point: two candidates that both reached a + /// platform are both `Implemented`, and the inherited-vs-owned + /// difference survives as *data* rather than as prose in `notes`. This + /// is the comparison the first pair of real reports could not express — + /// C1 reached AT-SPI through AccessKit inherited from eframe and + /// reported `NotBuilt`; C2 reached it through AccessKit it wired itself + /// and reported `Implemented`. + #[test] + fn two_candidates_on_one_platform_differ_only_in_ownership() { + let c1 = implemented( + "accesskit-0.24", + IntegrationOwnership::Inherited { + provider: "eframe 0.35".to_string(), + }, + ); + let c2 = implemented("accesskit-0.24", IntegrationOwnership::CandidateOwned); + for a in [&c1, &c2] { + assert!(matches!(a, AdapterStatus::Implemented { .. })); + } + let own = |a: &AdapterStatus| match a { + AdapterStatus::Implemented { + integration_ownership, + .. + } => integration_ownership.clone(), + _ => unreachable!(), + }; + assert_ne!(own(&c1), own(&c2)); + } + + /// `Inherited` must name its provider, so the claim is checkable + /// against the dependency graph instead of taken on trust. + #[test] + fn inherited_carries_its_provider_and_is_not_conflated_with_owned() { + let inherited = IntegrationOwnership::Inherited { + provider: "eframe 0.35 (bundled AccessKit integration)".to_string(), + }; + match &inherited { + IntegrationOwnership::Inherited { provider } => { + assert!(provider.contains("eframe"), "{provider}") + } + IntegrationOwnership::CandidateOwned => panic!("wrong variant"), + } + assert_ne!(inherited, IntegrationOwnership::CandidateOwned); + } + + /// Mutation: an `Implemented` row that omits `integration_ownership` + /// must fail to deserialize. If this ever passes, the field has become + /// optional in practice and the amendment is decorative — a report + /// could once again carry the distinction only in prose. + #[test] + fn an_implemented_row_without_integration_ownership_is_refused() { + let bad = serde_json::json!({ + "Implemented": { "platform": "at-spi2", "notes": "n" } + }); + let err = serde_json::from_value::(bad) + .unwrap_err() + .to_string(); + assert!(err.contains("integration_ownership"), "{err}"); + } + + /// `NotBuilt` is for uncovered scope only, so it takes no ownership + /// field — a platform reached by *any* route is `Implemented`. + #[test] + fn not_built_takes_no_ownership_field() { + let bad = serde_json::json!({ + "NotBuilt": { + "platform": "windows-uia", + "reason": "no runner", + "integration_ownership": "CandidateOwned" + } + }); + let err = serde_json::from_value::(bad) + .unwrap_err() + .to_string(); + assert!(err.contains("integration_ownership"), "{err}"); + } + + #[test] + fn adapter_rows_round_trip_through_json_both_ways() { + for a in [ + implemented("at-spi2", IntegrationOwnership::CandidateOwned), + implemented( + "accesskit-0.24", + IntegrationOwnership::Inherited { + provider: "eframe 0.35".to_string(), + }, + ), + AdapterStatus::NotBuilt { + platform: "windows-uia".to_string(), + reason: "no runner".to_string(), + }, + ] { + let json = serde_json::to_string(&a).unwrap(); + let back: AdapterStatus = serde_json::from_str(&json).unwrap(); + assert_eq!( + serde_json::to_string(&back).unwrap(), + json, + "round trip changed the row" + ); + } + } + /// An unknown field on the wire must be refused, not ignored — the same /// discipline every deserializable type in this workspace uses. #[test] From 775d7baced10dc2b7e74b6d62228e18b0a65e217 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 30 Jul 2026 19:02:46 -0400 Subject: [PATCH 3/5] Pin-13 schema amendment 2: an inherited provider must actually name something Appends to d76287c, which is left untouched. That amendment justified IntegrationOwnership::Inherited's provider field as a claim "a reader can check against the dependency graph rather than take on trust" -- and then accepted "" and " " for it, in Rust and through serde alike. An unnamed provider is not a weaker claim of inheritance. It is the same claim with its evidence removed, and it renders as Inherited in every table it reaches. Fixed the way this crate already fixed the identical hole in CheckOutcome's reason strings: a checked constructor, plus a hand-written Deserialize over a private wire shadow so the JSON path runs the same check. A hand-edited report is exactly where an unnamed provider would arrive from, so guarding only the Rust constructor would have guarded the path nobody takes. Seven tests. Six fail when the check is reverted to `if false`, verified by doing it and restoring: the constructor on empty and on whitespace-only, the deserialize path on both, the same empty provider nested inside a whole adapter row (the shape a real report carries it in, not just the enum alone), and the accepting case for a real provider. CandidateOwned keeps deserializing unchanged. No candidate report changes: both candidates already name real providers, so this closes a hole rather than correcting a value. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RSX4zSLgKvtiXaPjnMqLGz --- .../round2-candidatekit/src/report.rs | 134 +++++++++++++++++- 1 file changed, 133 insertions(+), 1 deletion(-) diff --git a/spikes/editor-toolkit/round2-candidatekit/src/report.rs b/spikes/editor-toolkit/round2-candidatekit/src/report.rs index 6ad1f94..cfd54d0 100644 --- a/spikes/editor-toolkit/round2-candidatekit/src/report.rs +++ b/spikes/editor-toolkit/round2-candidatekit/src/report.rs @@ -215,7 +215,7 @@ pub struct DependencyDelta { /// would have survived only as prose in `notes` — which is exactly how the /// two candidates diverged in the first place. So it is typed, required on /// every `Implemented` row, and therefore impossible to omit. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] #[serde(deny_unknown_fields)] pub enum IntegrationOwnership { /// The integration came with a dependency the candidate adopted; the @@ -233,6 +233,66 @@ pub enum IntegrationOwnership { CandidateOwned, } +impl IntegrationOwnership { + /// Checked constructor: rejects an empty-or-whitespace-only provider. + /// + /// The amendment that introduced [`IntegrationOwnership::Inherited`] + /// justified it as a claim "a reader can check against the dependency + /// graph rather than take on trust" — and then let the field be `""`, + /// which is checkable against nothing. An unnamed provider is not a + /// weaker claim of inheritance; it is the same claim with its evidence + /// removed, and it reads as `Inherited` in every table it appears in. + pub fn inherited(provider: impl Into) -> Result { + let provider = provider.into(); + if provider.trim().is_empty() { + return Err( + "IntegrationOwnership::inherited: provider must not be empty or \ + whitespace-only — an inherited integration whose provider is unnamed cannot be \ + checked against the dependency graph, which is the only reason this variant \ + carries a provider at all" + .to_string(), + ); + } + Ok(IntegrationOwnership::Inherited { provider }) + } +} + +/// Deserialization shadow for [`IntegrationOwnership`], so the JSON path +/// runs the same provider check the constructor does. Without it a +/// hand-edited or differently-generated report could carry +/// `{"Inherited": {"provider": ""}}` straight past a guard that only ever +/// existed in Rust — the same hole, and the same fix, as +/// [`crate::outcome::CheckOutcome`]'s reason strings. +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +enum IntegrationOwnershipWire { + Inherited { provider: String }, + CandidateOwned, +} + +impl TryFrom for IntegrationOwnership { + type Error = String; + + fn try_from(wire: IntegrationOwnershipWire) -> Result { + match wire { + IntegrationOwnershipWire::Inherited { provider } => { + IntegrationOwnership::inherited(provider) + } + IntegrationOwnershipWire::CandidateOwned => Ok(IntegrationOwnership::CandidateOwned), + } + } +} + +impl<'de> Deserialize<'de> for IntegrationOwnership { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let wire = IntegrationOwnershipWire::deserialize(deserializer)?; + IntegrationOwnership::try_from(wire).map_err(serde::de::Error::custom) + } +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub enum AdapterStatus { @@ -553,6 +613,78 @@ mod tests { assert!(err.contains("integration_ownership"), "{err}"); } + /// Mutation: the checked constructor refuses an empty provider. If this + /// passes, `Inherited`'s "checkable against the dependency graph" claim + /// is decorative. + #[test] + fn the_inherited_constructor_rejects_an_empty_provider() { + let err = IntegrationOwnership::inherited("").unwrap_err(); + assert!(err.contains("provider"), "{err}"); + assert!(err.contains("dependency graph"), "{err}"); + } + + /// Whitespace-only is the same hole wearing a space — a provider of + /// `" "` renders as `Inherited` in a table and names nothing. + #[test] + fn the_inherited_constructor_rejects_a_whitespace_only_provider() { + assert!(IntegrationOwnership::inherited(" \t \n ").is_err()); + } + + #[test] + fn the_inherited_constructor_accepts_a_real_provider() { + let own = IntegrationOwnership::inherited("eframe 0.35").unwrap(); + assert_eq!( + own, + IntegrationOwnership::Inherited { + provider: "eframe 0.35".to_string() + } + ); + } + + /// The JSON path must run the same check — a hand-edited report is + /// exactly where an unnamed provider would arrive from. + #[test] + fn deserializing_an_empty_provider_is_refused() { + let bad = serde_json::json!({ "Inherited": { "provider": "" } }); + let err = serde_json::from_value::(bad) + .unwrap_err() + .to_string(); + assert!(err.contains("provider"), "{err}"); + } + + #[test] + fn deserializing_a_whitespace_only_provider_is_refused() { + let bad = serde_json::json!({ "Inherited": { "provider": " " } }); + assert!(serde_json::from_value::(bad).is_err()); + } + + /// An empty provider must be refused when it arrives nested inside a + /// whole adapter row, not only when deserialized on its own — that is + /// the shape a real report carries it in. + #[test] + fn an_adapter_row_with_an_empty_provider_is_refused() { + let bad = serde_json::json!({ + "Implemented": { + "platform": "at-spi2", + "notes": "n", + "integration_ownership": { "Inherited": { "provider": "" } } + } + }); + let err = serde_json::from_value::(bad) + .unwrap_err() + .to_string(); + assert!(err.contains("provider"), "{err}"); + } + + #[test] + fn deserializing_candidate_owned_still_works() { + let ok = serde_json::json!("CandidateOwned"); + assert_eq!( + serde_json::from_value::(ok).unwrap(), + IntegrationOwnership::CandidateOwned + ); + } + #[test] fn adapter_rows_round_trip_through_json_both_ways() { for a in [ From ad4f6ed4f35fab4977e0d497a4bf9cb05776aa46 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 30 Jul 2026 20:39:32 -0400 Subject: [PATCH 4/5] Pin-13 schema amendment 3: the provider invariant moves into the type Appends to 775d7ba, which is left untouched. That amendment put the non-empty check in a constructor and in Deserialize, and left `Inherited { provider: String }` public. So the struct-literal path bypassed both -- and the very first caller took it: C1's adapter rows were written as `IntegrationOwnership::Inherited { provider: "...".to_string() }`, never touching the constructor that was supposed to be guarding them. A checked constructor sitting beside a public field is a suggestion. The field is the API. Provider is now a newtype whose inner String is private, so every route in -- Provider::new, Deserialize -- runs the same check, and an empty provider cannot be constructed on any path outside this module. The compiler proved it during the change: converting the field turned my own struct-literal test sites into type errors, which is exactly the class of site that had been slipping through. I also compiled a deliberate bypass from outside the module to confirm it is rejected rather than assume it: error[E0308]: mismatched types provider: String::new(), ^^^^^^^^^^^^^ expected `Provider`, found `String` and reverted that probe byte-identically. Two new tests beyond the constructor and wire cases already present. One pins the serialization consequence a type error cannot express: every Provider that exists has been checked, so no serialized row can carry an empty one. The other round-trips a whole adapter row and asserts the provider survives intact, since the shape a real report carries this in is the row, not the enum alone. C1's two construction sites are converted to the checked constructor. No report values change -- both candidates already named real providers. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RSX4zSLgKvtiXaPjnMqLGz --- .../round2-candidatekit/src/report.rs | 132 +++++++++++++++--- 1 file changed, 109 insertions(+), 23 deletions(-) diff --git a/spikes/editor-toolkit/round2-candidatekit/src/report.rs b/spikes/editor-toolkit/round2-candidatekit/src/report.rs index cfd54d0..1410ff5 100644 --- a/spikes/editor-toolkit/round2-candidatekit/src/report.rs +++ b/spikes/editor-toolkit/round2-candidatekit/src/report.rs @@ -227,33 +227,74 @@ pub enum IntegrationOwnership { /// on top of it: a candidate that inherits a bridge still writes the /// accessible nodes for anything it painted itself, and that work shows /// up in [`ReportPart::AccessibilityTreeConstruction`], not here. - Inherited { provider: String }, + Inherited { provider: Provider }, /// The candidate wrote the integration itself — the bridge wiring, the /// event plumbing, the adapter lifecycle. CandidateOwned, } -impl IntegrationOwnership { - /// Checked constructor: rejects an empty-or-whitespace-only provider. - /// - /// The amendment that introduced [`IntegrationOwnership::Inherited`] - /// justified it as a claim "a reader can check against the dependency - /// graph rather than take on trust" — and then let the field be `""`, - /// which is checkable against nothing. An unnamed provider is not a - /// weaker claim of inheritance; it is the same claim with its evidence - /// removed, and it reads as `Inherited` in every table it appears in. - pub fn inherited(provider: impl Into) -> Result { +/// A non-empty provider name, carrying its own invariant. +/// +/// The previous amendment put the check in a constructor and in +/// `Deserialize`, and left `Inherited { provider: String }` public — so the +/// struct-literal path bypassed both, and the very first caller (C1's +/// adapter rows) took it. A checked constructor beside a public field is a +/// suggestion; the field is the API. +/// +/// The inner `String` is private and every route in — `new`, `TryFrom`, +/// `Deserialize` — runs the same check, so an empty provider cannot be +/// constructed *or* serialized, on any path, without editing this module. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +#[serde(transparent)] +pub struct Provider(String); + +impl Provider { + /// The only way to build a [`Provider`]. Rejects empty and + /// whitespace-only names. + pub fn new(provider: impl Into) -> Result { let provider = provider.into(); if provider.trim().is_empty() { return Err( - "IntegrationOwnership::inherited: provider must not be empty or \ - whitespace-only — an inherited integration whose provider is unnamed cannot be \ - checked against the dependency graph, which is the only reason this variant \ - carries a provider at all" + "Provider::new: provider must not be empty or whitespace-only — an inherited \ + integration whose provider is unnamed cannot be checked against the dependency \ + graph, which is the only reason this variant carries a provider at all" .to_string(), ); } - Ok(IntegrationOwnership::Inherited { provider }) + Ok(Provider(provider)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } +} + +impl std::fmt::Display for Provider { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl<'de> Deserialize<'de> for Provider { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let raw = String::deserialize(deserializer)?; + Provider::new(raw).map_err(serde::de::Error::custom) + } +} + +impl IntegrationOwnership { + /// Checked constructor: rejects an empty-or-whitespace-only provider. + /// + /// Retained as the ergonomic route, but it is no longer the *only* + /// guard — [`Provider`] carries the invariant now, so the struct-literal + /// path is closed too. + pub fn inherited(provider: impl Into) -> Result { + Ok(IntegrationOwnership::Inherited { + provider: Provider::new(provider)?, + }) } } @@ -266,7 +307,7 @@ impl IntegrationOwnership { #[derive(Deserialize)] #[serde(deny_unknown_fields)] enum IntegrationOwnershipWire { - Inherited { provider: String }, + Inherited { provider: Provider }, CandidateOwned, } @@ -276,7 +317,7 @@ impl TryFrom for IntegrationOwnership { fn try_from(wire: IntegrationOwnershipWire) -> Result { match wire { IntegrationOwnershipWire::Inherited { provider } => { - IntegrationOwnership::inherited(provider) + Ok(IntegrationOwnership::Inherited { provider }) } IntegrationOwnershipWire::CandidateOwned => Ok(IntegrationOwnership::CandidateOwned), } @@ -548,7 +589,7 @@ mod tests { let c1 = implemented( "accesskit-0.24", IntegrationOwnership::Inherited { - provider: "eframe 0.35".to_string(), + provider: Provider::new("eframe 0.35").unwrap(), }, ); let c2 = implemented("accesskit-0.24", IntegrationOwnership::CandidateOwned); @@ -570,11 +611,11 @@ mod tests { #[test] fn inherited_carries_its_provider_and_is_not_conflated_with_owned() { let inherited = IntegrationOwnership::Inherited { - provider: "eframe 0.35 (bundled AccessKit integration)".to_string(), + provider: Provider::new("eframe 0.35 (bundled AccessKit integration)").unwrap(), }; match &inherited { IntegrationOwnership::Inherited { provider } => { - assert!(provider.contains("eframe"), "{provider}") + assert!(provider.as_str().contains("eframe"), "{provider}") } IntegrationOwnership::CandidateOwned => panic!("wrong variant"), } @@ -636,7 +677,7 @@ mod tests { assert_eq!( own, IntegrationOwnership::Inherited { - provider: "eframe 0.35".to_string() + provider: Provider::new("eframe 0.35").unwrap() } ); } @@ -676,6 +717,51 @@ mod tests { assert!(err.contains("provider"), "{err}"); } + /// Mutation, and the reason this amendment exists: the *struct-literal* + /// path must be closed, not just the constructor. `Provider`'s inner + /// field is private, so outside this module + /// `IntegrationOwnership::Inherited { provider: "".to_string() }` does + /// not compile at all — which is the only kind of guard a caller cannot + /// forget to call. The previous amendment guarded the constructor and + /// `Deserialize` and left the literal open, and the first real caller + /// (C1's adapter rows) took exactly that path. + /// + /// This test pins the *serialization* consequence, the half a type + /// error cannot express: every `Provider` that exists has been checked, + /// so no serialized row can carry an empty provider. + #[test] + fn no_constructible_provider_serializes_as_empty() { + for bad in ["", " ", "\t", "\n \t"] { + assert!( + Provider::new(bad).is_err(), + "Provider::new({bad:?}) must not construct" + ); + } + let good = Provider::new("eframe 0.35").unwrap(); + let json = serde_json::to_string(&good).unwrap(); + assert_eq!( + json, "\"eframe 0.35\"", + "provider must serialize transparently" + ); + assert!(!json.trim_matches('"').trim().is_empty()); + } + + /// The newtype must not smuggle its invariant past the wire either: a + /// whole adapter row carrying an empty provider string is refused on + /// the way in, and a row that round-trips keeps its provider intact. + #[test] + fn an_adapter_row_provider_survives_a_round_trip_unchanged() { + let row = AdapterStatus::Implemented { + platform: "at-spi2".to_string(), + notes: "n".to_string(), + integration_ownership: IntegrationOwnership::inherited("eframe 0.35").unwrap(), + }; + let json = serde_json::to_string(&row).unwrap(); + let back: AdapterStatus = serde_json::from_str(&json).unwrap(); + assert_eq!(serde_json::to_string(&back).unwrap(), json); + assert!(json.contains("eframe 0.35"), "{json}"); + } + #[test] fn deserializing_candidate_owned_still_works() { let ok = serde_json::json!("CandidateOwned"); @@ -692,7 +778,7 @@ mod tests { implemented( "accesskit-0.24", IntegrationOwnership::Inherited { - provider: "eframe 0.35".to_string(), + provider: Provider::new("eframe 0.35").unwrap(), }, ), AdapterStatus::NotBuilt { From e2979dfccda91e209d24d8fce7fa2ed92a4236b2 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 30 Jul 2026 21:42:05 -0400 Subject: [PATCH 5/5] Packet 2B: both candidates consume the frozen text apparatus C1 (egui + lyon) and C2 (vello) land together, from one commit, because scoring either against an apparatus the other had already moved would not be a comparison. Both were built against the pin-13 oracle frozen at 694d135 and its three schema amendments, all committed before either candidate existed. Both pass: Pass / Pass / NOT RUN / Pass / Pass, 80/80 hit-test probes each, five live out-of-process AT-SPI readbacks each, no bus-unreachable evidence claimed. Check 3 is NOT RUN by the 1.2 ruling, so both criterion cells are NOT RUN and both candidates are eligible -- check 3 is not disqualifying, and the checks that are (2 and 5) both pass. Neither candidate re-shapes. Each takes glyph ids and offsets from the resolved data and draws those glyphs at those positions; outline extraction and path conversion are candidate-owned, the staff-to-device transform is shared because re-implementing it would inject a divergence check 1 is not measuring. D1 is 0 on all ten rasters, worst D2 0.14% against a 2% tolerance, worst D4 1.22%. No tolerance was touched. Round 1's binaries are byte-identical. Both candidates added Round 2 entry points beside them rather than editing frozen evidence. The cost measurement, which is why the packet exists: ReportPart C1 C2 TextRendering 651 342 HitTestResolution 228 254 AccessibilityTreeConstruction 64 109 AccessibilityIntegrationWiring 62 199 FixtureAndReportPlumbing 1845 1925 Product-side accessibility -- tree construction plus integration wiring -- is 126 for C1 against 308 for C2: 2.4x, 182 lines. C1 inherits eframe's AccessKit path and writes a single 62-line file to reach it; C2 inherits nothing from vello and writes 199 lines of adapter lifecycle, event loop, and bridge setup. C1 also writes *less* semantic-node code, 64 against 109. That figure survived four reattributions, and the earlier ones were wrong in ways worth recording so the next measurement is not made the same way: - The first reading, 190 vs 30, was backwards. C1's tree row counted a file that also held cosmetic glyph rendering; C2's excluded wiring that belonged in it. Both errors pushed the same direction, which is exactly why the number looked like a clean story. - The second attribution put verifier subprocess orchestration under AccessibilityIntegrationWiring, so C1's row grew 692 -> 1011 -> 1066 across two review rounds while measuring nothing but how much review its harness attracted. That machinery is spike apparatus, and it is FixtureAndReportPlumbing now. - The mapping was disjoint before it was exhaustive: a 37-line file sat in no part at all. Both candidates now assert every source file is claimed by exactly one part and fail naming any that is not. - C2 serialized two rows for one part while C1 serialized one. My own comparison script summed them silently, which is how it survived a review; it now refuses to aggregate and fails instead. Three caveats belong with the number rather than under it. By non-comment code the same comparison reads 55 vs 176, a 3.2x ratio -- same direction, larger gap, because C1's files are proportionally more documentation; the committed reports encode whole-file maintenance surface, so that is the official figure and this is the sensitivity check. The dependency delta points the other way: C1 carried accesskit at the Round 1 baseline and C2 carried none, yet both pull the same 16-17 AccessKit/AT-SPI/zbus crates once a live tree exists, so inheritance saved code and not dependencies. And roughly 1,900 lines per candidate is spike-only plumbing -- verifier orchestration, evidence handling, report assembly -- that no real editor would carry. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RSX4zSLgKvtiXaPjnMqLGz --- spikes/editor-toolkit/Cargo.lock | 14 + .../round1-candidates/c1-egui-lyon/Cargo.toml | 26 + .../round2_a11y_evidence/F-A.json | 15 + .../round2_a11y_evidence/F-B.json | 15 + .../round2_a11y_evidence/F-C.json | 15 + .../round2_a11y_evidence/F-D.json | 15 + .../round2_a11y_evidence/F-E.json | 15 + .../c1-egui-lyon/round2_report.json | 1748 ++++++++++++++++ .../c1-egui-lyon/src/a11y_app.rs | 62 + .../c1-egui-lyon/src/a11y_node.rs | 64 + .../c1-egui-lyon/src/a11y_subprocess.rs | 1004 ++++++++++ .../c1-egui-lyon/src/bin/c1_round2_a11y.rs | 58 + .../c1-egui-lyon/src/bin/c1_round2_text.rs | 746 +++++++ .../c1-egui-lyon/src/glyph_outline.rs | 302 +++ .../c1-egui-lyon/src/hit_test.rs | 228 +++ .../round1-candidates/c1-egui-lyon/src/lib.rs | 37 + .../c1-egui-lyon/src/render_target.rs | 349 ++++ .../round1-candidates/c2-vello/Cargo.toml | 24 + .../c2-vello/round2_report.json | 1749 +++++++++++++++++ .../c2-vello/src/bin/c2_round2_text.rs | 1145 +++++++++++ .../src/bin/c2_round2_text/a11y_subprocess.rs | 780 ++++++++ .../src/bin/c2_round2_text/a11y_tree.rs | 109 + .../src/bin/c2_round2_text/a11y_wiring.rs | 199 ++ .../src/bin/c2_round2_text/hittest.rs | 254 +++ .../c2-vello/src/bin/c2_round2_text/render.rs | 342 ++++ 25 files changed, 9315 insertions(+) create mode 100644 spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-A.json create mode 100644 spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-B.json create mode 100644 spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-C.json create mode 100644 spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-D.json create mode 100644 spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-E.json create mode 100644 spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_report.json create mode 100644 spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/a11y_app.rs create mode 100644 spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/a11y_node.rs create mode 100644 spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/a11y_subprocess.rs create mode 100644 spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/bin/c1_round2_a11y.rs create mode 100644 spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/bin/c1_round2_text.rs create mode 100644 spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/glyph_outline.rs create mode 100644 spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/hit_test.rs create mode 100644 spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/lib.rs create mode 100644 spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/render_target.rs create mode 100644 spikes/editor-toolkit/round1-candidates/c2-vello/round2_report.json create mode 100644 spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text.rs create mode 100644 spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/a11y_subprocess.rs create mode 100644 spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/a11y_tree.rs create mode 100644 spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/a11y_wiring.rs create mode 100644 spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/hittest.rs create mode 100644 spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/render.rs diff --git a/spikes/editor-toolkit/Cargo.lock b/spikes/editor-toolkit/Cargo.lock index 79707e9..5220805 100644 --- a/spikes/editor-toolkit/Cargo.lock +++ b/spikes/editor-toolkit/Cargo.lock @@ -659,6 +659,7 @@ version = "0.1.0" dependencies = [ "anyhow", "bytemuck", + "eframe", "egui", "egui-wgpu", "epiphany-layout-ir", @@ -667,18 +668,31 @@ dependencies = [ "lyon_tessellation", "pollster", "round1-harness", + "round2-candidatekit", + "round2-diff", + "round2-textkit", + "serde_json", + "ttf-parser", ] [[package]] name = "c2-vello" version = "0.1.0" dependencies = [ + "accesskit", + "accesskit_winit 0.33.2", "anyhow", "bytemuck", "epiphany-layout-ir", "pollster", "round1-harness", + "round2-candidatekit", + "round2-diff", + "round2-textkit", + "serde_json", + "ttf-parser", "vello", + "winit", ] [[package]] diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/Cargo.toml b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/Cargo.toml index f3866e2..5dc28be 100644 --- a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/Cargo.toml +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/Cargo.toml @@ -25,3 +25,29 @@ lyon_tessellation = "1.0" pollster = "0.4" bytemuck = "1" anyhow = "1" + +# --- Packet 2B-C1 additions (Round 2, text) --- +# +# round2-candidatekit / round2-diff / round2-textkit: the candidate-neutral +# apparatus (fixture loading, the bounded visual differential, the report +# shape and scoring rule) Round 2 requires every candidate to consume rather +# than re-derive. See src/bin/round2_text.rs. +round2-candidatekit = { path = "../../round2-candidatekit" } +round2-diff = { path = "../../round2-diff" } +round2-textkit = { path = "../../round2-textkit" } +# Glyph outline extraction from the two declared host faces (TeX Gyre +# Pagella, Liberation Serif) is candidate-owned work (recipe: "Outline +# extraction from the face and conversion to a lyon path IS yours to +# write"). Pinned to the exact version round2-textkit shapes fixtures +# against, so a `ttf-parser` behavioural difference cannot be mistaken for a +# rendering bug in this candidate's own code. +ttf-parser = "=0.25.1" +# Writing round2_candidatekit::CandidateReport to disk. +serde_json = "1" +# eframe/winit windowed route for check 5 (accessibility): Round 1's binary +# is headless (offscreen wgpu only, no winit/eframe at all), and check 5 +# requires a real window on the live AT-SPI2 bus (recipe: "your Round 1 +# binary is headless, so this is a separate mode"). See +# src/bin/round2_a11y.rs, the same first-party AccessKit route +# `probe-egui`'s Round 0 binary used. +eframe = "0.35" diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-A.json b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-A.json new file mode 100644 index 0000000..799e759 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-A.json @@ -0,0 +1,15 @@ +{ + "fixture_id": "F-A", + "verdict": "PASS", + "reason": "a node with an accepted role ('label') carries the accessible name byte-for-byte", + "observed_role": "label", + "observed_name": "Allegro affettuoso \u2014 al fine", + "observed_name_hex": "416c6c6567726f20616666657474756f736f20e2809420616c2066696e65", + "prohibited_outcome": null, + "walked_tree": [ + "desktop / application:'c1_round2_a11y'", + "desktop / application:'c1_round2_a11y' / frame:''", + "desktop / application:'c1_round2_a11y' / frame:'' / label:'Round 2 check 5 \u2014 fixture F-A'", + "desktop / application:'c1_round2_a11y' / frame:'' / label:'Allegro affettuoso \u2014 al fine'" + ] +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-B.json b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-B.json new file mode 100644 index 0000000..0baed16 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-B.json @@ -0,0 +1,15 @@ +{ + "fixture_id": "F-B", + "verdict": "PASS", + "reason": "a node with an accepted role ('label') carries the accessible name byte-for-byte", + "observed_role": "label", + "observed_name": "Coro \u05d0\u05d1\u05d2", + "observed_name_hex": "436f726f20d790d791d792", + "prohibited_outcome": null, + "walked_tree": [ + "desktop / application:'c1_round2_a11y'", + "desktop / application:'c1_round2_a11y' / frame:''", + "desktop / application:'c1_round2_a11y' / frame:'' / label:'Round 2 check 5 \u2014 fixture F-B'", + "desktop / application:'c1_round2_a11y' / frame:'' / label:'Coro \u05d0\u05d1\u05d2'" + ] +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-C.json b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-C.json new file mode 100644 index 0000000..27bf697 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-C.json @@ -0,0 +1,15 @@ +{ + "fixture_id": "F-C", + "verdict": "PASS", + "reason": "a node with an accepted role ('label') carries the accessible name byte-for-byte", + "observed_role": "label", + "observed_name": "Coro \u0627", + "observed_name_hex": "436f726f20d8a7", + "prohibited_outcome": null, + "walked_tree": [ + "desktop / application:'c1_round2_a11y'", + "desktop / application:'c1_round2_a11y' / frame:''", + "desktop / application:'c1_round2_a11y' / frame:'' / label:'Round 2 check 5 \u2014 fixture F-C'", + "desktop / application:'c1_round2_a11y' / frame:'' / label:'Coro \u0627'" + ] +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-D.json b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-D.json new file mode 100644 index 0000000..0def590 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-D.json @@ -0,0 +1,15 @@ +{ + "fixture_id": "F-D", + "verdict": "PASS", + "reason": "a node with an accepted role ('label') carries the accessible name byte-for-byte", + "observed_role": "label", + "observed_name": "Allegro \u05d0\u05d1\u05d2 con brio", + "observed_name_hex": "416c6c6567726f20d790d791d79220636f6e206272696f", + "prohibited_outcome": null, + "walked_tree": [ + "desktop / application:'c1_round2_a11y'", + "desktop / application:'c1_round2_a11y' / frame:''", + "desktop / application:'c1_round2_a11y' / frame:'' / label:'Round 2 check 5 \u2014 fixture F-D'", + "desktop / application:'c1_round2_a11y' / frame:'' / label:'Allegro \u05d0\u05d1\u05d2 con brio'" + ] +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-E.json b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-E.json new file mode 100644 index 0000000..bd85c44 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_a11y_evidence/F-E.json @@ -0,0 +1,15 @@ +{ + "fixture_id": "F-E", + "verdict": "PASS", + "reason": "a node with an accepted role ('label') carries the accessible name byte-for-byte", + "observed_role": "label", + "observed_name": "Cafe\u0301 \u2014 resume\u0301", + "observed_name_hex": "43616665cc8120e2809420726573756d65cc81", + "prohibited_outcome": null, + "walked_tree": [ + "desktop / application:'c1_round2_a11y'", + "desktop / application:'c1_round2_a11y' / frame:''", + "desktop / application:'c1_round2_a11y' / frame:'' / label:'Round 2 check 5 \u2014 fixture F-E'", + "desktop / application:'c1_round2_a11y' / frame:'' / label:'Cafe\u0301 \u2014 resume\u0301'" + ] +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_report.json b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_report.json new file mode 100644 index 0000000..8879214 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/round2_report.json @@ -0,0 +1,1748 @@ +{ + "candidate_id": "C1 egui 0.35 + lyon 1.0 (egui_wgpu::Renderer)", + "check1_faithful_consumption": "Pass", + "check2_fallback": "Pass", + "check3_bidi": { + "NotRun": "ROUND2_TEXT_RECIPE.md §1.2 (2026-07-29 ruling): check 3 is NOT RUN for every candidate, on both adapters — no Arabic-capable face is installed, and pin 9 makes an absent required face environmental NOT RUN. F-D's supplementary Hebrew/Latin bidi evidence is recorded separately and must never upgrade check 3 to PASS." + }, + "check4_hit_testing": "Pass", + "check5_accessibility": "Pass", + "check5_bus_unreachable_evidence": null, + "supplementary_f_d_bidi": "Pass", + "per_fixture_diffs": { + "F-A": { + "width": 1920, + "height": 1080, + "band_pixel_count": 52244, + "d1_pixels_outside_band_differing": 0, + "d1_pass": true, + "reference_ink_mass": 31933.196078430126, + "candidate_ink_mass": 31911.352941176752, + "d2_relative_delta": 0.0006840260273267325, + "d2_pass": true, + "reference_centroid": [ + 890.8902938917568, + 507.18029108563553 + ], + "candidate_centroid": [ + 891.1530317749048, + 507.20871028135434 + ], + "d3_delta": [ + 0.26273788314802005, + 0.028419195718811352 + ], + "d3_pass": true, + "in_band_max_abs_delta_luma": 65, + "in_band_count_delta_gt_report_threshold": 2953, + "d4_regions": [ + { + "label": "F-A seg0.glyph0 (face 0, gid 34)", + "reference_mass": 1962.933333333338, + "candidate_mass": 1957.9490196078427, + "relative_delta": 0.0025392170181506592, + "pass": true + }, + { + "label": "F-A seg0.glyph1 (face 0, gid 77)", + "reference_mass": 1126.7843137254906, + "candidate_mass": 1123.6549019607862, + "relative_delta": 0.002777294400165739, + "pass": true + }, + { + "label": "F-A seg0.glyph2 (face 0, gid 77)", + "reference_mass": 1123.1372549019607, + "candidate_mass": 1124.0470588235312, + "relative_delta": 0.0008100558659234804, + "pass": true + }, + { + "label": "F-A seg0.glyph3 (face 0, gid 70)", + "reference_mass": 1347.9568627450994, + "candidate_mass": 1346.0039215686281, + "relative_delta": 0.001448815782201049, + "pass": true + }, + { + "label": "F-A seg0.glyph4 (face 0, gid 72)", + "reference_mass": 2228.1019607843186, + "candidate_mass": 2223.4941176470616, + "relative_delta": 0.002068057574723852, + "pass": true + }, + { + "label": "F-A seg0.glyph5 (face 0, gid 83)", + "reference_mass": 1012.6588235294114, + "candidate_mass": 1013.9098039215687, + "relative_delta": 0.0012353424105833444, + "pass": true + }, + { + "label": "F-A seg0.glyph6 (face 0, gid 80)", + "reference_mass": 1436.8078431372576, + "candidate_mass": 1435.1529411764718, + "relative_delta": 0.0011517907343631284, + "pass": true + }, + { + "label": "F-A seg0.glyph8 (face 0, gid 66)", + "reference_mass": 1406.149019607845, + "candidate_mass": 1406.2078431372556, + "relative_delta": 0.000041833069319338136, + "pass": true + }, + { + "label": "F-A seg0.glyph9 (face 0, gid 234)", + "reference_mass": 2515.3490196078515, + "candidate_mass": 2514.1294117647076, + "relative_delta": 0.00048486624863455964, + "pass": true + }, + { + "label": "F-A seg0.glyph10 (face 0, gid 70)", + "reference_mass": 1349.2196078431382, + "candidate_mass": 1344.7411764705887, + "relative_delta": 0.003319275339993548, + "pass": true + }, + { + "label": "F-A seg0.glyph11 (face 0, gid 85)", + "reference_mass": 1090.1529411764707, + "candidate_mass": 1092.3176470588246, + "relative_delta": 0.0019856900812631135, + "pass": true + }, + { + "label": "F-A seg0.glyph12 (face 0, gid 85)", + "reference_mass": 1090.2745098039222, + "candidate_mass": 1092.66274509804, + "relative_delta": 0.0021904898928135587, + "pass": true + }, + { + "label": "F-A seg0.glyph13 (face 0, gid 86)", + "reference_mass": 1597.7372549019626, + "candidate_mass": 1599.1215686274522, + "relative_delta": 0.0008664213851448065, + "pass": true + }, + { + "label": "F-A seg0.glyph14 (face 0, gid 80)", + "reference_mass": 1438.9960784313753, + "candidate_mass": 1431.9215686274504, + "relative_delta": 0.004916281503446981, + "pass": true + }, + { + "label": "F-A seg0.glyph15 (face 0, gid 84)", + "reference_mass": 1181.8352941176502, + "candidate_mass": 1180.5529411764714, + "relative_delta": 0.0010850521621425348, + "pass": true + }, + { + "label": "F-A seg0.glyph16 (face 0, gid 80)", + "reference_mass": 1437.9294117647094, + "candidate_mass": 1434.7725490196074, + "relative_delta": 0.0021954226120374756, + "pass": true + }, + { + "label": "F-A seg0.glyph18 (face 0, gid 119)", + "reference_mass": 959.780392156863, + "candidate_mass": 959.7529411764707, + "relative_delta": 0.00002860131402617683, + "pass": true + }, + { + "label": "F-A seg0.glyph20 (face 0, gid 66)", + "reference_mass": 1401.8509803921577, + "candidate_mass": 1406.5764705882366, + "relative_delta": 0.0033708933846570387, + "pass": true + }, + { + "label": "F-A seg0.glyph21 (face 0, gid 77)", + "reference_mass": 1125.5294117647059, + "candidate_mass": 1123.419607843139, + "relative_delta": 0.0018744991463697039, + "pass": true + }, + { + "label": "F-A seg0.glyph23 (face 0, gid 97)", + "reference_mass": 2139.2784313725547, + "candidate_mass": 2139.8549019607913, + "relative_delta": 0.00026946963975452555, + "pass": true + }, + { + "label": "F-A seg0.glyph24 (face 0, gid 79)", + "reference_mass": 1628.7137254901959, + "candidate_mass": 1632.3764705882386, + "relative_delta": 0.0022488575129679866, + "pass": true + }, + { + "label": "F-A seg0.glyph25 (face 0, gid 70)", + "reference_mass": 1347.239215686276, + "candidate_mass": 1343.86274509804, + "relative_delta": 0.0025062145971724357, + "pass": true + } + ], + "d4_pass": true, + "d4_worst": { + "label": "F-A seg0.glyph14 (face 0, gid 80)", + "reference_mass": 1438.9960784313753, + "candidate_mass": 1431.9215686274504, + "relative_delta": 0.004916281503446981, + "pass": true + }, + "pass": true + }, + "F-B": { + "width": 1920, + "height": 1080, + "band_pixel_count": 16456, + "d1_pixels_outside_band_differing": 0, + "d1_pass": true, + "reference_ink_mass": 9836.71764705878, + "candidate_ink_mass": 9843.047058823493, + "d2_relative_delta": 0.0006434475392922739, + "d2_pass": true, + "reference_centroid": [ + 407.43842537942237, + 504.8943554023082 + ], + "candidate_centroid": [ + 407.6547103818109, + 504.90202460022675 + ], + "d3_delta": [ + 0.2162850023885312, + 0.0076691979185739 + ], + "d3_pass": true, + "in_band_max_abs_delta_luma": 64, + "in_band_count_delta_gt_report_threshold": 869, + "d4_regions": [ + { + "label": "F-B seg0.glyph0 (face 0, gid 36)", + "reference_mass": 1867.0078431372583, + "candidate_mass": 1868.686274509805, + "relative_delta": 0.0008989953516886158, + "pass": true + }, + { + "label": "F-B seg0.glyph1 (face 0, gid 80)", + "reference_mass": 1438.5607843137286, + "candidate_mass": 1434.1529411764707, + "relative_delta": 0.0030640645743452, + "pass": true + }, + { + "label": "F-B seg0.glyph2 (face 0, gid 83)", + "reference_mass": 1013.2980392156862, + "candidate_mass": 1013.3058823529412, + "relative_delta": 7.740207669876935e-6, + "pass": true + }, + { + "label": "F-B seg0.glyph3 (face 0, gid 80)", + "reference_mass": 1438.847058823533, + "candidate_mass": 1433.5215686274512, + "relative_delta": 0.003701220476091557, + "pass": true + }, + { + "label": "F-B seg1.glyph0 (face 1, gid 1282)", + "reference_mass": 849.0705882352955, + "candidate_mass": 851.4274509803922, + "relative_delta": 0.0027758148471438452, + "pass": true + }, + { + "label": "F-B seg1.glyph1 (face 1, gid 1281)", + "reference_mass": 1425.415686274511, + "candidate_mass": 1436.7019607843135, + "relative_delta": 0.00791788291547465, + "pass": true + }, + { + "label": "F-B seg1.glyph2 (face 1, gid 1280)", + "reference_mass": 1804.5176470588276, + "candidate_mass": 1805.2509803921562, + "relative_delta": 0.0004063874545775871, + "pass": true + } + ], + "d4_pass": true, + "d4_worst": { + "label": "F-B seg1.glyph1 (face 1, gid 1281)", + "reference_mass": 1425.415686274511, + "candidate_mass": 1436.7019607843135, + "relative_delta": 0.00791788291547465, + "pass": true + }, + "pass": true + }, + "F-C": { + "width": 1920, + "height": 1080, + "band_pixel_count": 9595, + "d1_pixels_outside_band_differing": 0, + "d1_pass": true, + "reference_ink_mass": 5757.713725490225, + "candidate_ink_mass": 5749.666666666617, + "d2_relative_delta": 0.0013976135680353488, + "d2_pass": true, + "reference_centroid": [ + 295.4923870245324, + 505.39794798725103 + ], + "candidate_centroid": [ + 295.38524313430213, + 505.42091647256933 + ], + "d3_delta": [ + 0.10714389023024751, + 0.022968485318301646 + ], + "d3_pass": true, + "in_band_max_abs_delta_luma": 64, + "in_band_count_delta_gt_report_threshold": 509, + "d4_regions": [ + { + "label": "F-C seg0.glyph0 (face 0, gid 36)", + "reference_mass": 1867.0078431372583, + "candidate_mass": 1868.686274509805, + "relative_delta": 0.0008989953516886158, + "pass": true + }, + { + "label": "F-C seg0.glyph1 (face 0, gid 80)", + "reference_mass": 1438.5607843137286, + "candidate_mass": 1434.1529411764707, + "relative_delta": 0.0030640645743452, + "pass": true + }, + { + "label": "F-C seg0.glyph2 (face 0, gid 83)", + "reference_mass": 1013.2980392156862, + "candidate_mass": 1013.3058823529412, + "relative_delta": 7.740207669876935e-6, + "pass": true + }, + { + "label": "F-C seg0.glyph3 (face 0, gid 80)", + "reference_mass": 1438.847058823533, + "candidate_mass": 1433.5215686274512, + "relative_delta": 0.003701220476091557, + "pass": true + } + ], + "d4_pass": true, + "d4_worst": { + "label": "F-C seg0.glyph3 (face 0, gid 80)", + "reference_mass": 1438.847058823533, + "candidate_mass": 1433.5215686274512, + "relative_delta": 0.003701220476091557, + "pass": true + }, + "pass": true + }, + "F-D": { + "width": 1920, + "height": 1080, + "band_pixel_count": 38964, + "d1_pixels_outside_band_differing": 0, + "d1_pass": true, + "reference_ink_mass": 23733.22352941099, + "candidate_ink_mass": 23721.407843137342, + "d2_relative_delta": 0.0004978542530897446, + "d2_pass": true, + "reference_centroid": [ + 707.2675298233589, + 507.4185305219683 + ], + "candidate_centroid": [ + 707.385752325313, + 507.4383441845064 + ], + "d3_delta": [ + 0.11822250195405104, + 0.01981366253806982 + ], + "d3_pass": true, + "in_band_max_abs_delta_luma": 81, + "in_band_count_delta_gt_report_threshold": 2346, + "d4_regions": [ + { + "label": "F-D seg0.glyph0 (face 0, gid 34)", + "reference_mass": 1962.933333333338, + "candidate_mass": 1957.9490196078427, + "relative_delta": 0.0025392170181506592, + "pass": true + }, + { + "label": "F-D seg0.glyph1 (face 0, gid 77)", + "reference_mass": 1126.7843137254906, + "candidate_mass": 1123.6549019607862, + "relative_delta": 0.002777294400165739, + "pass": true + }, + { + "label": "F-D seg0.glyph2 (face 0, gid 77)", + "reference_mass": 1123.1372549019607, + "candidate_mass": 1124.0470588235312, + "relative_delta": 0.0008100558659234804, + "pass": true + }, + { + "label": "F-D seg0.glyph3 (face 0, gid 70)", + "reference_mass": 1347.9568627450994, + "candidate_mass": 1346.0039215686281, + "relative_delta": 0.001448815782201049, + "pass": true + }, + { + "label": "F-D seg0.glyph4 (face 0, gid 72)", + "reference_mass": 2228.1019607843186, + "candidate_mass": 2223.4941176470616, + "relative_delta": 0.002068057574723852, + "pass": true + }, + { + "label": "F-D seg0.glyph5 (face 0, gid 83)", + "reference_mass": 1012.6588235294114, + "candidate_mass": 1013.9098039215687, + "relative_delta": 0.0012353424105833444, + "pass": true + }, + { + "label": "F-D seg0.glyph6 (face 0, gid 80)", + "reference_mass": 1436.8078431372576, + "candidate_mass": 1435.1529411764718, + "relative_delta": 0.0011517907343631284, + "pass": true + }, + { + "label": "F-D seg2.glyph1 (face 0, gid 68)", + "reference_mass": 1028.6627450980407, + "candidate_mass": 1026.4196078431376, + "relative_delta": 0.00218063429009409, + "pass": true + }, + { + "label": "F-D seg2.glyph2 (face 0, gid 80)", + "reference_mass": 1438.725490196082, + "candidate_mass": 1434.8039215686283, + "relative_delta": 0.0027257240204448694, + "pass": true + }, + { + "label": "F-D seg2.glyph3 (face 0, gid 79)", + "reference_mass": 1629.4039215686275, + "candidate_mass": 1631.6431372549055, + "relative_delta": 0.001374254509049074, + "pass": true + }, + { + "label": "F-D seg2.glyph5 (face 0, gid 67)", + "reference_mass": 1962.7607843137268, + "candidate_mass": 1960.8431372549023, + "relative_delta": 0.0009770151687104411, + "pass": true + }, + { + "label": "F-D seg2.glyph6 (face 0, gid 83)", + "reference_mass": 1013.5490196078433, + "candidate_mass": 1013.8039215686275, + "relative_delta": 0.0002514944574489558, + "pass": true + }, + { + "label": "F-D seg2.glyph7 (face 0, gid 74)", + "reference_mass": 901.1450980392156, + "candidate_mass": 902.0745098039216, + "relative_delta": 0.0010313674975632112, + "pass": true + }, + { + "label": "F-D seg2.glyph8 (face 0, gid 80)", + "reference_mass": 1438.1529411764725, + "candidate_mass": 1434.5411764705889, + "relative_delta": 0.0025113912453073543, + "pass": true + }, + { + "label": "F-D seg1.glyph0 (face 1, gid 1282)", + "reference_mass": 851.0117647058834, + "candidate_mass": 850.8196078431373, + "relative_delta": 0.00022579812725911154, + "pass": true + }, + { + "label": "F-D seg1.glyph1 (face 1, gid 1281)", + "reference_mass": 1426.3529411764723, + "candidate_mass": 1436.858823529412, + "relative_delta": 0.0073655559221367795, + "pass": true + }, + { + "label": "F-D seg1.glyph2 (face 1, gid 1280)", + "reference_mass": 1805.0784313725549, + "candidate_mass": 1805.388235294118, + "relative_delta": 0.00017162906396679163, + "pass": true + } + ], + "d4_pass": true, + "d4_worst": { + "label": "F-D seg1.glyph1 (face 1, gid 1281)", + "reference_mass": 1426.3529411764723, + "candidate_mass": 1436.858823529412, + "relative_delta": 0.0073655559221367795, + "pass": true + }, + "pass": true + }, + "F-E": { + "width": 1920, + "height": 1080, + "band_pixel_count": 27495, + "d1_pixels_outside_band_differing": 0, + "d1_pass": true, + "reference_ink_mass": 16241.003921568197, + "candidate_ink_mass": 16239.494117647086, + "d2_relative_delta": 0.00009296247500479724, + "d2_pass": true, + "reference_centroid": [ + 606.0381566289889, + 506.37877838133613 + ], + "candidate_centroid": [ + 605.8534678589202, + 506.39771522874446 + ], + "d3_delta": [ + 0.1846887700686466, + 0.018936847408326685 + ], + "d3_pass": true, + "in_band_max_abs_delta_luma": 65, + "in_band_count_delta_gt_report_threshold": 1456, + "d4_regions": [ + { + "label": "F-E seg0.glyph0 (face 0, gid 36)", + "reference_mass": 1867.0078431372583, + "candidate_mass": 1868.686274509805, + "relative_delta": 0.0008989953516886158, + "pass": true + }, + { + "label": "F-E seg0.glyph1 (face 0, gid 66)", + "reference_mass": 1406.5058823529423, + "candidate_mass": 1404.9647058823539, + "relative_delta": 0.0010957483291930445, + "pass": true + }, + { + "label": "F-E seg0.glyph2 (face 0, gid 71)", + "reference_mass": 1293.4627450980406, + "candidate_mass": 1298.0549019607865, + "relative_delta": 0.0035502815060961435, + "pass": true + }, + { + "label": "F-E seg0.glyph3 (face 0, gid 198)", + "reference_mass": 1576.113725490198, + "candidate_mass": 1573.3725490196093, + "relative_delta": 0.0017391996695772355, + "pass": true + }, + { + "label": "F-E seg0.glyph5 (face 0, gid 119)", + "reference_mass": 959.780392156863, + "candidate_mass": 959.7529411764707, + "relative_delta": 0.00002860131402617683, + "pass": true + }, + { + "label": "F-E seg0.glyph7 (face 0, gid 83)", + "reference_mass": 1013.5764705882351, + "candidate_mass": 1014.1686274509804, + "relative_delta": 0.0005842251472171408, + "pass": true + }, + { + "label": "F-E seg0.glyph8 (face 0, gid 70)", + "reference_mass": 1347.929411764708, + "candidate_mass": 1346.4823529411774, + "relative_delta": 0.0010735419903302715, + "pass": true + }, + { + "label": "F-E seg0.glyph9 (face 0, gid 84)", + "reference_mass": 1183.2627450980413, + "candidate_mass": 1180.0549019607845, + "relative_delta": 0.002711015072980235, + "pass": true + }, + { + "label": "F-E seg0.glyph10 (face 0, gid 86)", + "reference_mass": 1597.6784313725498, + "candidate_mass": 1599.3764705882356, + "relative_delta": 0.0010628166359028194, + "pass": true + }, + { + "label": "F-E seg0.glyph11 (face 0, gid 78)", + "reference_mass": 2515.4901960784387, + "candidate_mass": 2517.082352941179, + "relative_delta": 0.0006329409930607496, + "pass": true + }, + { + "label": "F-E seg0.glyph12 (face 0, gid 198)", + "reference_mass": 1554.384313725492, + "candidate_mass": 1549.6235294117657, + "relative_delta": 0.003062810317685005, + "pass": true + } + ], + "d4_pass": true, + "d4_worst": { + "label": "F-E seg0.glyph2 (face 0, gid 71)", + "reference_mass": 1293.4627450980406, + "candidate_mass": 1298.0549019607865, + "relative_delta": 0.0035502815060961435, + "pass": true + }, + "pass": true + } + }, + "hittest_probe_results": [ + { + "fixture_id": "F-A", + "point": { + "x": 209.765625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 278.173828125, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "actual_source_offset": 1, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 315.4296875, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "actual_source_offset": 2, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 364.697265625, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "actual_source_offset": 3, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 431.884765625, + "y": 540.0 + }, + "expected_source_offset": 4, + "expected_affinity": "Downstream", + "actual_source_offset": 4, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 493.75, + "y": 540.0 + }, + "expected_source_offset": 5, + "expected_affinity": "Downstream", + "actual_source_offset": 5, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 553.955078125, + "y": 540.0 + }, + "expected_source_offset": 6, + "expected_affinity": "Downstream", + "actual_source_offset": 6, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 604.8828125, + "y": 540.0 + }, + "expected_source_offset": 7, + "expected_affinity": "Downstream", + "actual_source_offset": 7, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 652.880859375, + "y": 540.0 + }, + "expected_source_offset": 8, + "expected_affinity": "Downstream", + "actual_source_offset": 8, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 704.833984375, + "y": 540.0 + }, + "expected_source_offset": 9, + "expected_affinity": "Downstream", + "actual_source_offset": 9, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 744.7265625, + "y": 540.0 + }, + "expected_source_offset": 10, + "expected_affinity": "Downstream", + "actual_source_offset": 10, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 795.3125, + "y": 540.0 + }, + "expected_source_offset": 11, + "expected_affinity": "Downstream", + "actual_source_offset": 11, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 846.826171875, + "y": 540.0 + }, + "expected_source_offset": 12, + "expected_affinity": "Downstream", + "actual_source_offset": 12, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 888.525390625, + "y": 540.0 + }, + "expected_source_offset": 13, + "expected_affinity": "Downstream", + "actual_source_offset": 13, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 947.998046875, + "y": 540.0 + }, + "expected_source_offset": 14, + "expected_affinity": "Downstream", + "actual_source_offset": 14, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1021.533203125, + "y": 540.0 + }, + "expected_source_offset": 15, + "expected_affinity": "Downstream", + "actual_source_offset": 15, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1083.59375, + "y": 540.0 + }, + "expected_source_offset": 16, + "expected_affinity": "Downstream", + "actual_source_offset": 16, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1145.703125, + "y": 540.0 + }, + "expected_source_offset": 17, + "expected_affinity": "Downstream", + "actual_source_offset": 17, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1196.630859375, + "y": 540.0 + }, + "expected_source_offset": 18, + "expected_affinity": "Downstream", + "actual_source_offset": 18, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1276.611328125, + "y": 540.0 + }, + "expected_source_offset": 19, + "expected_affinity": "Downstream", + "actual_source_offset": 19, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1356.640625, + "y": 540.0 + }, + "expected_source_offset": 22, + "expected_affinity": "Downstream", + "actual_source_offset": 22, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1404.638671875, + "y": 540.0 + }, + "expected_source_offset": 23, + "expected_affinity": "Downstream", + "actual_source_offset": 23, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1455.2734375, + "y": 540.0 + }, + "expected_source_offset": 24, + "expected_affinity": "Downstream", + "actual_source_offset": 24, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1489.892578125, + "y": 540.0 + }, + "expected_source_offset": 25, + "expected_affinity": "Downstream", + "actual_source_offset": 25, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1525.244140625, + "y": 540.0 + }, + "expected_source_offset": 26, + "expected_affinity": "Downstream", + "actual_source_offset": 26, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1563.96484375, + "y": 540.0 + }, + "expected_source_offset": 27, + "expected_affinity": "Downstream", + "actual_source_offset": 27, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1620.556640625, + "y": 540.0 + }, + "expected_source_offset": 28, + "expected_affinity": "Downstream", + "actual_source_offset": 28, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1677.8125, + "y": 540.0 + }, + "expected_source_offset": 29, + "expected_affinity": "Downstream", + "actual_source_offset": 29, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 205.322265625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 285.64453125, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "actual_source_offset": 1, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 345.8984375, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "actual_source_offset": 2, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 406.103515625, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "actual_source_offset": 3, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 457.03125, + "y": 540.0 + }, + "expected_source_offset": 4, + "expected_affinity": "Downstream", + "actual_source_offset": 4, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 494.189453125, + "y": 540.0 + }, + "expected_source_offset": 9, + "expected_affinity": "Downstream", + "actual_source_offset": 9, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 546.142578125, + "y": 540.0 + }, + "expected_source_offset": 7, + "expected_affinity": "Downstream", + "actual_source_offset": 7, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 596.953125, + "y": 540.0 + }, + "expected_source_offset": 5, + "expected_affinity": "Downstream", + "actual_source_offset": 5, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 205.322265625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 285.64453125, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "actual_source_offset": 1, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 345.8984375, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "actual_source_offset": 2, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 406.103515625, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "actual_source_offset": 3, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 457.03125, + "y": 540.0 + }, + "expected_source_offset": 4, + "expected_affinity": "Downstream", + "actual_source_offset": 4, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 493.046875, + "y": 540.0 + }, + "expected_source_offset": 5, + "expected_affinity": "Downstream", + "actual_source_offset": 5, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 209.765625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 278.173828125, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "actual_source_offset": 1, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 315.4296875, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "actual_source_offset": 2, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 364.697265625, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "actual_source_offset": 3, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 431.884765625, + "y": 540.0 + }, + "expected_source_offset": 4, + "expected_affinity": "Downstream", + "actual_source_offset": 4, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 493.75, + "y": 540.0 + }, + "expected_source_offset": 5, + "expected_affinity": "Downstream", + "actual_source_offset": 5, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 553.955078125, + "y": 540.0 + }, + "expected_source_offset": 6, + "expected_affinity": "Downstream", + "actual_source_offset": 6, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 604.8828125, + "y": 540.0 + }, + "expected_source_offset": 7, + "expected_affinity": "Downstream", + "actual_source_offset": 7, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 642.041015625, + "y": 540.0 + }, + "expected_source_offset": 12, + "expected_affinity": "Downstream", + "actual_source_offset": 12, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 693.994140625, + "y": 540.0 + }, + "expected_source_offset": 10, + "expected_affinity": "Downstream", + "actual_source_offset": 10, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 763.232421875, + "y": 540.0 + }, + "expected_source_offset": 8, + "expected_affinity": "Downstream", + "actual_source_offset": 8, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 817.626953125, + "y": 540.0 + }, + "expected_source_offset": 14, + "expected_affinity": "Downstream", + "actual_source_offset": 14, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 862.01171875, + "y": 540.0 + }, + "expected_source_offset": 15, + "expected_affinity": "Downstream", + "actual_source_offset": 15, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 925.390625, + "y": 540.0 + }, + "expected_source_offset": 16, + "expected_affinity": "Downstream", + "actual_source_offset": 16, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 997.607421875, + "y": 540.0 + }, + "expected_source_offset": 17, + "expected_affinity": "Downstream", + "actual_source_offset": 17, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 1050.87890625, + "y": 540.0 + }, + "expected_source_offset": 18, + "expected_affinity": "Downstream", + "actual_source_offset": 18, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 1102.24609375, + "y": 540.0 + }, + "expected_source_offset": 19, + "expected_affinity": "Downstream", + "actual_source_offset": 19, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 1162.890625, + "y": 540.0 + }, + "expected_source_offset": 20, + "expected_affinity": "Downstream", + "actual_source_offset": 20, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 1206.54296875, + "y": 540.0 + }, + "expected_source_offset": 21, + "expected_affinity": "Downstream", + "actual_source_offset": 21, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 1244.90234375, + "y": 540.0 + }, + "expected_source_offset": 22, + "expected_affinity": "Downstream", + "actual_source_offset": 22, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 205.322265625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 282.71484375, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "actual_source_offset": 1, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 335.400390625, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "actual_source_offset": 2, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 386.71875, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "actual_source_offset": 3, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 433.3984375, + "y": 540.0 + }, + "expected_source_offset": 6, + "expected_affinity": "Downstream", + "actual_source_offset": 6, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 513.37890625, + "y": 540.0 + }, + "expected_source_offset": 7, + "expected_affinity": "Downstream", + "actual_source_offset": 7, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 593.359375, + "y": 540.0 + }, + "expected_source_offset": 10, + "expected_affinity": "Downstream", + "actual_source_offset": 10, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 634.66796875, + "y": 540.0 + }, + "expected_source_offset": 11, + "expected_affinity": "Downstream", + "actual_source_offset": 11, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 690.625, + "y": 540.0 + }, + "expected_source_offset": 12, + "expected_affinity": "Downstream", + "actual_source_offset": 12, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 748.388671875, + "y": 540.0 + }, + "expected_source_offset": 13, + "expected_affinity": "Downstream", + "actual_source_offset": 13, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 814.111328125, + "y": 540.0 + }, + "expected_source_offset": 14, + "expected_affinity": "Downstream", + "actual_source_offset": 14, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 909.228515625, + "y": 540.0 + }, + "expected_source_offset": 15, + "expected_affinity": "Downstream", + "actual_source_offset": 15, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 985.72265625, + "y": 540.0 + }, + "expected_source_offset": 16, + "expected_affinity": "Downstream", + "actual_source_offset": 16, + "actual_affinity": "Downstream", + "pass": true + } + ], + "a11y_evidence": [ + { + "fixture_id": "F-A", + "platform": "at-spi2", + "observed_name": "Allegro affettuoso — al fine", + "observed_name_bytes_hex": "416c6c6567726f20616666657474756f736f20e2809420616c2066696e65", + "observed_role": "label", + "prohibited_outcome": null, + "pass": true, + "notes": "PASS" + }, + { + "fixture_id": "F-B", + "platform": "at-spi2", + "observed_name": "Coro אבג", + "observed_name_bytes_hex": "436f726f20d790d791d792", + "observed_role": "label", + "prohibited_outcome": null, + "pass": true, + "notes": "PASS" + }, + { + "fixture_id": "F-C", + "platform": "at-spi2", + "observed_name": "Coro ا", + "observed_name_bytes_hex": "436f726f20d8a7", + "observed_role": "label", + "prohibited_outcome": null, + "pass": true, + "notes": "PASS" + }, + { + "fixture_id": "F-D", + "platform": "at-spi2", + "observed_name": "Allegro אבג con brio", + "observed_name_bytes_hex": "416c6c6567726f20d790d791d79220636f6e206272696f", + "observed_role": "label", + "prohibited_outcome": null, + "pass": true, + "notes": "PASS" + }, + { + "fixture_id": "F-E", + "platform": "at-spi2", + "observed_name": "Café — resumé", + "observed_name_bytes_hex": "43616665cc8120e2809420726573756d65cc81", + "observed_role": "label", + "prohibited_outcome": null, + "pass": true, + "notes": "PASS" + } + ], + "cost": { + "baseline_commit": "c20bc93", + "dependencies_added": [ + { + "name": "round2-candidatekit", + "version": "0.1.0 (path)", + "reason": "candidate-neutral fixture/oracle loading and the shared report shape / scoring rule Round 2 requires every candidate to consume rather than re-derive." + }, + { + "name": "round2-diff", + "version": "0.1.0 (path)", + "reason": "the precommitted bounded visual differential (D1-D4) checks 1/2 score against." + }, + { + "name": "round2-textkit", + "version": "0.1.0 (path)", + "reason": "SpikeResolvedText fixtures, the staff->device transform (hittest::to_device), the hit-test probe table, and face resolution." + }, + { + "name": "ttf-parser", + "version": "=0.25.1", + "reason": "candidate-owned glyph outline extraction from the two declared host faces (not from Bravura's typed PathCommand data, which Round 1 used) — pinned to the exact version round2-textkit shapes fixtures against." + }, + { + "name": "serde_json", + "version": "1", + "reason": "serializing CandidateReport to round2_report.json, and parsing verify.py's --json output." + }, + { + "name": "eframe", + "version": "0.35", + "reason": "check 5 needs a real window on the live AT-SPI2 bus; Round 1's binary is headless (offscreen wgpu only, no winit/eframe at all). Same first-party AccessKit route probe-egui's Round 0 binary used." + } + ], + "adapters": [ + { + "Implemented": { + "platform": "at-spi2", + "notes": "the round's own platform on this Linux/Wayland machine — verified via a11y-verifier/verify.py's live, out-of-process AT-SPI2 readback for all five fixtures (round2_a11y_evidence/*.json).", + "integration_ownership": { + "Inherited": { + "provider": "eframe 0.35 -> egui-winit -> accesskit_winit -> accesskit_unix (the AT-SPI2 adapter and its lifecycle ship with eframe; this candidate wrote none of that plumbing)" + } + } + } + }, + { + "Implemented": { + "platform": "accesskit-0.24", + "notes": "reached and exercised — every check-5 readback below travelled this path. What this candidate does write on top of the inherited integration is the accessible node for its own canvas-painted run — counted under ReportPart::AccessibilityTreeConstruction (c1_egui_lyon::a11y_node), not here.", + "integration_ownership": { + "Inherited": { + "provider": "eframe 0.35 (bundled AccessKit integration; accesskit was already in this crate's Round 1 dependency graph at c20bc93 via egui 0.35)" + } + } + } + }, + { + "NotBuilt": { + "platform": "aria", + "reason": "no web/ARIA target exists for this candidate — egui/eframe here is a native desktop app, not a web build." + } + }, + { + "NotBuilt": { + "platform": "macos-nsaccessibility", + "reason": "no macOS runner available in this environment." + } + }, + { + "NotBuilt": { + "platform": "windows-uia", + "reason": "no Windows runner available in this environment." + } + } + ], + "integration_wiring": [ + "glyph_outline.rs: ttf_parser::OutlineBuilder callbacks converted directly to a lyon_path::Path in device space (no PathCommand/SVG intermediate), tessellated with lyon's NonZero fill rule as one compound path per glyph so bounded holes (e.g. 'o', 'e') survive.", + "render_target.rs: the offscreen egui_wgpu render target (device/adapter setup, MSAA/resolve texture pair, render pass, CPU readback) checks 1/2 draw into.", + "hit_test.rs: a hand-written floor-search resolver over the resolved text's own Downstream caret-stop partition, reusing only round2_textkit::hittest::to_device for the shared staff->device transform — no other apparatus from the probe generator is called.", + "check 2 evidence: F-C's U+0627 (byte range recorded per-fixture below) is explicitly detected via SpikeShapedSegment::face == None in render_target::draw_fixture(), which asserts its glyph list is empty and records the span in round2_report.json's console log rather than silently drawing nothing — this candidate's fixture-level trace is: F-C unresolved segments = [\"source 5..7 (\\\"ا\\\"): face resolved to None (no declared face covers this span) — 0 glyphs drawn, no substitution\"]", + "a11y_node.rs: a custom AccessKit node (Role::Label, value = the fixture's exact source string) built directly via egui::Context::accesskit_node_builder on an Id allocated with ui.interact(..., Sense::hover()) — bypassing egui's Label widget and its own text-layout/galley construction entirely, so the accessible name is never touched by anything that could re-shape, wrap, or normalize it.", + "a11y_app.rs (ReportPart::AccessibilityIntegrationWiring — this candidate's own integration, and the only file counted under this row): the eframe::App/window/ event-loop wiring the check-5 windowed probe runs under (no visual glyph rendering — dropped by the F3 fix, see that file's own doc comment).", + "a11y_subprocess.rs (ReportPart::FixtureAndReportPlumbing, reattributed by user ruling: a verifier-subprocess harness common to both Round 2 candidates, not part of either stack's own accessibility integration): subprocess orchestration spawning c1_round2_a11y once per fixture and invoking a11y-verifier/verify.py out-of-process for the live AT-SPI2 readback (never a same-process self-report). F1: every invocation writes to a fresh, unique path and requires the exit status and JSON verdict to agree, erroring hard on disagreement rather than trusting either. G1: admission of an exit-2 NOT RUN requires both the exact prefix and an approved environmental-cause marker (an allow-list, not a deny-list). F2: any FAIL across the fixture set wins over any NotRun in the final aggregate, regardless of which was observed first. G3: validation (in the system temp directory) and publishing (one canonical file per fixture, overwriting) are separate steps, so evidence never accumulates." + ], + "loc_by_part": [ + { + "part": "TextRendering", + "lines": 651 + }, + { + "part": "HitTestResolution", + "lines": 228 + }, + { + "part": "AccessibilityTreeConstruction", + "lines": 64 + }, + { + "part": "AccessibilityIntegrationWiring", + "lines": 62 + }, + { + "part": "FixtureAndReportPlumbing", + "lines": 1845 + } + ] + } +} \ No newline at end of file diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/a11y_app.rs b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/a11y_app.rs new file mode 100644 index 0000000..be26f15 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/a11y_app.rs @@ -0,0 +1,62 @@ +//! `ReportPart::AccessibilityIntegrationWiring`, half one — "getting that +//! tree to the platform: adapter lifecycle, event-loop plumbing, window and +//! bridge setup" (the F3 fix's own definition of this row). `a11y_subprocess.rs` +//! is the other half (the subprocess orchestration of the verifier). +//! +//! This module owns the `eframe::App` impl, the window options, and the +//! `eframe::run_native` call — the windowed route check 5 requires ("a real +//! window on the AT-SPI bus", the contract's own words) that Round 1's +//! headless binary does not have. It contains **no** semantic node-building +//! logic of its own (that is `a11y_node.rs`, which this module calls into) +//! and **no** visual rendering (dropped from an earlier revision of this +//! packet — see `a11y_node.rs`'s doc comment for why). + +use round2_textkit::types::SpikeResolvedText; + +pub struct A11yApp { + pub fixture_id: String, + pub resolved: SpikeResolvedText, +} + +impl eframe::App for A11yApp { + fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + ui.label(format!("Round 2 check 5 — fixture {}", self.fixture_id)); + ui.separator(); + + let rect = egui::Rect::from_min_size(ui.min_rect().min, egui::vec2(900.0, 140.0)); + crate::a11y_node::build_text_run_node(ui, &self.fixture_id, rect, &self.resolved.text); + + // Keep repainting: AT-SPI clients query live state, and there is no + // other event source driving redraws in this minimal app. + ui.ctx() + .request_repaint_after(std::time::Duration::from_millis(200)); + } +} + +/// Opens the window and runs the event loop until the process is killed +/// (`a11y_subprocess.rs` is the one that kills it, once `verify.py` has read +/// the tree). The `eframe::run_native` app-id string +/// (`"EpiphanyRound2C1"`) is **not** what AT-SPI names the application — +/// AT-SPI's own application name tracks the process/binary name (measured +/// against `round0-evidence/c1-egui-readback.txt`'s precedent and +/// re-confirmed for this packet's own binary name); `a11y_subprocess.rs`'s +/// `A11Y_APP_NAME` constant is what actually has to match. +pub fn run(fixture_id: String, resolved: SpikeResolvedText) -> eframe::Result { + let options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([960.0, 260.0]) + .with_title(format!("EpiphanyRound2C1 {fixture_id}")), + ..Default::default() + }; + + eframe::run_native( + "EpiphanyRound2C1", + options, + Box::new(move |_cc| { + Ok(Box::new(A11yApp { + fixture_id, + resolved, + })) + }), + ) +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/a11y_node.rs b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/a11y_node.rs new file mode 100644 index 0000000..37f2de3 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/a11y_node.rs @@ -0,0 +1,64 @@ +//! `ReportPart::AccessibilityTreeConstruction`, **semantic content only** — +//! the F3 fix's own definition of this row: "building the accessible +//! node(s): role, name, relationships, derived from the resolved text." +//! +//! Deliberately carries **no** window/event-loop/lifecycle code (that is +//! `a11y_app.rs`, counted under `AccessibilityIntegrationWiring`) and **no** +//! visual rendering at all — an earlier revision of this packet painted the +//! fixture's glyphs in the same file that built the AccessKit node, which is +//! exactly the kind of file-level mixing the F3 finding named as the defect: +//! a 190-line file that was mostly window setup, font loading, and +//! rendering, reported as if it were 190 lines of semantic tree +//! construction. This module is `AccessibilityTreeConstruction`, full stop; +//! it does not draw anything, and check 5 does not require it to (the +//! visual glyph mesh was cosmetic — "for visual confirmation only. Not read +//! by verify.py" — dropped here rather than kept and mis-attributed). +//! +//! **The accessible name is never derived from egui's own text layout.** +//! The node is built directly via `egui::Context::accesskit_node_builder` on +//! an `Id` that carries no text layout of its own +//! (`ui.interact(rect, id, Sense::hover())`), so the bytes reaching AT-SPI +//! are exactly the fixture's source string, untouched by galley +//! construction, wrapping, or any Unicode normalization egui's text stack +//! might otherwise apply — which is exactly what F-E (NFD) and F-C (an +//! uncovered codepoint that still must appear in the name) test. + +use egui::accesskit::{Node, Rect as AkRect, Role}; + +/// The AccessKit role this candidate exposes the run under — `Label`, whose +/// accessible name is read from `Node::value` (per `accesskit`'s own doc +/// comment on `Node::set_label`: "the text content of a node with the +/// `Role::Label` role should be provided via `Node::value`, not this +/// property"), and which `accesskit_atspi_common` maps to AT-SPI role +/// `"label"` — one of the accepted at-spi2 tokens +/// (`round2_textkit::a11y::ACCEPTED_ROLE_TABLE`). +pub const NODE_ROLE: Role = Role::Label; + +/// Builds one AccessKit node carrying `source_text` byte-for-byte as its +/// accessible name, at `rect`, allocated under `ui`'s current accesskit +/// parent (`ui.interact` registers the `Id` as an accesskit child of the +/// enclosing `Ui` — see `egui::Ui::interact`'s own implementation). +/// +/// The `Id` is stable per fixture (`("epiphany_round2_text_run", +/// fixture_id)`), so repeated calls across frames update the same node +/// rather than accumulating duplicates. +pub fn build_text_run_node( + ui: &mut egui::Ui, + fixture_id: &str, + rect: egui::Rect, + source_text: &str, +) { + let id = egui::Id::new(("epiphany_round2_text_run", fixture_id)); + let _response = ui.interact(rect, id, egui::Sense::hover()); + let name = source_text.to_string(); + ui.ctx().accesskit_node_builder(id, |node: &mut Node| { + node.set_role(NODE_ROLE); + node.set_value(name.clone()); + node.set_bounds(AkRect { + x0: rect.min.x as f64, + y0: rect.min.y as f64, + x1: rect.max.x as f64, + y1: rect.max.y as f64, + }); + }); +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/a11y_subprocess.rs b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/a11y_subprocess.rs new file mode 100644 index 0000000..1e3341b --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/a11y_subprocess.rs @@ -0,0 +1,1004 @@ +//! `ReportPart::AccessibilityIntegrationWiring`, half two — "the subprocess +//! orchestration of the verifier" (the F3 fix's own definition of this row, +//! naming this exact responsibility). `a11y_app.rs` is the other half (the +//! window/event-loop/adapter-lifecycle side). +//! +//! This module spawns `bin/c1_round2_a11y.rs` once per fixture, runs +//! `a11y-verifier/verify.py` out-of-process against the live AT-SPI2 bus, +//! and turns its exit status + `--json` output into a +//! [`FixtureCheck5Outcome`] — never a same-process self-report. +//! +//! ## F1 — freshness +//! +//! Every invocation writes to a **fresh, unique, run-private scratch path** +//! ([`fresh_temp_path`]: candidate PID + a monotonic counter + a nanosecond +//! timestamp, in the system temp directory) that cannot have existed before +//! this call, so [`interpret_verify_output`] can never read a file this run +//! did not write — the earlier revision of this file used one fixed path +//! per fixture (`round2_a11y_evidence/.json`) and checked +//! `json_path.exists()` *before* looking at the exit status, so a stale +//! file from a previous run could be read back as if it were this run's +//! own output, and a usage +//! error's exit 2 with no file present was treated identically to a +//! genuine bus-unreachable NOT RUN. [`interpret_verify_output`] now +//! requires the exit status and the JSON verdict to **agree** (disagreement +//! is a hard [`anyhow::Error`], never a guess) and validates the JSON's own +//! `fixture_id` field against the fixture actually requested. +//! +//! ## G1 — an allow-list, not a deny-list +//! +//! F1's first cut still admitted almost anything at exit 2 as bus- +//! unreachable evidence: it rejected only stdout *beginning* with +//! `"CHECK5: usage error"` and treated everything else — empty stdout, an +//! unrecognised message, a stderr-only argparse failure (which leaves +//! stdout empty) — as NOT RUN. [`interpret_verify_output`] now **requires** +//! the exact `"CHECK5: NOT RUN"` prefix **and** one of +//! [`APPROVED_NOT_RUN_MARKERS`], read directly from `verify.py`'s own exit +//! points rather than guessed. Everything else at exit 2 is a hard `Err`. +//! +//! ## F2 — ordering independence +//! +//! [`aggregate_check5`] checks for **any** `Fail` first, over the whole +//! fixture set, before it ever looks at whether a `NotRun` also occurred — +//! the earlier revision let whichever outcome was seen *last* in the loop +//! decide, so a FAIL on one fixture followed by a legitimate NOT RUN on +//! another silently discarded the FAIL. A FAIL is disqualifying in +//! **either** ordering; an environmental NOT RUN can only apply when +//! nothing failed. +//! +//! ## G3 — publish, don't accumulate +//! +//! F1's freshness fix put the per-run private path inside `evidence_dir` +//! itself, so every run left its own PID/timestamp-named file behind and +//! the directory grew without bound (ten files after two runs of five +//! fixtures, never cleaned). *Validating* freshness and *publishing* +//! evidence are now two separate steps: [`fresh_temp_path`] writes into the +//! system temp directory (never `evidence_dir`), [`interpret_verify_output`] +//! validates it there, and only a validated outcome is +//! [`publish_canonical`]-ed to `evidence_dir`'s canonical `.json` +//! — overwriting any earlier run's file, never adding to it, and never +//! itself read back by anything in this module. + +use std::path::{Path, PathBuf}; +use std::process::{Child, Command}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{SystemTime, UNIX_EPOCH}; + +use anyhow::{anyhow, Context, Result}; + +use round2_candidatekit::{scoring, A11yEvidence, BusUnreachableEvidence, CheckOutcome}; + +/// Must match `bin/c1_round2_a11y.rs`'s built binary name (the file's own +/// stem — Cargo auto-names a `src/bin/*.rs` target after its file, and this +/// crate declares no `[[bin]] name` override). This is what +/// `a11y-verifier/verify.py --app-name` filters against, since AT-SPI's own +/// application name tracks the process/binary name, not the `eframe` +/// window title (measured in `round0-evidence/c1-egui-readback.txt`). +/// +/// F4: renamed from `round2_a11y` to `c1_round2_a11y` — both Round 2 +/// candidates had built a binary literally named `round2_text`/`round2_a11y`, +/// which collided in the shared `target/` output directory (a locked +/// release build can hold only one binary per name; C2's silently won). +pub const A11Y_APP_NAME: &str = "c1_round2_a11y"; + +/// One fixture's check-5 outcome, as actually observed — never inferred +/// from an absent file or a convenient default. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FixtureCheck5Outcome { + Pass { + observed_role: Option, + observed_name: Option, + }, + Fail { + reason: String, + observed_role: Option, + observed_name: Option, + prohibited_outcome: Option, + }, + /// Admissible **only** when verify.py itself reported the AT-SPI bus + /// unreachable: the exact `"CHECK5: NOT RUN"` prefix **and** one of + /// [`APPROVED_NOT_RUN_MARKERS`] (G1). Never constructed for a usage + /// error on this candidate's own invocation, empty stdout, unrecognised + /// stdout, or a stderr-only failure — all of those are a hard `Err` + /// from [`interpret_verify_output`] instead. + NotRun { reason: String }, +} + +/// **G1.** Every substring `a11y-verifier/verify.py` itself prints +/// immediately after its `"CHECK5: NOT RUN — "` prefix, at every exit point +/// that reaches `sys.exit(2)` for a genuine environmental cause (read +/// directly from `verify.py`, not guessed): +/// +/// - `"could not import gi.repository.Atspi"` — `main()`, before dispatch +/// - `"Atspi.init() failed"` — `run_check5` +/// - `"Atspi.get_desktop(0) failed"` — `run_check5` +/// - `"Atspi.get_desktop(0) returned None"` — `run_check5` +/// - `"desktop.get_child_count() failed"` — `run_check5` +/// +/// `verify.py` also exits 2 for three **usage** errors (`"CHECK5: usage +/// error — "`, not this prefix at all), which are refused below regardless +/// of marker. G1's fix is that admission now requires **both** the exact +/// `"CHECK5: NOT RUN"` prefix **and** one of these markers — empty stdout, +/// unrecognised stdout, and a stderr-only failure (argparse writes to +/// stderr, leaving stdout empty) all fail every one of these checks and are +/// refused, where the previous revision admitted all three as bus- +/// unreachable evidence merely because they did not start with +/// `"CHECK5: usage error"`. +const APPROVED_NOT_RUN_MARKERS: &[&str] = &[ + "could not import gi.repository.Atspi", + "Atspi.init() failed", + "Atspi.get_desktop(0) failed", + "Atspi.get_desktop(0) returned None", + "desktop.get_child_count() failed", +]; + +static EVIDENCE_COUNTER: AtomicU64 = AtomicU64::new(0); + +/// **G3.** A run-private **scratch** path that cannot have existed before +/// this call: the system temp directory (never `evidence_dir` — see +/// [`publish_canonical`]'s doc comment for why the two must be different +/// directories) + process id + a monotonically increasing in-process +/// counter + a nanosecond timestamp, none of which repeat across +/// invocations of this binary. F1: this is what makes "never read a file +/// this run did not write" true *structurally*, rather than by convention — +/// there is no other writer that could have created a file at this exact +/// path first. [`run_check5_for_fixture`] removes this file again once it +/// has been validated and (if applicable) published, so it never +/// accumulates. +fn fresh_temp_path(fixture_id: &str) -> PathBuf { + let pid = std::process::id(); + let seq = EVIDENCE_COUNTER.fetch_add(1, Ordering::Relaxed); + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + std::env::temp_dir().join(format!( + "c1-round2-a11y-{fixture_id}-{pid}-{seq}-{nanos}.json" + )) +} + +/// **G3: publish, never read back.** Copies the already-validated scratch +/// JSON at `temp_path` to `evidence_dir`'s **canonical** name for +/// `fixture_id` (`.json`) — overwriting whatever an earlier run +/// left there — for a `Pass`/`Fail` outcome; for a `NotRun` outcome there is +/// no fresh JSON at all (verify.py never writes one at exit 2), so any +/// stale canonical file from an earlier run is removed instead, so the +/// directory always reflects only this run's own evidence. +/// +/// **This function is write-only with respect to `evidence_dir`.** Nothing +/// in this module ever opens a file under `evidence_dir` to decide +/// anything — every decision ([`interpret_verify_output`]) is made from the +/// private scratch path before this function is ever called. A canonical +/// file is a **report artifact**, not an input: keeping the two directories +/// distinct (scratch in the system temp dir, published in `evidence_dir`) +/// makes "the run must still refuse to read a file it did not write this +/// run" true by construction — there is no code path that reads from +/// `evidence_dir` at all. +/// +/// Idempotent: calling this once per fixture per run, for a fixed +/// five-fixture set, always leaves exactly five files in `evidence_dir` — +/// never a growing pile of timestamped names (the defect this fix closes; +/// see `publishing_is_idempotent_two_runs_leave_exactly_five_files`). +fn publish_canonical( + evidence_dir: &Path, + fixture_id: &str, + temp_path: &Path, + outcome: &FixtureCheck5Outcome, +) -> Result<()> { + let canonical = evidence_dir.join(format!("{fixture_id}.json")); + match outcome { + FixtureCheck5Outcome::Pass { .. } | FixtureCheck5Outcome::Fail { .. } => { + std::fs::copy(temp_path, &canonical).with_context(|| { + format!( + "failed to publish {} -> {}", + temp_path.display(), + canonical.display() + ) + })?; + } + FixtureCheck5Outcome::NotRun { .. } => { + // No fresh JSON exists for this fixture this run; do not leave + // a stale one behind claiming otherwise. + let _ = std::fs::remove_file(&canonical); + } + } + Ok(()) +} + +/// Turns one `verify.py` invocation's exit status + stdout + (possibly +/// absent) fresh `--json` output into a [`FixtureCheck5Outcome`], or a hard +/// [`anyhow::Error`] when the two disagree, when the JSON's own +/// `fixture_id` does not match what was requested, or when a file exists +/// where the exit status says none should. +/// +/// **This function never trusts the file alone, and never trusts the exit +/// code alone** — F1's whole point. `json_path` must be a path this +/// specific invocation was told to write to ([`fresh_temp_path`]); a +/// caller must never pass a path some earlier run might have written, and +/// this function never reads from `evidence_dir` — see [`publish_canonical`] +/// (G3). +fn interpret_verify_output( + json_path: &Path, + code: Option, + fixture_id: &str, + stdout: &str, +) -> Result { + let exists = json_path.exists(); + + match code { + Some(0) | Some(1) => { + if !exists { + return Err(anyhow!( + "verify.py exited {code:?} for {fixture_id} but wrote no JSON at {} — exit \ + status and output must agree; refusing to guess", + json_path.display() + )); + } + let text = std::fs::read_to_string(json_path) + .with_context(|| format!("failed to read {}", json_path.display()))?; + let v: serde_json::Value = serde_json::from_str(&text) + .with_context(|| format!("failed to parse {}", json_path.display()))?; + let json_fixture_id = v["fixture_id"].as_str().unwrap_or(""); + if json_fixture_id != fixture_id { + return Err(anyhow!( + "verify.py's JSON at {} reports fixture_id {json_fixture_id:?}, but this \ + invocation asked for {fixture_id:?} — refusing to attribute someone else's \ + result", + json_path.display() + )); + } + let verdict = v["verdict"].as_str().unwrap_or(""); + let expected = if code == Some(0) { "PASS" } else { "FAIL" }; + if verdict != expected { + return Err(anyhow!( + "verify.py exited {code:?} (implying {expected}) for {fixture_id}, but its \ + own JSON verdict is {verdict:?} — exit status and JSON output disagree, \ + which must never be resolved by trusting either one silently" + )); + } + let reason = v["reason"].as_str().unwrap_or("").to_string(); + let observed_role = v["observed_role"].as_str().map(|s| s.to_string()); + let observed_name = v["observed_name"].as_str().map(|s| s.to_string()); + let prohibited_outcome = v["prohibited_outcome"].as_str().map(|s| s.to_string()); + if verdict == "PASS" { + Ok(FixtureCheck5Outcome::Pass { + observed_role, + observed_name, + }) + } else { + Ok(FixtureCheck5Outcome::Fail { + reason, + observed_role, + observed_name, + prohibited_outcome, + }) + } + } + Some(2) => { + // F1: a file existing here at all is a contradiction — exit 2 + // means verify.py's own `run_check5` returned before ever + // reaching its `--json` write (every usage-error and NOT-RUN + // exit point in verify.py precedes that write). A file present + // anyway (a stale leftover, a path collision) is refused rather + // than read, which is exactly the "stale PASS file" failure + // mode F1 exists to close. + if exists { + return Err(anyhow!( + "verify.py exited 2 (usage error / NOT RUN) for {fixture_id}, but a JSON \ + file exists at {} anyway — verify.py's own contract is that exit 2 never \ + writes --json, so this is refused rather than read as if it were fresh", + json_path.display() + )); + } + // G1: an **allow-list**, not a deny-list. Admission requires the + // exact "CHECK5: NOT RUN" prefix AND one of + // APPROVED_NOT_RUN_MARKERS naming the actual environmental + // cause — not merely "did not say usage error". Empty stdout, + // unrecognised stdout, and a stderr-only argparse failure (which + // leaves stdout empty) all fail this and are refused below, + // exactly the "almost anything qualifies" failure mode G1 + // exists to close. + let first_line = stdout.lines().next().unwrap_or(""); + let has_marker = APPROVED_NOT_RUN_MARKERS + .iter() + .any(|m| first_line.contains(m)); + if first_line.starts_with("CHECK5: NOT RUN") && has_marker { + Ok(FixtureCheck5Outcome::NotRun { + reason: stdout.to_string(), + }) + } else { + // Everything else at exit 2: a usage error on THIS + // candidate's own invocation, empty stdout, unrecognised + // stdout, or a stderr-only failure — never an environmental + // absence, and must not become admissible bus-unreachable + // evidence. + Err(anyhow!( + "verify.py exited 2 for {fixture_id} without an approved NOT-RUN marker — \ + refusing to admit this as bus-unreachable evidence. stdout={stdout:?}" + )) + } + } + other => Err(anyhow!( + "verify.py exited with unexpected status {other:?} for {fixture_id}: {stdout}" + )), + } +} + +/// Spawns `bin/c1_round2_a11y.rs` for `fixture_id`, runs `verify.py` against +/// it into a run-private [`fresh_temp_path`], kills the app, validates the +/// result via [`interpret_verify_output`], **then** publishes it to +/// `evidence_dir` via [`publish_canonical`] (G3) — validation and +/// publishing are two separate steps, in that order, so a rejected result +/// is never published. +fn run_check5_for_fixture( + root: &Path, + a11y_bin: &Path, + evidence_dir: &Path, + fixture_id: &str, +) -> Result { + let mut child: Child = Command::new(a11y_bin) + .arg("--fixture") + .arg(fixture_id) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .with_context(|| format!("failed to spawn {}", a11y_bin.display()))?; + + let temp_path = fresh_temp_path(fixture_id); + + let expectations = root.join("round2-a11y-oracle/a11y_expectations.json"); + let verify_py = root.join("a11y-verifier/verify.py"); + let digest = round2_textkit::output::expected_artifact_digest(); + + let output = Command::new("python3") + .arg(&verify_py) + .arg("--expectations") + .arg(&expectations) + .arg("--fixture") + .arg(fixture_id) + .arg("--app-name") + .arg(A11Y_APP_NAME) + .arg("--expect-source-digest") + .arg(digest) + .arg("--json") + .arg(&temp_path) + .arg("--timeout") + .arg("15") + .output(); + + // Always try to kill the app, whatever verify.py did. + let _ = child.kill(); + let _ = child.wait(); + + let output = output.context("failed to invoke a11y-verifier/verify.py")?; + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let code = output.status.code(); + + let outcome = interpret_verify_output(&temp_path, code, fixture_id, &stdout); + // Publish only on a validated outcome — never a rejected one — and + // clean up the scratch file regardless, so it never accumulates. + let result = match &outcome { + Ok(o) => publish_canonical(evidence_dir, fixture_id, &temp_path, o), + Err(_) => Ok(()), + }; + let _ = std::fs::remove_file(&temp_path); + result?; + outcome +} + +/// Reduces every fixture's [`FixtureCheck5Outcome`] to the report's +/// `check5_accessibility` cell + (when applicable) +/// [`BusUnreachableEvidence`]. +/// +/// **F2: any `Fail`, anywhere in `outcomes`, wins over any `NotRun`, +/// regardless of which was observed first.** An environmental `NotRun` is +/// admissible only when *nothing* failed. +fn aggregate_check5( + outcomes: &[(String, FixtureCheck5Outcome)], +) -> (CheckOutcome, Option) { + let failing: Vec<&str> = outcomes + .iter() + .filter(|(_, o)| matches!(o, FixtureCheck5Outcome::Fail { .. })) + .map(|(id, _)| id.as_str()) + .collect(); + if !failing.is_empty() { + return ( + CheckOutcome::fail(format!( + "{}/{} fixtures failed check 5: {} — see a11y_evidence for the exact \ + prohibited_outcome per fixture", + failing.len(), + outcomes.len(), + failing.join(", ") + )) + .expect("non-empty reason"), + None, + ); + } + + if let Some((id, reason)) = outcomes.iter().find_map(|(id, o)| match o { + FixtureCheck5Outcome::NotRun { reason } => Some((id.clone(), reason.clone())), + _ => None, + }) { + return ( + CheckOutcome::not_run(format!( + "{id}: verify.py reported the AT-SPI2 bus unreachable: {reason}" + )) + .expect("non-empty reason"), + Some(BusUnreachableEvidence { + probe_description: format!( + "a11y-verifier/verify.py --expectations ... --fixture {id} --app-name \ + {A11Y_APP_NAME} (gi.repository.Atspi, connected to the live AT-SPI2 session \ + bus)" + ), + probe_output: reason, + }), + ); + } + + (CheckOutcome::Pass, None) +} + +/// The whole of check 5: `check5_accessibility`, its bus-unreachable +/// evidence (if any), and every fixture's recorded [`A11yEvidence`]. +pub struct Check5Result { + pub check5_accessibility: CheckOutcome, + pub check5_bus_unreachable_evidence: Option, + pub a11y_evidence: Vec, +} + +/// Runs check 5 for every fixture in `fixture_ids`, in order, printing +/// progress as it goes. +pub fn run_all( + root: &Path, + a11y_bin: &Path, + evidence_dir: &Path, + fixture_ids: &[String], +) -> Result { + std::fs::create_dir_all(evidence_dir) + .with_context(|| format!("failed to create {}", evidence_dir.display()))?; + let mut outcomes = Vec::with_capacity(fixture_ids.len()); + for fixture_id in fixture_ids { + println!( + "check 5: running {} for {fixture_id}...", + a11y_bin.display() + ); + let outcome = run_check5_for_fixture(root, a11y_bin, evidence_dir, fixture_id)?; + println!(" {fixture_id} -> {outcome:?}"); + outcomes.push((fixture_id.clone(), outcome)); + } + + let a11y_evidence = outcomes + .iter() + .map(|(fixture_id, outcome)| match outcome { + FixtureCheck5Outcome::Pass { + observed_role, + observed_name, + } => A11yEvidence { + fixture_id: fixture_id.clone(), + platform: scoring::ROUND_PLATFORM.to_string(), + observed_name: observed_name.clone(), + observed_name_bytes_hex: observed_name + .as_ref() + .map(|s| s.as_bytes().iter().map(|b| format!("{b:02x}")).collect()), + observed_role: observed_role.clone(), + prohibited_outcome: None, + pass: true, + notes: "PASS".to_string(), + }, + FixtureCheck5Outcome::Fail { + reason, + observed_role, + observed_name, + prohibited_outcome, + } => A11yEvidence { + fixture_id: fixture_id.clone(), + platform: scoring::ROUND_PLATFORM.to_string(), + observed_name: observed_name.clone(), + observed_name_bytes_hex: observed_name + .as_ref() + .map(|s| s.as_bytes().iter().map(|b| format!("{b:02x}")).collect()), + observed_role: observed_role.clone(), + prohibited_outcome: prohibited_outcome.clone(), + pass: false, + notes: reason.clone(), + }, + FixtureCheck5Outcome::NotRun { reason } => A11yEvidence { + fixture_id: fixture_id.clone(), + platform: scoring::ROUND_PLATFORM.to_string(), + observed_name: None, + observed_name_bytes_hex: None, + observed_role: None, + prohibited_outcome: None, + pass: false, + notes: reason.clone(), + }, + }) + .collect(); + + let (check5_accessibility, check5_bus_unreachable_evidence) = aggregate_check5(&outcomes); + + Ok(Check5Result { + check5_accessibility, + check5_bus_unreachable_evidence, + a11y_evidence, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!( + "c1-a11y-subprocess-test-{name}-{}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + fn write_json(path: &Path, fixture_id: &str, verdict: &str) { + std::fs::write( + path, + serde_json::json!({ + "fixture_id": fixture_id, + "verdict": verdict, + "reason": "test", + "observed_role": "label", + "observed_name": "x", + "observed_name_hex": "78", + "prohibited_outcome": serde_json::Value::Null, + "walked_tree": [], + }) + .to_string(), + ) + .unwrap(); + } + + // ---- F1: freshness ---- + + /// Required kill: a stale PASS file sitting at the path this + /// invocation was told to use, when the exit status is 2 (usage error + /// / NOT RUN, which per verify.py's own contract never writes --json), + /// must be refused — never silently read as this run's own PASS. + #[test] + fn a_stale_pass_file_at_exit_2_is_refused_not_picked_up() { + let dir = scratch_dir("stale-pass-exit-2"); + let path = dir.join("F-A-stale.json"); + write_json(&path, "F-A", "PASS"); + let err = interpret_verify_output(&path, Some(2), "F-A", "CHECK5: NOT RUN — bus gone") + .unwrap_err(); + assert!( + err.to_string().contains("exit 2"), + "must name the exit-2/file-exists contradiction: {err}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The legitimate case this must not break: exit 2, no file present, + /// verify.py's own "NOT RUN" prefix (bus genuinely unreachable) -> + /// admissible NotRun. + #[test] + fn a_genuine_bus_unreachable_exit_2_with_no_file_is_not_run() { + let dir = scratch_dir("genuine-not-run"); + let path = dir.join("F-A-fresh.json"); + let outcome = interpret_verify_output( + &path, + Some(2), + "F-A", + "CHECK5: NOT RUN — Atspi.init() failed", + ) + .unwrap(); + assert!(matches!(outcome, FixtureCheck5Outcome::NotRun { .. })); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Required kill: exit 2 with no fresh file present but verify.py's own + /// output says "usage error" (a defect in how THIS candidate invoked + /// it, e.g. a bad digest or path) must be a hard failure, never + /// admissible bus-unreachable evidence — the exact "every exit 2 + /// becomes admissible" failure mode F1 names. + #[test] + fn a_usage_error_at_exit_2_is_a_hard_failure_not_admissible_not_run() { + let dir = scratch_dir("usage-error"); + let path = dir.join("F-A-fresh.json"); + let err = interpret_verify_output( + &path, + Some(2), + "F-A", + "CHECK5: usage error — 'a11y_expectations.json' failed validation: ...", + ) + .unwrap_err(); + assert!(err.to_string().contains("usage error"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// G1 required kill: empty stdout at exit 2 must be a hard failure, not + /// admissible NOT RUN — the exact "almost anything qualifies" bug: no + /// marker at all was previously sufficient because it merely didn't + /// start with "CHECK5: usage error". + #[test] + fn g1_empty_stdout_at_exit_2_is_a_hard_failure() { + let dir = scratch_dir("g1-empty-stdout"); + let path = dir.join("F-A-fresh.json"); + let err = interpret_verify_output(&path, Some(2), "F-A", "").unwrap_err(); + assert!( + err.to_string() + .contains("without an approved NOT-RUN marker"), + "{err}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// G1 required kill: unrecognised stdout (present, non-empty, but + /// naming nothing verify.py's own NOT-RUN exit points actually print) + /// must be a hard failure. + #[test] + fn g1_unrecognised_stdout_at_exit_2_is_a_hard_failure() { + let dir = scratch_dir("g1-unrecognised-stdout"); + let path = dir.join("F-A-fresh.json"); + let err = interpret_verify_output( + &path, + Some(2), + "F-A", + "some unrelated diagnostic banner, not one of verify.py's own messages", + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("without an approved NOT-RUN marker"), + "{err}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// G1 required kill: an argparse-style failure (Python's argparse + /// writes usage errors to **stderr**, leaving stdout empty) must be a + /// hard failure — this function only ever sees stdout, so this is the + /// same code path as the empty-stdout case, exercised under its own + /// name because it is the specific real-world trigger G1 names. + #[test] + fn g1_argparse_stderr_only_failure_is_a_hard_failure() { + let dir = scratch_dir("g1-argparse-stderr-only"); + let path = dir.join("F-A-fresh.json"); + // stdout empty, as it would be for a real argparse ap.error() exit + // (argparse prints usage + the error to stderr and calls + // sys.exit(2), never touching stdout). + let err = interpret_verify_output(&path, Some(2), "F-A", "").unwrap_err(); + assert!( + err.to_string() + .contains("without an approved NOT-RUN marker"), + "{err}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// G1: a genuine "CHECK5: NOT RUN" with one of every approved marker + /// must still be admitted — the allow-list must not have become so + /// strict it refuses verify.py's own real exit points. + #[test] + fn g1_every_approved_marker_is_admitted() { + let dir = scratch_dir("g1-every-marker"); + for marker in APPROVED_NOT_RUN_MARKERS { + let stdout = format!("CHECK5: NOT RUN — {marker}: simulated"); + let path = dir.join("F-A-fresh.json"); + let outcome = interpret_verify_output(&path, Some(2), "F-A", &stdout) + .unwrap_or_else(|e| panic!("marker {marker:?} must be admitted: {e}")); + assert!(matches!(outcome, FixtureCheck5Outcome::NotRun { .. })); + } + let _ = std::fs::remove_dir_all(&dir); + } + + /// G1 gap fix, half one: the `"CHECK5: NOT RUN"` prefix is present, but + /// nothing after it matches any [`APPROVED_NOT_RUN_MARKERS`] entry. + /// verify.py itself never actually emits this (every real NOT-RUN exit + /// point pairs the prefix with one of the approved markers), but the + /// guard against it is what actually proves the **marker** half of the + /// conjunction is load-bearing: this is the one case that distinguishes + /// `prefix && marker` from `prefix && true` (a marker check silently + /// dropped) — every case already covered (empty stdout, unrelated + /// stdout, argparse's stderr-only failure) lacks the prefix too, so a + /// dropped marker check could not be seen through those alone. + #[test] + fn g1_prefix_present_marker_absent_is_a_hard_failure() { + let dir = scratch_dir("g1-prefix-no-marker"); + let path = dir.join("F-A-fresh.json"); + let err = interpret_verify_output( + &path, + Some(2), + "F-A", + "CHECK5: NOT RUN — something we do not recognise", + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("without an approved NOT-RUN marker"), + "{err}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// G1 gap fix, half two: a marker substring appears somewhere in the + /// output, but the line does not carry the `"CHECK5: NOT RUN"` prefix — + /// an unrelated diagnostic that happens to mention e.g. `Atspi.init() + /// failed` is not a NOT-RUN verdict. This is the case that distinguishes + /// `prefix && marker` from `true && marker` (a prefix check silently + /// dropped) — every case already covered lacks a marker too, so a + /// dropped prefix check could not be seen through those alone. + #[test] + fn g1_marker_present_prefix_absent_is_a_hard_failure() { + let dir = scratch_dir("g1-marker-no-prefix"); + let path = dir.join("F-A-fresh.json"); + let err = interpret_verify_output( + &path, + Some(2), + "F-A", + "unrelated preamble mentioning Atspi.init() failed in passing, not a verdict line", + ) + .unwrap_err(); + assert!( + err.to_string() + .contains("without an approved NOT-RUN marker"), + "{err}" + ); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Required kill: exit 0 (PASS) but no file at the fresh path at all — + /// must error rather than silently treat the run as failed/not-run. + #[test] + fn exit_0_with_no_file_is_a_hard_error() { + let dir = scratch_dir("exit0-no-file"); + let path = dir.join("F-A-missing.json"); + let err = interpret_verify_output(&path, Some(0), "F-A", "").unwrap_err(); + assert!(err.to_string().contains("wrote no JSON"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Required kill: exit status and JSON verdict disagreement (exit 0 + /// implies PASS, but the JSON says FAIL) must be a hard error, never + /// resolved by trusting either source silently. + #[test] + fn exit_status_and_json_verdict_disagreement_is_a_hard_error() { + let dir = scratch_dir("disagreement"); + let path = dir.join("F-A.json"); + write_json(&path, "F-A", "FAIL"); + let err = interpret_verify_output(&path, Some(0), "F-A", "").unwrap_err(); + assert!(err.to_string().contains("disagree"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// Required kill: the JSON's own `fixture_id` must match what was + /// requested — otherwise a result meant for a different fixture (e.g. a + /// path mixup) could be silently attributed to this one. + #[test] + fn a_mismatched_fixture_id_in_the_json_is_refused() { + let dir = scratch_dir("mismatched-fixture"); + let path = dir.join("F-A.json"); + write_json(&path, "F-B", "PASS"); // wrong fixture id inside the file + let err = interpret_verify_output(&path, Some(0), "F-A", "").unwrap_err(); + assert!(err.to_string().contains("fixture_id"), "{err}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// The legitimate PASS path still works after all the above refusals. + #[test] + fn a_genuine_matching_pass_is_accepted() { + let dir = scratch_dir("genuine-pass"); + let path = dir.join("F-A.json"); + write_json(&path, "F-A", "PASS"); + let outcome = interpret_verify_output(&path, Some(0), "F-A", "").unwrap(); + assert!(matches!(outcome, FixtureCheck5Outcome::Pass { .. })); + let _ = std::fs::remove_dir_all(&dir); + } + + /// [`fresh_temp_path`] never repeats across calls, even for the same + /// fixture id in the same process — the structural property F1's whole + /// fix rests on. + #[test] + fn fresh_temp_path_never_repeats() { + let mut seen = std::collections::HashSet::new(); + for _ in 0..50 { + let p = fresh_temp_path("F-A"); + assert!(seen.insert(p.clone()), "path repeated: {}", p.display()); + assert!( + !p.exists(), + "a fresh path must never already exist: {}", + p.display() + ); + } + } + + // ---- F2: ordering independence ---- + + fn fail(id: &str) -> (String, FixtureCheck5Outcome) { + ( + id.to_string(), + FixtureCheck5Outcome::Fail { + reason: "absent-from-tree".to_string(), + observed_role: None, + observed_name: None, + prohibited_outcome: Some("absent-from-tree".to_string()), + }, + ) + } + fn not_run(id: &str) -> (String, FixtureCheck5Outcome) { + ( + id.to_string(), + FixtureCheck5Outcome::NotRun { + reason: "bus unreachable".to_string(), + }, + ) + } + fn pass(id: &str) -> (String, FixtureCheck5Outcome) { + ( + id.to_string(), + FixtureCheck5Outcome::Pass { + observed_role: Some("label".to_string()), + observed_name: Some("x".to_string()), + }, + ) + } + + /// Required kill: FAIL-then-NotRun must still report FAIL, not NotRun — + /// the exact bug (a known FAIL erased by a later bus-unreachable). + #[test] + fn fail_then_not_run_reports_fail() { + let outcomes = vec![fail("F-A"), not_run("F-B")]; + let (outcome, evidence) = aggregate_check5(&outcomes); + assert!(outcome.is_fail(), "{outcome:?}"); + assert!(evidence.is_none()); + } + + /// The other ordering must produce the SAME outcome — proving the + /// aggregation does not depend on which was observed first. + #[test] + fn not_run_then_fail_reports_fail_too() { + let outcomes = vec![not_run("F-A"), fail("F-B")]; + let (outcome, evidence) = aggregate_check5(&outcomes); + assert!(outcome.is_fail(), "{outcome:?}"); + assert!(evidence.is_none()); + } + + #[test] + fn all_pass_reports_pass() { + let outcomes = vec![pass("F-A"), pass("F-B")]; + let (outcome, evidence) = aggregate_check5(&outcomes); + assert!(outcome.is_pass()); + assert!(evidence.is_none()); + } + + /// NotRun with no FAIL anywhere is admissible, and carries evidence. + #[test] + fn not_run_with_no_fail_is_admissible_with_evidence() { + let outcomes = vec![pass("F-A"), not_run("F-B")]; + let (outcome, evidence) = aggregate_check5(&outcomes); + assert!(outcome.is_not_run(), "{outcome:?}"); + assert!(evidence.is_some()); + } + + #[test] + fn not_run_before_pass_is_admissible_too() { + let outcomes = vec![not_run("F-A"), pass("F-B")]; + let (outcome, evidence) = aggregate_check5(&outcomes); + assert!(outcome.is_not_run(), "{outcome:?}"); + assert!(evidence.is_some()); + } + + // ---- G3: publish, don't accumulate ---- + + /// Required kill: publishing five fixtures' outcomes, twice in a row, + /// must leave **exactly five** canonical files — never ten. This is the + /// exact defect G3 closes: the earlier revision wrote a fresh + /// PID/timestamp-named file straight into `evidence_dir` on every run, + /// so `evidence_dir` accumulated without bound. + #[test] + fn publishing_is_idempotent_two_runs_leave_exactly_five_files() { + let dir = scratch_dir("idempotent-publish"); + let fixture_ids = ["F-A", "F-B", "F-C", "F-D", "F-E"]; + for round in 0..2 { + for id in fixture_ids { + let temp = dir.join(format!("scratch-{id}-{round}.json")); + write_json(&temp, id, "PASS"); + let outcome = FixtureCheck5Outcome::Pass { + observed_role: Some("label".to_string()), + observed_name: Some("x".to_string()), + }; + publish_canonical(&dir, id, &temp, &outcome).unwrap(); + let _ = std::fs::remove_file(&temp); + } + let files: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .map(|e| e.unwrap().file_name()) + .collect(); + assert_eq!( + files.len(), + 5, + "round {round}: expected exactly 5 canonical files, got {files:?}" + ); + } + let _ = std::fs::remove_dir_all(&dir); + } + + /// A `Pass`/`Fail` outcome's canonical file carries the actually- + /// published content (not merely "a file exists") — republishing with a + /// different verdict must overwrite, not append. + #[test] + fn publishing_overwrites_the_previous_verdict() { + let dir = scratch_dir("overwrite-verdict"); + let temp1 = dir.join("scratch-1.json"); + write_json(&temp1, "F-A", "PASS"); + publish_canonical( + &dir, + "F-A", + &temp1, + &FixtureCheck5Outcome::Pass { + observed_role: None, + observed_name: None, + }, + ) + .unwrap(); + + let temp2 = dir.join("scratch-2.json"); + write_json(&temp2, "F-A", "FAIL"); + publish_canonical( + &dir, + "F-A", + &temp2, + &FixtureCheck5Outcome::Fail { + reason: "x".to_string(), + observed_role: None, + observed_name: None, + prohibited_outcome: None, + }, + ) + .unwrap(); + + let canonical = dir.join("F-A.json"); + let text = std::fs::read_to_string(&canonical).unwrap(); + assert!(text.contains("FAIL"), "{text}"); + assert!(!text.contains("PASS"), "{text}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A `NotRun` outcome removes any stale canonical file rather than + /// leaving an earlier run's PASS/FAIL lingering under a claim this run + /// never made. + #[test] + fn a_not_run_outcome_removes_a_stale_canonical_file() { + let dir = scratch_dir("not-run-removes-stale"); + let temp1 = dir.join("scratch-1.json"); + write_json(&temp1, "F-A", "PASS"); + publish_canonical( + &dir, + "F-A", + &temp1, + &FixtureCheck5Outcome::Pass { + observed_role: None, + observed_name: None, + }, + ) + .unwrap(); + assert!(dir.join("F-A.json").exists()); + + // No temp file exists for a NotRun outcome (verify.py never wrote + // one) — pass a path that does not exist, matching the real + // caller's situation. + let missing_temp = dir.join("does-not-exist.json"); + publish_canonical( + &dir, + "F-A", + &missing_temp, + &FixtureCheck5Outcome::NotRun { + reason: "bus unreachable".to_string(), + }, + ) + .unwrap(); + assert!( + !dir.join("F-A.json").exists(), + "a stale canonical file must be removed on NotRun, not left claiming PASS" + ); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/bin/c1_round2_a11y.rs b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/bin/c1_round2_a11y.rs new file mode 100644 index 0000000..dfb79b4 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/bin/c1_round2_a11y.rs @@ -0,0 +1,58 @@ +//! `ReportPart::FixtureAndReportPlumbing` — CLI parsing and fixture loading +//! for the check-5 windowed probe. The window/event-loop/adapter-lifecycle +//! code is `c1_egui_lyon::a11y_app` (`AccessibilityIntegrationWiring`); the +//! accessible node itself is `c1_egui_lyon::a11y_node` +//! (`AccessibilityTreeConstruction`). This file is deliberately thin: it +//! reads `--fixture`, loads that one fixture's resolved text from the +//! frozen `fixtures.json`, and hands off. +//! +//! Round 1's binary (and `c1_round2_text.rs`) are headless: offscreen wgpu +//! only, no window, nothing an AT-SPI client could ever see. Check 5 +//! requires "a real window on the AT-SPI bus" (the contract's own words), +//! so this is a second, separate windowed mode — the same first-party +//! AccessKit route `probe-egui`'s Round 0 binary demonstrated a readback +//! for (see `round0-evidence/c1-egui-readback.txt`). +//! +//! **This binary carries no visual rendering.** An earlier revision of this +//! packet painted the fixture's glyphs here too, "for visual confirmation +//! only" — which is exactly the kind of non-semantic content the F3 finding +//! named as wrongly mixed into this binary's `ReportPart` attribution. +//! Scoring is not this binary's job either way: `a11y-verifier/verify.py`, +//! run out-of-process against the live AT-SPI2 bus by `c1_round2_text.rs` +//! (`c1_egui_lyon::a11y_subprocess`), is the actual readback and verdict. + +use std::path::PathBuf; + +fn spike_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn parse_fixture_arg() -> String { + let args: Vec = std::env::args().collect(); + let mut i = 1; + while i < args.len() { + if args[i] == "--fixture" && i + 1 < args.len() { + return args[i + 1].clone(); + } + i += 1; + } + eprintln!("usage: c1_round2_a11y --fixture "); + std::process::exit(2); +} + +fn main() -> eframe::Result { + let fixture_id = parse_fixture_arg(); + let root = spike_root(); + + let fixtures_path = root.join("round2-textkit/fixtures.json"); + let fixtures = round2_textkit::output::load_fixtures(&fixtures_path) + .unwrap_or_else(|e| panic!("failed to load {}: {e}", fixtures_path.display())); + let record = fixtures + .fixtures + .iter() + .find(|f| f.id == fixture_id) + .unwrap_or_else(|| panic!("no fixture {fixture_id:?} in fixtures.json")); + let resolved = record.resolved.clone(); + + c1_egui_lyon::a11y_app::run(fixture_id, resolved) +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/bin/c1_round2_text.rs b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/bin/c1_round2_text.rs new file mode 100644 index 0000000..33b0d5b --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/bin/c1_round2_text.rs @@ -0,0 +1,746 @@ +//! Packet 2B-C1 — Round 2 (text), candidate **C1 (egui + lyon)**. +//! +//! Drives checks 1 (faithful consumption), 2 (fallback, forced), 4 (hit +//! testing), and 5 (accessibility, via subprocess orchestration of +//! `bin/c1_round2_a11y.rs` + `a11y-verifier/verify.py`, +//! `c1_egui_lyon::a11y_subprocess`) against the frozen Round 2 text +//! apparatus (`round2-candidatekit`, `round2-textkit`, `round2-diff`), and +//! writes a `round2_candidatekit::CandidateReport` to `round2_report.json` +//! in this crate's own directory. +//! +//! **Separate from `src/main.rs`**, the Round 1 binary, which this packet +//! does not touch. This binary renders the resolved text data offscreen — +//! it never calls any egui text-layout API, font-fallback API, or +//! `rustybuzz`; glyph ids and positions come straight from +//! `SpikeResolvedText` (pin 8's fixture data), and outline extraction / +//! lyon-path conversion is this candidate's own work +//! (`c1_egui_lyon::glyph_outline`, `c1_egui_lyon::render_target`). +//! +//! `ReportPart::FixtureAndReportPlumbing` (F3): this file itself is +//! apparatus loading, check 1/2/4 scoring, and report assembly — the actual +//! rendering pipeline lives in `c1_egui_lyon::render_target` +//! (`TextRendering`) and the check-5 subprocess orchestration lives in +//! `c1_egui_lyon::a11y_subprocess`. **Reattribution (user ruling):** +//! `a11y_subprocess.rs` is now `FixtureAndReportPlumbing`, not +//! `AccessibilityIntegrationWiring` — it is the verifier-subprocess harness +//! (spawning, decoding `verify.py`'s output, freshness/publish handling), +//! common to both candidates and not part of either stack's own +//! accessibility integration. Only `c1_egui_lyon::a11y_app` +//! (`AccessibilityIntegrationWiring`) is this candidate's own +//! adapter/window/event-loop wiring; this file calls into both. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use anyhow::{anyhow, Context, Result}; + +use c1_egui_lyon::hit_test; +use c1_egui_lyon::render_target::{self, GpuCtx}; + +use round2_candidatekit::{ + scoring, AdapterStatus, CandidateReport, CostRecord, DependencyDelta, DiffReportRecord, + HitTestProbeResult, IntegrationOwnership, LocByPart, ReportPart, +}; +use round2_diff::GlyphRegion; +use round2_textkit::faces::{resolve_declared_chain, FaceResolution, LoadedFace}; +use round2_textkit::hittest::DevicePoint; + +const BASELINE_COMMIT: &str = "c20bc93"; +const CANDIDATE_ID: &str = "C1 egui 0.35 + lyon 1.0 (egui_wgpu::Renderer)"; + +fn spike_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +fn main() -> Result<()> { + let root = spike_root(); + println!("== Packet 2B-C1: Round 2 text, candidate C1 (egui + lyon) =="); + println!("spike root: {}", root.display()); + + let inputs = round2_candidatekit::load_all(&root) + .map_err(|e| anyhow!("failed to load candidate-neutral apparatus: {e}"))?; + println!( + "loaded {} fixtures, {} hit-test probe tables, {} reference rasters", + inputs.fixtures.fixtures.len(), + inputs.hittest_probes.fixtures.len(), + inputs.reference.len() + ); + + // Resolve the two declared faces. Both are present on this machine + // (measured; see the recipe §1 hashes) — a missing face here would be + // pin 14's environmental NOT RUN, but since rendering, hit testing, and + // accessibility all key off the SAME resolved `fixtures.json` (which + // itself required both faces to generate), a missing face at this + // point would make every check NOT RUN, not just one, so this is + // treated as a fatal precondition rather than folded into any one + // check's outcome. + let resolved_chain = resolve_declared_chain(); + let mut loaded_faces: Vec = Vec::new(); + for r in resolved_chain { + match r { + FaceResolution::Loaded(lf) => loaded_faces.push(lf), + FaceResolution::Missing { path } => { + return Err(anyhow!( + "NOT RUN: declared face missing at {} — environment absence (pin 14), not a \ + candidate failure; every Round 2 check requires both declared faces", + path.display() + )); + } + } + } + let ttf_faces: Vec = loaded_faces + .iter() + .map(|lf| { + ttf_parser::Face::parse(&lf.bytes, lf.identity.face_index) + .expect("face bytes already validated by resolve_declared_chain") + }) + .collect(); + + let mut gpu: GpuCtx = render_target::build_gpu()?; + println!( + "GPU adapter: {} ({})", + gpu.adapter_name, gpu.adapter_device_type + ); + + // ---- Checks 1 & 2: render + diff every fixture ---- + let mut per_fixture_diffs: BTreeMap = BTreeMap::new(); + let mut unresolved_by_fixture: BTreeMap> = BTreeMap::new(); + let mut check1_failures: Vec = Vec::new(); + + for f in &inputs.fixtures.fixtures { + let rt = &f.resolved; + let draw = render_target::draw_fixture(&mut gpu, rt, &ttf_faces) + .with_context(|| format!("{}: render failed", f.id))?; + unresolved_by_fixture.insert(f.id.clone(), draw.unresolved_segments.clone()); + + let reference = &inputs.reference[&f.id]; + let regions: Vec = reference.regions.clone(); + let diff = round2_diff::diff( + &reference.reference_rgba, + &draw.rgba, + render_target::WIDTH, + render_target::HEIGHT, + ®ions, + ) + .map_err(|e| anyhow!("{}: diff failed: {e}", f.id))?; + + println!( + "{}: D1={} D2={:.4}% D3={:?} D4_worst={:?} pass={}", + f.id, + diff.d1_pixels_outside_band_differing, + diff.d2_relative_delta * 100.0, + diff.d3_delta, + diff.d4_worst + .as_ref() + .map(|w| (w.label.clone(), w.relative_delta)), + diff.pass() + ); + if !diff.pass() { + check1_failures.push(format!( + "{}: d1_pass={} d2_pass={} d3_pass={:?} d4_pass={}", + f.id, diff.d1_pass, diff.d2_pass, diff.d3_pass, diff.d4_pass + )); + } + per_fixture_diffs.insert(f.id.clone(), DiffReportRecord::from(&diff)); + } + + let check1_faithful_consumption = if check1_failures.is_empty() { + round2_candidatekit::CheckOutcome::Pass + } else { + round2_candidatekit::CheckOutcome::fail(format!( + "bounded visual differential failed for: {}", + check1_failures.join("; ") + )) + .unwrap() + }; + + // ---- Check 2: fallback, forced ---- + // F-C's U+0627 must resolve to face:None (reported explicitly, never + // substituted) and F-B/F-C's renders must still match the reference + // (proving the *rest* of the declared chain — including the traversal + // to face 1 for Hebrew — was followed faithfully, not host-substituted). + let fc_unresolved = unresolved_by_fixture + .get("F-C") + .cloned() + .unwrap_or_default(); + let fb_pass = per_fixture_diffs + .get("F-B") + .map(|d| d.pass) + .unwrap_or(false); + let fc_pass = per_fixture_diffs + .get("F-C") + .map(|d| d.pass) + .unwrap_or(false); + println!("F-C unresolved segments (check 2 evidence): {fc_unresolved:?}"); + + let check2_fallback = if fc_unresolved.is_empty() { + round2_candidatekit::CheckOutcome::fail( + "F-C produced no unresolved (face: None) segment at all — expected U+0627 to be \ + explicitly reported as uncovered by the declared chain", + ) + .unwrap() + } else if !fb_pass || !fc_pass { + round2_candidatekit::CheckOutcome::fail(format!( + "F-B pass={fb_pass}, F-C pass={fc_pass} — the declared fallback chain was not \ + rendered faithfully" + )) + .unwrap() + } else { + round2_candidatekit::CheckOutcome::Pass + }; + + // ---- Check 4: hit testing ---- + let mut hittest_probe_results = Vec::new(); + let mut check4_fail_count = 0usize; + for ft in &inputs.hittest_probes.fixtures { + let rt = &inputs + .fixtures + .fixtures + .iter() + .find(|f| f.id == ft.fixture_id) + .unwrap() + .resolved; + for p in &ft.probes { + let point = DevicePoint { + x: p.point.x, + y: p.point.y, + }; + let answer = hit_test::resolve(rt, point); + let pass = answer.source_offset == p.expected_source_offset + && answer.affinity == p.expected_affinity; + if !pass { + check4_fail_count += 1; + } + hittest_probe_results.push(HitTestProbeResult { + fixture_id: ft.fixture_id.clone(), + point: p.point, + expected_source_offset: p.expected_source_offset, + expected_affinity: p.expected_affinity, + actual_source_offset: answer.source_offset, + actual_affinity: answer.affinity, + pass, + }); + } + } + println!( + "check 4: {}/{} probes passed", + hittest_probe_results.len() - check4_fail_count, + hittest_probe_results.len() + ); + let check4_hit_testing = if check4_fail_count == 0 { + round2_candidatekit::CheckOutcome::Pass + } else { + round2_candidatekit::CheckOutcome::fail(format!( + "{check4_fail_count}/{} hit-test probes disagreed with the committed expected answer", + hittest_probe_results.len() + )) + .unwrap() + }; + + // ---- Supplementary F-D bidi row (never reaches check 3's cell) ---- + let fd_pass = per_fixture_diffs + .get("F-D") + .map(|d| d.pass) + .unwrap_or(false); + let fd_probe_count = hittest_probe_results + .iter() + .filter(|r| r.fixture_id == "F-D") + .count(); + let fd_probe_fail = hittest_probe_results + .iter() + .filter(|r| r.fixture_id == "F-D" && !r.pass) + .count(); + let supplementary_f_d_bidi = if fd_pass && fd_probe_fail == 0 { + round2_candidatekit::CheckOutcome::Pass + } else { + round2_candidatekit::CheckOutcome::fail(format!( + "F-D diff pass={fd_pass}, hit-test probes {fd_probe_fail}/{fd_probe_count} failed" + )) + .unwrap() + }; + + // ---- Check 5: accessibility, out-of-process (F1/F2 fixes live in + // c1_egui_lyon::a11y_subprocess) ---- + let exe_dir = std::env::current_exe()? + .parent() + .expect("executable has a parent directory") + .to_path_buf(); + let a11y_bin = exe_dir.join("c1_round2_a11y"); + if !a11y_bin.exists() { + return Err(anyhow!( + "{} does not exist — build it first (cargo build -p c1-egui-lyon --bin \ + c1_round2_a11y)", + a11y_bin.display() + )); + } + let evidence_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("round2_a11y_evidence"); + let fixture_ids: Vec = inputs + .fixtures + .fixtures + .iter() + .map(|f| f.id.clone()) + .collect(); + let check5 = + c1_egui_lyon::a11y_subprocess::run_all(&root, &a11y_bin, &evidence_dir, &fixture_ids)?; + + // ---- Cost record ---- + let cost = CostRecord { + baseline_commit: BASELINE_COMMIT.to_string(), + dependencies_added: vec![ + DependencyDelta { + name: "round2-candidatekit".to_string(), + version: "0.1.0 (path)".to_string(), + reason: "candidate-neutral fixture/oracle loading and the shared report shape / \ + scoring rule Round 2 requires every candidate to consume rather than \ + re-derive." + .to_string(), + }, + DependencyDelta { + name: "round2-diff".to_string(), + version: "0.1.0 (path)".to_string(), + reason: "the precommitted bounded visual differential (D1-D4) checks 1/2 score \ + against." + .to_string(), + }, + DependencyDelta { + name: "round2-textkit".to_string(), + version: "0.1.0 (path)".to_string(), + reason: "SpikeResolvedText fixtures, the staff->device transform \ + (hittest::to_device), the hit-test probe table, and face resolution." + .to_string(), + }, + DependencyDelta { + name: "ttf-parser".to_string(), + version: "=0.25.1".to_string(), + reason: "candidate-owned glyph outline extraction from the two declared host \ + faces (not from Bravura's typed PathCommand data, which Round 1 used) — \ + pinned to the exact version round2-textkit shapes fixtures against." + .to_string(), + }, + DependencyDelta { + name: "serde_json".to_string(), + version: "1".to_string(), + reason: "serializing CandidateReport to round2_report.json, and parsing \ + verify.py's --json output." + .to_string(), + }, + DependencyDelta { + name: "eframe".to_string(), + version: "0.35".to_string(), + reason: "check 5 needs a real window on the live AT-SPI2 bus; Round 1's binary \ + is headless (offscreen wgpu only, no winit/eframe at all). Same first-party \ + AccessKit route probe-egui's Round 0 binary used." + .to_string(), + }, + ], + adapters: vec![ + AdapterStatus::Implemented { + platform: "at-spi2".to_string(), + notes: "the round's own platform on this Linux/Wayland machine — verified via \ + a11y-verifier/verify.py's live, out-of-process AT-SPI2 readback for all five \ + fixtures (round2_a11y_evidence/*.json)." + .to_string(), + integration_ownership: IntegrationOwnership::inherited( + "eframe 0.35 -> egui-winit -> accesskit_winit -> accesskit_unix \ + (the AT-SPI2 adapter and its lifecycle ship with eframe; this candidate \ + wrote none of that plumbing)", + ) + .expect("provider is a non-empty literal"), + }, + AdapterStatus::Implemented { + platform: "accesskit-0.24".to_string(), + notes: "reached and exercised — every check-5 readback below travelled this \ + path. What this candidate does write on top of the inherited integration is \ + the accessible node for its own canvas-painted run — counted under \ + ReportPart::AccessibilityTreeConstruction (c1_egui_lyon::a11y_node), not \ + here." + .to_string(), + integration_ownership: IntegrationOwnership::inherited( + "eframe 0.35 (bundled AccessKit integration; accesskit was already \ + in this crate's Round 1 dependency graph at c20bc93 via egui 0.35)", + ) + .expect("provider is a non-empty literal"), + }, + AdapterStatus::NotBuilt { + platform: "aria".to_string(), + reason: "no web/ARIA target exists for this candidate — egui/eframe here is a \ + native desktop app, not a web build." + .to_string(), + }, + AdapterStatus::NotBuilt { + platform: "macos-nsaccessibility".to_string(), + reason: "no macOS runner available in this environment.".to_string(), + }, + AdapterStatus::NotBuilt { + platform: "windows-uia".to_string(), + reason: "no Windows runner available in this environment.".to_string(), + }, + ], + integration_wiring: vec![ + "glyph_outline.rs: ttf_parser::OutlineBuilder callbacks converted directly to a \ + lyon_path::Path in device space (no PathCommand/SVG intermediate), tessellated \ + with lyon's NonZero fill rule as one compound path per glyph so bounded holes \ + (e.g. 'o', 'e') survive." + .to_string(), + "render_target.rs: the offscreen egui_wgpu render target (device/adapter setup, \ + MSAA/resolve texture pair, render pass, CPU readback) checks 1/2 draw into." + .to_string(), + "hit_test.rs: a hand-written floor-search resolver over the resolved text's own \ + Downstream caret-stop partition, reusing only round2_textkit::hittest::to_device \ + for the shared staff->device transform — no other apparatus from the probe \ + generator is called." + .to_string(), + format!( + "check 2 evidence: F-C's U+0627 (byte range recorded per-fixture below) is \ + explicitly detected via SpikeShapedSegment::face == None in \ + render_target::draw_fixture(), which asserts its glyph list is empty and \ + records the span in round2_report.json's console log rather than silently \ + drawing nothing — this candidate's fixture-level trace is: F-C unresolved \ + segments = {fc_unresolved:?}" + ), + "a11y_node.rs: a custom AccessKit node (Role::Label, value = the fixture's exact \ + source string) built directly via egui::Context::accesskit_node_builder on an \ + Id allocated with ui.interact(..., Sense::hover()) — bypassing egui's Label \ + widget and its own text-layout/galley construction entirely, so the accessible \ + name is never touched by anything that could re-shape, wrap, or normalize it." + .to_string(), + "a11y_app.rs (ReportPart::AccessibilityIntegrationWiring — this candidate's own \ + integration, and the only file counted under this row): the eframe::App/window/ \ + event-loop wiring the check-5 windowed probe runs under (no visual glyph \ + rendering — dropped by the F3 fix, see that file's own doc comment)." + .to_string(), + "a11y_subprocess.rs (ReportPart::FixtureAndReportPlumbing, reattributed by user \ + ruling: a verifier-subprocess harness common to both Round 2 candidates, not \ + part of either stack's own accessibility integration): subprocess orchestration \ + spawning c1_round2_a11y once per fixture and invoking a11y-verifier/verify.py \ + out-of-process for the live AT-SPI2 readback (never a same-process self-report). \ + F1: every invocation writes to a fresh, unique path and requires the exit status \ + and JSON verdict to agree, erroring hard on disagreement rather than trusting \ + either. G1: admission of an exit-2 NOT RUN requires both the exact prefix and an \ + approved environmental-cause marker (an allow-list, not a deny-list). F2: any \ + FAIL across the fixture set wins over any NotRun in the final aggregate, \ + regardless of which was observed first. G3: validation (in the system temp \ + directory) and publishing (one canonical file per fixture, overwriting) are \ + separate steps, so evidence never accumulates." + .to_string(), + ], + loc_by_part: loc_by_part()?, + }; + + let report = CandidateReport { + candidate_id: CANDIDATE_ID.to_string(), + check1_faithful_consumption, + check2_fallback, + check3_bidi: round2_candidatekit::CheckOutcome::not_run(scoring::CHECK_3_RULING).unwrap(), + check4_hit_testing, + check5_accessibility: check5.check5_accessibility, + check5_bus_unreachable_evidence: check5.check5_bus_unreachable_evidence, + supplementary_f_d_bidi, + per_fixture_diffs, + hittest_probe_results, + a11y_evidence: check5.a11y_evidence, + cost, + }; + + let cell = scoring::criterion_cell(&report); + let eligible = scoring::is_eligible(&report); + println!("\n== Round 2 criterion cell: {cell:?} =="); + println!("== eligible (disqualifying set passed): {eligible} =="); + + let out_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("round2_report.json"); + let json = serde_json::to_string_pretty(&report)?; + std::fs::write(&out_path, json)?; + println!("wrote {}", out_path.display()); + + Ok(()) +} + +/// `src/main.rs` is Round 1's frozen binary — untouched by this packet, not +/// part of Round 2's cost table at all — and is the **only** file under +/// `src/` this mapping is allowed to leave unclaimed. Named as a constant +/// rather than an inline literal so [`check_mapping_exhaustive_and_disjoint`] +/// and its tests refer to the same one string. +const FROZEN_ROUND1_FILE: &str = "src/main.rs"; + +/// G2: the file-to-`ReportPart` mapping must be **exhaustive**, not merely +/// disjoint — every `.rs` file under `src/` (`src/main.rs` excepted, see +/// [`FROZEN_ROUND1_FILE`]) must be claimed by **exactly one** part. A file +/// silently omitted (as `src/lib.rs` was, before this fix) understates the +/// packet total and makes this table disagree with a sibling candidate's +/// equivalent table about what it even counts. Returns every problem found +/// (never just the first), each naming the specific file. +fn check_mapping_exhaustive_and_disjoint( + all_files: &[String], + mapping: &[(&str, &[&str])], +) -> Result<()> { + use std::collections::BTreeMap; + + let mut claimed_by: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); + for (part, files) in mapping { + for f in *files { + claimed_by.entry(f).or_default().push(part); + } + } + + let mut problems = Vec::new(); + for f in all_files { + match claimed_by.get(f.as_str()) { + None => problems.push(format!("{f}: unclaimed by any ReportPart")), + Some(parts) if parts.len() > 1 => { + problems.push(format!("{f}: claimed by multiple parts: {parts:?}")) + } + _ => {} + } + } + for (f, parts) in &claimed_by { + if !all_files.iter().any(|a| a == f) { + problems.push(format!( + "{f}: claimed by {parts:?} but not found under src/ (stale mapping entry?)" + )); + } + } + + if !problems.is_empty() { + return Err(anyhow!( + "ReportPart file mapping is not exhaustive/disjoint over src/:\n {}", + problems.join("\n ") + )); + } + Ok(()) +} + +/// Every `.rs` file under `dir`'s `src/`, recursively, as `src/...`-relative +/// paths — `src/main.rs` excluded (see [`FROZEN_ROUND1_FILE`]). +fn list_rs_files_under_src(dir: &Path) -> Result> { + fn walk(dir: &Path, base: &Path, out: &mut Vec) -> Result<()> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + walk(&path, base, out)?; + } else if path.extension().map(|e| e == "rs").unwrap_or(false) { + let rel = path + .strip_prefix(base) + .expect("walked path is under base") + .to_string_lossy() + .replace('\\', "/"); + out.push(rel); + } + } + Ok(()) + } + let mut files = Vec::new(); + walk(&dir.join("src"), dir, &mut files)?; + files.retain(|f| f != FROZEN_ROUND1_FILE); + files.sort(); + Ok(files) +} + +/// LOC per shared `ReportPart`, from this crate's own new Round 2 files +/// (`wc -l` equivalents, computed at run time so the figure never drifts +/// from what is actually on disk). +/// +/// **F3: one `ReportPart` maps to a disjoint set of whole files — no file +/// contributes to two parts.** **G2: the mapping is also exhaustive** — +/// [`check_mapping_exhaustive_and_disjoint`] fails loudly, naming the file, +/// if anything under `src/` (besides `src/main.rs`) is left unclaimed or +/// claimed twice, so a mapping that silently omits a file (as `src/lib.rs` +/// was) can no longer happen unnoticed. The mapping is printed (not only +/// asserted) so it can be checked against the module layout directly. +fn loc_by_part() -> Result> { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let count = |rel: &str| -> Result { + Ok(std::fs::read_to_string(dir.join(rel))?.lines().count() as u64) + }; + + // Reattribution (user ruling): AccessibilityIntegrationWiring holds only + // the product-side path — adapter lifecycle, event loop, window/bridge + // setup, tree publication. The verifier-subprocess harness + // (a11y_subprocess.rs: spawning, result decoding/reduction, freshness + // and canonical-publish handling, their mutation tests) is common to + // both Round 2 candidates and not part of either stack's own + // accessibility integration, so it counts as FixtureAndReportPlumbing — + // never `Other`, which the ruling reserves for genuinely + // candidate-specific seams, and this harness is not one. + let mapping: [(&str, &[&str]); 5] = [ + ( + "TextRendering", + &["src/glyph_outline.rs", "src/render_target.rs"], + ), + ("HitTestResolution", &["src/hit_test.rs"]), + ("AccessibilityTreeConstruction", &["src/a11y_node.rs"]), + ("AccessibilityIntegrationWiring", &["src/a11y_app.rs"]), + ( + "FixtureAndReportPlumbing", + &[ + "src/lib.rs", + "src/a11y_subprocess.rs", + "src/bin/c1_round2_text.rs", + "src/bin/c1_round2_a11y.rs", + ], + ), + ]; + + let all_files = list_rs_files_under_src(&dir)?; + check_mapping_exhaustive_and_disjoint(&all_files, &mapping)?; + + println!("\n== ReportPart file mapping (F3 disjoint, G2 exhaustive) =="); + let mut rows = Vec::with_capacity(mapping.len()); + for (label, files) in mapping { + let mut total = 0u64; + for f in files { + let n = count(f)?; + println!(" {label:<32} {f:<32} {n:>5} lines"); + total += n; + } + rows.push(total); + } + let packet_total: u64 = rows.iter().sum(); + println!(" {:<32} {:<32} {packet_total:>5} lines", "TOTAL", ""); + + Ok(vec![ + LocByPart { + part: ReportPart::TextRendering, + lines: rows[0], + }, + LocByPart { + part: ReportPart::HitTestResolution, + lines: rows[1], + }, + LocByPart { + part: ReportPart::AccessibilityTreeConstruction, + lines: rows[2], + }, + LocByPart { + part: ReportPart::AccessibilityIntegrationWiring, + lines: rows[3], + }, + LocByPart { + part: ReportPart::FixtureAndReportPlumbing, + lines: rows[4], + }, + ]) +} + +#[cfg(test)] +mod loc_mapping_tests { + use super::*; + + fn sample_mapping() -> Vec<(&'static str, &'static [&'static str])> { + vec![("A", &["src/a.rs"]), ("B", &["src/b.rs", "src/c.rs"])] + } + + #[test] + fn an_exhaustive_disjoint_mapping_passes() { + let files = vec![ + "src/a.rs".to_string(), + "src/b.rs".to_string(), + "src/c.rs".to_string(), + ]; + check_mapping_exhaustive_and_disjoint(&files, &sample_mapping()).unwrap(); + } + + /// G2 required kill: a file present under `src/` but named in no + /// part's file list must be refused, naming the file. + #[test] + fn an_unclaimed_file_is_refused_by_name() { + let files = vec![ + "src/a.rs".to_string(), + "src/b.rs".to_string(), + "src/c.rs".to_string(), + "src/d.rs".to_string(), // not in any part's file list + ]; + let err = check_mapping_exhaustive_and_disjoint(&files, &sample_mapping()).unwrap_err(); + assert!(err.to_string().contains("src/d.rs: unclaimed"), "{err}"); + } + + /// G2 required kill: a file named in two parts' file lists must be + /// refused, naming the file and both parts — the disjointness half of + /// the rule, still enforced now that exhaustiveness is checked too. + #[test] + fn a_doubly_claimed_file_is_refused_by_name() { + let mapping = vec![ + ("A", &["src/a.rs"][..]), + ("B", &["src/a.rs", "src/c.rs"][..]), + ]; + let files = vec!["src/a.rs".to_string(), "src/c.rs".to_string()]; + let err = check_mapping_exhaustive_and_disjoint(&files, &mapping).unwrap_err(); + assert!( + err.to_string().contains("src/a.rs: claimed by multiple"), + "{err}" + ); + } + + /// The real, current mapping (as built in `loc_by_part`) must itself + /// pass against the real, current file tree — this is the regression + /// lock for G2 on the actual packet, not just the synthetic cases + /// above. + #[test] + fn the_real_mapping_is_exhaustive_and_disjoint_over_the_real_tree() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let mapping: [(&str, &[&str]); 5] = [ + ( + "TextRendering", + &["src/glyph_outline.rs", "src/render_target.rs"], + ), + ("HitTestResolution", &["src/hit_test.rs"]), + ("AccessibilityTreeConstruction", &["src/a11y_node.rs"]), + ("AccessibilityIntegrationWiring", &["src/a11y_app.rs"]), + ( + "FixtureAndReportPlumbing", + &[ + "src/lib.rs", + "src/a11y_subprocess.rs", + "src/bin/c1_round2_text.rs", + "src/bin/c1_round2_a11y.rs", + ], + ), + ]; + let all_files = list_rs_files_under_src(&dir).unwrap(); + check_mapping_exhaustive_and_disjoint(&all_files, &mapping).unwrap(); + } + + /// Reattribution regression lock (user ruling): `a11y_subprocess.rs` + /// must be claimed by `FixtureAndReportPlumbing`, never + /// `AccessibilityIntegrationWiring` and never folded into `Other` — the + /// ruling explicitly reserves `Other` for candidate-specific seams, and + /// the verifier-subprocess harness is shared with C2, not one. + #[test] + fn a11y_subprocess_is_plumbing_not_integration_wiring_or_other() { + let mapping: [(&str, &[&str]); 5] = [ + ( + "TextRendering", + &["src/glyph_outline.rs", "src/render_target.rs"], + ), + ("HitTestResolution", &["src/hit_test.rs"]), + ("AccessibilityTreeConstruction", &["src/a11y_node.rs"]), + ("AccessibilityIntegrationWiring", &["src/a11y_app.rs"]), + ( + "FixtureAndReportPlumbing", + &[ + "src/lib.rs", + "src/a11y_subprocess.rs", + "src/bin/c1_round2_text.rs", + "src/bin/c1_round2_a11y.rs", + ], + ), + ]; + let (label, _) = mapping + .iter() + .find(|(_, files)| files.contains(&"src/a11y_subprocess.rs")) + .expect("src/a11y_subprocess.rs must be claimed by some part"); + assert_eq!(*label, "FixtureAndReportPlumbing", "{label}"); + assert_ne!(*label, "AccessibilityIntegrationWiring"); + } + + /// `list_rs_files_under_src` must exclude `src/main.rs` (Round 1's + /// frozen binary) but include everything else, e.g. `src/lib.rs` — the + /// exact file G2 found omitted from the mapping. + #[test] + fn main_rs_is_excluded_but_lib_rs_is_present() { + let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let files = list_rs_files_under_src(&dir).unwrap(); + assert!(!files.contains(&"src/main.rs".to_string()), "{files:?}"); + assert!(files.contains(&"src/lib.rs".to_string()), "{files:?}"); + } +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/glyph_outline.rs b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/glyph_outline.rs new file mode 100644 index 0000000..0522995 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/glyph_outline.rs @@ -0,0 +1,302 @@ +//! Candidate-owned glyph outline extraction + tessellation, used by +//! `render_target.rs` (the offscreen checks 1/2 render pipeline +//! `bin/c1_round2_text.rs` drives). Counted under `ReportPart::TextRendering` +//! together with `render_target.rs` — the F3 cost-schema amendment's own +//! definition of that row ("outline extraction, path building, +//! tessellation/rasterization, the offscreen render target"). The check-5 +//! windowed probe (`bin/c1_round2_a11y.rs`) does not use this module: it +//! carries no visual rendering at all, only the accessible node, so that its +//! own LOC is not a mix of `AccessibilityTreeConstruction` and rendering +//! code the F3 finding named as the defect in the pre-fix version of this +//! packet. +//! +//! Round 1's `main.rs` converted `epiphany_layout_ir::PathCommand` (Bravura's +//! own typed outline data, already staff-space `MoveTo`/`LineTo`/`CurveTo`) +//! into a lyon path. Round 2's glyphs come from host font faces instead, +//! addressed by font-internal glyph id (`SpikePositionedGlyph::glyph_id`) — +//! there is no `PathCommand` for them anywhere in this recipe's data. This +//! module is therefore new candidate work, not a reuse of Round 1's +//! `build_path`: it walks `ttf_parser::Face::outline_glyph`'s own +//! `OutlineBuilder` callbacks straight into a `lyon_path::Path`, exactly the +//! extraction-and-conversion step the packet names as "yours to write" and +//! "part of what the cost table measures." +//! +//! `round2-svgref` (the frozen, candidate-neutral reference emitter) walks +//! the same `ttf_parser::OutlineBuilder` callbacks to build an SVG path +//! string. This module does the analogous walk for a *lyon* path instead — +//! independently implemented, not called into, since the reference emitter +//! is off-limits apparatus (`round2-svgref` is not depended on here) and the +//! whole point of this module is that the candidate does its own outline +//! walk. + +use egui::epaint::{Mesh, Vertex}; +use egui::{Color32, Pos2, TextureId}; +use lyon_path::math::point as lyon_point; +use lyon_path::Path as LyonPath; +use lyon_tessellation::{ + BuffersBuilder, FillOptions, FillRule, FillTessellator, FillVertex, VertexBuffers, +}; + +/// The ink colour every glyph is painted, opaque, matching the reference +/// emitter's `fill="#000000"` and Round 1's own `INK`. +pub const INK: Color32 = Color32::BLACK; + +/// Collects one glyph outline straight into a `lyon_path::Path`, converting +/// font units to device pixels and flipping y (font space is y-up; device +/// space, like Round 1's and the reference emitter's, is y-down) in the same +/// step — no intermediate `PathCommand` or SVG-string representation. +/// +/// `device_origin` is the glyph's own device-space pen position — the output +/// of `round2_textkit::hittest::to_device` on the glyph's `offset`, per the +/// packet's non-negotiable rendering convention. `scale` is device px per +/// font unit (`em_px / units_per_em`). +struct GlyphPathSink { + builder: lyon_path::path::Builder, + ox: f64, + oy: f64, + scale: f64, + open: bool, + any: bool, +} + +impl GlyphPathSink { + fn map(&self, x: f32, y: f32) -> lyon_path::math::Point { + lyon_point( + (self.ox + x as f64 * self.scale) as f32, + (self.oy - y as f64 * self.scale) as f32, + ) + } +} + +impl ttf_parser::OutlineBuilder for GlyphPathSink { + fn move_to(&mut self, x: f32, y: f32) { + if self.open { + self.builder.end(true); + } + let p = self.map(x, y); + self.builder.begin(p); + self.open = true; + self.any = true; + } + + fn line_to(&mut self, x: f32, y: f32) { + let p = self.map(x, y); + self.builder.line_to(p); + } + + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + let c1 = self.map(x1, y1); + let p = self.map(x, y); + self.builder.quadratic_bezier_to(c1, p); + } + + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + let c1 = self.map(x1, y1); + let c2 = self.map(x2, y2); + let p = self.map(x, y); + self.builder.cubic_bezier_to(c1, c2, p); + } + + fn close(&mut self) { + if self.open { + self.builder.end(true); + self.open = false; + } + } +} + +/// Extracts glyph `glyph_id`'s **complete** outline (every subpath — a +/// glyph's own bounded holes and disjoint components alike, the same +/// discipline Round 1's oracle measured Bravura against) from `face` as a +/// `lyon_path::Path` in device space, or `None` if the glyph has no outline +/// at all (whitespace — `ttf_parser::Face::outline_glyph` itself returns +/// `None`, or draws nothing). Never substituted with a placeholder: a glyph +/// with no outline draws nothing, exactly as a segment with `face: None` +/// draws nothing (recipe: "do not substitute a fallback and do not draw +/// `.notdef`"). +pub fn glyph_outline_to_lyon_path( + face: &ttf_parser::Face, + glyph_id: u32, + device_origin: (f64, f64), + em_px: f64, +) -> Option { + let upem = face.units_per_em() as f64; + if upem <= 0.0 { + return None; + } + let mut sink = GlyphPathSink { + builder: LyonPath::builder(), + ox: device_origin.0, + oy: device_origin.1, + scale: em_px / upem, + open: false, + any: false, + }; + let gid = ttf_parser::GlyphId(glyph_id as u16); + face.outline_glyph(gid, &mut sink)?; + if sink.open { + sink.builder.end(true); + } + if !sink.any { + return None; + } + Some(sink.builder.build()) +} + +/// Tessellates one glyph outline and appends its vertices/indices into +/// `buffers`, offsetting indices so multiple glyphs can share one +/// `VertexBuffers` / one draw call. +/// +/// **Nonzero fill rule** — matching both the reference emitter's own +/// `fill-rule="nonzero"` and Round 1's finding that TrueType/CFF outlines +/// (like Bravura's) are correctly wound, so nonzero and even-odd agree; the +/// **whole glyph outline is tessellated in one `tessellate_path` call**, the +/// same "one compound path, not per-subpath" discipline Round 1's `main.rs` +/// documents — a glyph with a bounded counter (e.g. `o`, `e`) has its hole +/// preserved only because every subpath enters the same fill call. +pub fn tessellate_into( + path: &LyonPath, + buffers: &mut VertexBuffers<[f32; 2], u32>, +) -> Result<(), String> { + let mut tess = FillTessellator::new(); + tess.tessellate_path( + path, + &FillOptions::default().with_fill_rule(FillRule::NonZero), + &mut BuffersBuilder::new(buffers, |v: FillVertex| { + let p = v.position(); + [p.x, p.y] + }), + ) + .map_err(|e| format!("lyon tessellation failed: {e:?}"))?; + Ok(()) +} + +/// Builds one `egui::epaint::Mesh`, bound to `tex`, containing every glyph +/// already tessellated into `buffers` — the whole fixture's ink in one mesh, +/// paintable in a single draw call. +pub fn mesh_from_buffers(buffers: &VertexBuffers<[f32; 2], u32>, tex: TextureId) -> Mesh { + let mut mesh = Mesh::with_texture(tex); + mesh.vertices = buffers + .vertices + .iter() + .map(|[x, y]| Vertex { + pos: Pos2::new(*x, *y), + uv: Pos2::ZERO, + color: INK, + }) + .collect(); + mesh.indices = buffers.indices.clone(); + mesh +} + +#[cfg(test)] +mod tests { + use super::*; + + const PAGELLA: &str = "/usr/share/fonts/tex-gyre/texgyrepagella-regular.otf"; + + fn face_bytes() -> Option> { + std::fs::read(PAGELLA).ok() + } + + /// Mutation-first: an outline that exists must actually tessellate to a + /// non-empty mesh with real ink coverage — a sink wired backwards (e.g. + /// dropping `close()`) would silently produce zero triangles instead of + /// a build error. + #[test] + fn a_real_glyph_outline_tessellates_to_a_nonempty_mesh() { + let Some(bytes) = face_bytes() else { + eprintln!("NOT RUN: {PAGELLA} absent — environment absence, not a failure"); + return; + }; + let face = ttf_parser::Face::parse(&bytes, 0).unwrap(); + let gid = face.glyph_index('A').unwrap(); + let path = glyph_outline_to_lyon_path(&face, gid.0 as u32, (0.0, 0.0), 128.0) + .expect("'A' must have an outline"); + let mut buffers: VertexBuffers<[f32; 2], u32> = VertexBuffers::new(); + tessellate_into(&path, &mut buffers).unwrap(); + assert!(!buffers.vertices.is_empty()); + assert!(!buffers.indices.is_empty()); + assert_eq!( + buffers.indices.len() % 3, + 0, + "a fill tessellation must produce whole triangles" + ); + } + + /// A whitespace glyph (space) has no outline and must map to `None`, not + /// an empty-but-`Some` path — the same "draws nothing, not a degenerate + /// mesh" contract `round2-svgref`'s `emit_glyph_paths` documents for its + /// own `empty` list. + #[test] + fn a_whitespace_glyph_has_no_outline() { + let Some(bytes) = face_bytes() else { + eprintln!("NOT RUN: {PAGELLA} absent"); + return; + }; + let face = ttf_parser::Face::parse(&bytes, 0).unwrap(); + let gid = face.glyph_index(' ').unwrap(); + let path = glyph_outline_to_lyon_path(&face, gid.0 as u32, (0.0, 0.0), 128.0); + assert!(path.is_none(), "a space glyph must produce no outline"); + } + + /// Required kill: a glyph with a bounded hole (`o`) must tessellate with + /// its counter preserved — i.e. NOT as a solid blob. Checked the same + /// way Round 1's oracle checks it: a point at the glyph's own centre + /// (inside the counter) must NOT be covered by any tessellated triangle, + /// while a point on the stem must be. This is a coarse geometric check + /// (bounding-box centroid, not the oracle's precise point-in-path + /// derivation), sufficient to catch the regression this module's own + /// doc comment warns about: tessellating per-subpath (which would fill + /// the hole solid) instead of as one compound path. + #[test] + fn a_glyph_with_a_hole_keeps_its_counter_open() { + let Some(bytes) = face_bytes() else { + eprintln!("NOT RUN: {PAGELLA} absent"); + return; + }; + let face = ttf_parser::Face::parse(&bytes, 0).unwrap(); + let gid = face.glyph_index('o').unwrap(); + let path = glyph_outline_to_lyon_path(&face, gid.0 as u32, (0.0, 0.0), 1000.0) + .expect("'o' must have an outline"); + let mut buffers: VertexBuffers<[f32; 2], u32> = VertexBuffers::new(); + tessellate_into(&path, &mut buffers).unwrap(); + + // Bounding box of the tessellated ink. + let (mut minx, mut miny, mut maxx, mut maxy) = (f32::MAX, f32::MAX, f32::MIN, f32::MIN); + for [x, y] in &buffers.vertices { + minx = minx.min(*x); + miny = miny.min(*y); + maxx = maxx.max(*x); + maxy = maxy.max(*y); + } + let cx = (minx + maxx) / 2.0; + let cy = (miny + maxy) / 2.0; + + let point_in_triangle = |p: (f32, f32), a: (f32, f32), b: (f32, f32), c: (f32, f32)| { + let sign = |p1: (f32, f32), p2: (f32, f32), p3: (f32, f32)| { + (p1.0 - p3.0) * (p2.1 - p3.1) - (p2.0 - p3.0) * (p1.1 - p3.1) + }; + let d1 = sign(p, a, b); + let d2 = sign(p, b, c); + let d3 = sign(p, c, a); + let has_neg = d1 < 0.0 || d2 < 0.0 || d3 < 0.0; + let has_pos = d1 > 0.0 || d2 > 0.0 || d3 > 0.0; + !(has_neg && has_pos) + }; + let covers = |p: (f32, f32)| { + buffers.indices.chunks(3).any(|tri| { + let a = buffers.vertices[tri[0] as usize]; + let b = buffers.vertices[tri[1] as usize]; + let c = buffers.vertices[tri[2] as usize]; + point_in_triangle(p, (a[0], a[1]), (b[0], b[1]), (c[0], c[1])) + }) + }; + + assert!( + !covers((cx, cy)), + "the centre of 'o' must be an unfilled counter, not solid ink — a per-subpath \ + tessellation (the regression this module exists to avoid) would fill it" + ); + } +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/hit_test.rs b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/hit_test.rs new file mode 100644 index 0000000..c0e67e0 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/hit_test.rs @@ -0,0 +1,228 @@ +//! Candidate-owned hit-test resolution: point -> (byte offset, affinity) +//! against a `SpikeResolvedText`'s own caret-stop data (check 4). +//! +//! Loading the *expected* answers (`round2_textkit::hittest::HitTestProbeFile`) +//! is neutral apparatus, consumed as-is in `bin/c1_round2_text.rs`. Computing an +//! answer from a device point is this module's job, and this module's only +//! borrowing from `round2_textkit::hittest` is [`to_device`] — the shared +//! staff-space -> device-space transform every render in this packet uses +//! (the contract requires reusing it rather than re-implementing the +//! transform), not the probe *generator*'s own resolution logic. Resolution +//! itself is independently reasoned about below, not copied from that +//! module's doc comment. +//! +//! ## The resolution rule +//! +//! A run's caret stops (`SpikeCaretStop`, one per grapheme-cluster boundary, +//! from the resolved text's own `ClusterMap`) are the only geometry this +//! candidate has to test a point against. The `Downstream`-affinity stops are +//! exactly the leading edge of each grapheme: sorted by device x they +//! partition the line into a sequence of non-overlapping boxes with no gaps. +//! So a point maps to the stop that begins the box containing it — the +//! largest `Downstream` stop whose device x is at or before the point (a +//! "floor" search over a sorted sequence), never a nearest-neighbour vote, +//! which would be ambiguous exactly at a box's own midpoint. A point before +//! every stop resolves to the first stop (there is no earlier box to belong +//! to); a point after every stop resolves to the last. +//! +//! `Upstream`-affinity stops (the direction-boundary duplicates the resolved +//! text carries at a bidi run boundary) are not part of this partition — +//! they exist so a *caret*, already known to be at a specific logical +//! offset, can pick the geometrically correct side of a direction boundary. +//! A point-to-offset query carries no such prior knowledge, so it is +//! answered from the `Downstream` partition alone, and this resolver's +//! answer always reports `Downstream` affinity. + +use round2_textkit::hittest::{to_device, DevicePoint}; +use round2_textkit::types::{SpikeCaretAffinity, SpikeResolvedText}; + +/// One resolved answer: a UTF-8 byte offset into `SpikeResolvedText::text` +/// and the affinity this resolver reports for it — always `Downstream` (see +/// the module doc comment). +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct HitTestAnswer { + pub source_offset: u32, + pub affinity: SpikeCaretAffinity, +} + +/// One `Downstream` caret stop, resolved to device space and kept alongside +/// its source offset. +struct Stop { + source_offset: u32, + device_x: f64, +} + +/// Builds the device-x-sorted `Downstream` partition this module's +/// resolution rule is defined over. +fn downstream_partition(rt: &SpikeResolvedText) -> Vec { + let mut stops: Vec = rt + .clusters + .clusters + .iter() + .flat_map(|c| c.caret_stops.iter()) + .filter(|s| s.affinity == SpikeCaretAffinity::Downstream) + .map(|s| Stop { + source_offset: s.source_offset, + device_x: to_device(rt, &s.position).x, + }) + .collect(); + stops.sort_by(|a, b| { + a.device_x + .partial_cmp(&b.device_x) + .expect("device x is always finite") + }); + stops +} + +/// Resolves one device x-coordinate against an already-built, device-x-sorted +/// `Downstream` partition — the "floor" search the module doc comment +/// describes: the last stop at or before `point_x`, or the first stop if +/// `point_x` precedes every stop. +fn resolve_against(partition: &[Stop], point_x: f64) -> HitTestAnswer { + assert!( + !partition.is_empty(), + "a resolved text with zero caret stops cannot be hit-tested" + ); + let mut floor = &partition[0]; + for stop in partition { + if stop.device_x <= point_x { + floor = stop; + } else { + break; + } + } + HitTestAnswer { + source_offset: floor.source_offset, + affinity: SpikeCaretAffinity::Downstream, + } +} + +/// Resolves `point` against `rt` from scratch — the entry point +/// `bin/c1_round2_text.rs` uses for every probe. +/// +/// Only `point.x` is consulted: every fixture in this recipe lays its run out +/// on one fixed baseline (`origin.y` fixed, `align: Start`), so device y does +/// not distinguish anything the probe table tests — every probe in +/// `hittest_probes.json` shares its fixture's one baseline y already. +pub fn resolve(rt: &SpikeResolvedText, point: DevicePoint) -> HitTestAnswer { + let partition = downstream_partition(rt); + resolve_against(&partition, point.x) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn spike_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("../..") + } + + /// End-to-end: this resolver, run over every one of the 80 committed + /// probes across all five fixtures, must agree with every precommitted + /// expected answer. This is the check itself, exercised here as a unit + /// test rather than only inside the `c1_round2_text` binary, so a + /// regression in `resolve` is caught by `cargo test -p c1-egui-lyon` + /// alone. + #[test] + fn resolves_every_committed_probe_correctly() { + let fixtures_path = spike_root().join("round2-textkit/fixtures.json"); + if !fixtures_path.exists() { + eprintln!("NOT RUN: fixtures.json absent"); + return; + } + let fixtures = round2_textkit::output::load_fixtures(&fixtures_path).unwrap(); + let probes_path = spike_root().join("round2-textkit/hittest_probes.json"); + let probe_file = + round2_textkit::hittest::load_hittest_probes(&probes_path, &fixtures).unwrap(); + + let mut total = 0usize; + let mut mismatches = Vec::new(); + for ft in &probe_file.fixtures { + let rt = &fixtures + .fixtures + .iter() + .find(|f| f.id == ft.fixture_id) + .unwrap() + .resolved; + for p in &ft.probes { + total += 1; + let point = DevicePoint { + x: p.point.x, + y: p.point.y, + }; + let answer = resolve(rt, point); + if answer.source_offset != p.expected_source_offset + || answer.affinity != p.expected_affinity + { + mismatches.push(format!( + "{}: {} -> got (offset {}, {:?}), expected (offset {}, {:?})", + ft.fixture_id, + p.source_grapheme, + answer.source_offset, + answer.affinity, + p.expected_source_offset, + p.expected_affinity + )); + } + } + } + assert!( + mismatches.is_empty(), + "{}/{total} probes mismatched:\n{}", + mismatches.len(), + mismatches.join("\n") + ); + assert_eq!(total, 80, "the recipe measures exactly 80 committed probes"); + } + + /// Mutation-first (task requirement): a synthetic two-stop run, floor + /// resolution at the midpoint must return the FIRST stop, not the + /// nearest one — a nearest-neighbour implementation (the bug this + /// module's doc comment explicitly rejects) would return the same + /// answer on one side and disagree exactly at the midpoint's other + /// side, so this test probes both sides of the midpoint, not the tie + /// itself. + #[test] + fn floor_semantics_not_nearest_neighbour() { + let partition = vec![ + Stop { + source_offset: 0, + device_x: 0.0, + }, + Stop { + source_offset: 5, + device_x: 100.0, + }, + ]; + // Just past the midpoint (50.0) on the left: nearest-neighbour would + // still say "first stop" here too, so this alone doesn't + // distinguish the rules -- the distinguishing point is anything in + // (0, 100) at all under floor semantics, which always says "first + // stop" until x reaches 100. Assert floor holds all the way up to + // (but not including) the second stop. + assert_eq!(resolve_against(&partition, 0.0).source_offset, 0); + assert_eq!(resolve_against(&partition, 49.0).source_offset, 0); + assert_eq!(resolve_against(&partition, 50.0).source_offset, 0); + assert_eq!(resolve_against(&partition, 99.999).source_offset, 0); + assert_eq!(resolve_against(&partition, 100.0).source_offset, 5); + assert_eq!(resolve_against(&partition, 500.0).source_offset, 5); + // Before the first stop: still resolves to the first stop. + assert_eq!(resolve_against(&partition, -50.0).source_offset, 0); + } + + /// Required kill: every answer's affinity is `Downstream`, never + /// `Upstream` — this resolver has no notion of "the caret's own side" a + /// point-only query lacks (module doc comment). + #[test] + fn every_resolved_answer_is_downstream() { + let partition = vec![Stop { + source_offset: 0, + device_x: 0.0, + }]; + assert_eq!( + resolve_against(&partition, 10.0).affinity, + SpikeCaretAffinity::Downstream + ); + } +} diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/lib.rs b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/lib.rs new file mode 100644 index 0000000..fb3202c --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/lib.rs @@ -0,0 +1,37 @@ +//! Packet 2B-C1: Round 2 (text) shared modules for candidate C1 +//! (egui + lyon). +//! +//! `src/main.rs` — the Round 1 binary — is frozen evidence and does not use +//! this library; it is untouched by this packet (confirmed by `git diff` in +//! the packet's own report). This crate gains a `[lib]` target purely so +//! `src/bin/c1_round2_text.rs` and `src/bin/c1_round2_a11y.rs` can share +//! candidate-owned logic. +//! +//! ## The F3 cost-schema mapping: one `ReportPart` per whole file +//! +//! Every module below maps to exactly one `round2_candidatekit::ReportPart`, +//! and no file contributes to two parts — the rule the F3 finding fixed +//! this packet to follow, so the per-part LOC comparison against C2 is +//! actually comparable rather than an artifact of how one candidate happened +//! to split its own files. +//! +//! | module | `ReportPart` | +//! |---|---| +//! | [`glyph_outline`] | `TextRendering` | +//! | [`render_target`] | `TextRendering` | +//! | [`hit_test`] | `HitTestResolution` | +//! | [`a11y_node`] | `AccessibilityTreeConstruction` | +//! | [`a11y_app`] | `AccessibilityIntegrationWiring` | +//! | [`a11y_subprocess`] | `AccessibilityIntegrationWiring` | +//! +//! `bin/c1_round2_text.rs` and `bin/c1_round2_a11y.rs` themselves are +//! `FixtureAndReportPlumbing` — fixture/font loading, diff invocation, +//! report assembly, CLI — the two are printed in the run's own output +//! (`c1_round2_text`'s `loc_by_part` section) rather than only asserted here. + +pub mod a11y_app; +pub mod a11y_node; +pub mod a11y_subprocess; +pub mod glyph_outline; +pub mod hit_test; +pub mod render_target; diff --git a/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/render_target.rs b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/render_target.rs new file mode 100644 index 0000000..de53319 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c1-egui-lyon/src/render_target.rs @@ -0,0 +1,349 @@ +//! `ReportPart::TextRendering`, half two: the offscreen render target this +//! candidate draws Round 2 fixtures into for checks 1/2 — `egui_wgpu` +//! device/adapter setup, the MSAA/resolve texture pair, the render pass, and +//! the CPU readback. `glyph_outline.rs` is the other half (outline +//! extraction + tessellation); together they are exactly the F3 cost-schema +//! amendment's definition of this row: "outline extraction, path building, +//! tessellation/rasterization, the offscreen render target." +//! +//! Split out of `bin/c1_round2_text.rs` by the F3 fix: that file used to +//! carry this render pipeline *and* apparatus loading *and* report assembly +//! in one file, which is fine for the packet's own line total but makes the +//! per-part comparison against C2 meaningless — a file can only honestly +//! contribute to one `ReportPart`. This module is `TextRendering`, full +//! stop; `bin/c1_round2_text.rs` now only calls into it. + +use anyhow::{anyhow, Context, Result}; +use egui::epaint::{ClippedPrimitive, Primitive}; +use egui::{Pos2, Rect, TextureId}; +use egui_wgpu::wgpu; +use egui_wgpu::{Renderer, RendererOptions, ScreenDescriptor}; +use lyon_tessellation::VertexBuffers; + +use crate::glyph_outline::{glyph_outline_to_lyon_path, mesh_from_buffers, tessellate_into}; +use round2_textkit::types::SpikeResolvedText; + +/// Pin 4's offscreen target (restated as a literal, the discipline every +/// loader/emitter in this workspace uses). +pub const WIDTH: u32 = 1920; +pub const HEIGHT: u32 = 1080; +/// Matches Round 1's own C1 configuration (`main.rs`'s `MSAA`) — hardware +/// MSAA render-target attachment, GPU-resolved. +pub const MSAA: u32 = 8; +pub const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; +const GROUND: wgpu::Color = wgpu::Color { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, +}; + +pub struct GpuCtx { + device: wgpu::Device, + queue: wgpu::Queue, + renderer: Renderer, + white_tex: TextureId, + pub adapter_name: String, + pub adapter_device_type: String, +} + +pub fn build_gpu() -> Result { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::VULKAN, + ..wgpu::InstanceDescriptor::new_without_display_handle() + }); + let adapters = pollster::block_on(instance.enumerate_adapters(wgpu::Backends::VULKAN)); + if adapters.is_empty() { + return Err(anyhow!( + "NOT RUN: no Vulkan adapter enumerated — environment absence, not a candidate failure" + )); + } + // Prefer the integrated adapter (pin 4/round 4's deciding figure comes + // from the integrated adapter) when present; else take whatever + // enumerated first. Checks 1/2/4 are pixel/geometry correctness checks, + // not timed figures, so the choice is a reporting detail, not a + // methodological one — recorded in the printed report either way. + let adapter = adapters + .iter() + .find(|a| a.get_info().device_type == wgpu::DeviceType::IntegratedGpu) + .unwrap_or(&adapters[0]); + let info = adapter.get_info(); + + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("c1-round2-text"), + required_features: wgpu::Features::TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES, + required_limits: wgpu::Limits::default(), + memory_hints: wgpu::MemoryHints::default(), + trace: wgpu::Trace::Off, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + })) + .context("wgpu device request failed")?; + + let mut renderer = Renderer::new( + &device, + FORMAT, + RendererOptions { + msaa_samples: MSAA, + depth_stencil_format: None, + ..Default::default() + }, + ); + + // A 1x1 opaque-white texture registered with the renderer — an + // unregistered `TextureId` is silently skipped by egui's own draw loop + // (see Round 1's `main.rs` doc comment on `tessellate`), which would + // read as "every ink sample is background" rather than a build error. + let white = device.create_texture(&wgpu::TextureDescriptor { + label: Some("c1-round2-white-1x1"), + size: wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &white, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &[255u8, 255, 255, 255], + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4), + rows_per_image: Some(1), + }, + wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + ); + let white_view = white.create_view(&wgpu::TextureViewDescriptor::default()); + let white_tex = + renderer.register_native_texture(&device, &white_view, wgpu::FilterMode::Nearest); + + Ok(GpuCtx { + device, + queue, + renderer, + white_tex, + adapter_name: info.name.clone(), + adapter_device_type: format!("{:?}", info.device_type), + }) +} + +fn readback( + device: &wgpu::Device, + queue: &wgpu::Queue, + texture: &wgpu::Texture, + width: u32, + height: u32, +) -> Result> { + let unpadded = width * 4; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let padded = unpadded.div_ceil(align) * align; + + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("c1-round2-readback"), + size: (padded as u64) * (height as u64), + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("c1-round2-copy"), + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded), + rows_per_image: Some(height), + }, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + queue.submit([encoder.finish()]); + + let slice = buffer.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + device.poll(wgpu::PollType::wait_indefinitely())?; + rx.recv() + .map_err(|e| anyhow!("readback channel closed: {e}"))? + .map_err(|e| anyhow!("buffer map failed: {e}"))?; + + let mapped = slice.get_mapped_range(); + let mut out = Vec::with_capacity((unpadded as usize) * (height as usize)); + for row in 0..height as usize { + let start = row * padded as usize; + out.extend_from_slice(&mapped[start..start + unpadded as usize]); + } + drop(mapped); + buffer.unmap(); + Ok(out) +} + +/// Everything measured while drawing one fixture: the candidate raster, and +/// the check-2 evidence (which segments, if any, resolved to `face: None` +/// and therefore drew nothing). +pub struct FixtureDraw { + pub rgba: Vec, + pub unresolved_segments: Vec, +} + +/// Builds the whole fixture's ink as one tessellated mesh directly from +/// `rt`'s own segments/glyphs — **never** from any egui text-layout call, +/// font-fallback API, or `rustybuzz`. A segment with `face: None` (F-C's +/// uncovered Arabic letter) is skipped by construction: its own `glyphs` is +/// already empty (W3-F3 / the resolved-text invariants), so there is +/// nothing to draw and nothing to substitute — recorded in +/// `unresolved_segments` so the report can name it explicitly rather than +/// looking identical to a candidate that silently dropped it. +pub fn draw_fixture( + gpu: &mut GpuCtx, + rt: &SpikeResolvedText, + ttf_faces: &[ttf_parser::Face], +) -> Result { + let mut buffers: VertexBuffers<[f32; 2], u32> = VertexBuffers::new(); + let mut unresolved_segments = Vec::new(); + + for seg in &rt.segments { + let Some(face_idx) = seg.face else { + assert!( + seg.glyphs.is_empty(), + "an unresolved segment (face: None) must carry no glyphs — this candidate never \ + substitutes a fallback glyph for one" + ); + let text = rt + .text + .get(seg.source.start as usize..seg.source.end as usize) + .unwrap_or(""); + unresolved_segments.push(format!( + "source {}..{} ({text:?}): face resolved to None (no declared face covers this \ + span) — {} glyphs drawn, no substitution", + seg.source.start, + seg.source.end, + seg.glyphs.len() + )); + continue; + }; + let face = ttf_faces.get(face_idx as usize).ok_or_else(|| { + anyhow!( + "segment declares face {face_idx}, but only {} faces were loaded", + ttf_faces.len() + ) + })?; + let em_px = seg.size.0 * round2_textkit::DEVICE_SCALE; + for g in &seg.glyphs { + let device = round2_textkit::hittest::to_device(rt, &g.offset); + if let Some(path) = + glyph_outline_to_lyon_path(face, g.glyph_id, (device.x, device.y), em_px) + { + tessellate_into(&path, &mut buffers) + .map_err(|e| anyhow!("tessellation failed: {e}"))?; + } + // `None`: a whitespace glyph with no outline — draws nothing, + // exactly as the reference emitter's own `empty` list records. + } + } + + let mesh = mesh_from_buffers(&buffers, gpu.white_tex); + + let msaa_tex = gpu.device.create_texture(&wgpu::TextureDescriptor { + label: Some("c1-round2-msaa"), + size: wgpu::Extent3d { + width: WIDTH, + height: HEIGHT, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: MSAA, + dimension: wgpu::TextureDimension::D2, + format: FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let resolve_tex = gpu.device.create_texture(&wgpu::TextureDescriptor { + label: Some("c1-round2-resolve"), + size: wgpu::Extent3d { + width: WIDTH, + height: HEIGHT, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FORMAT, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let msaa_view = msaa_tex.create_view(&wgpu::TextureViewDescriptor::default()); + let resolve_view = resolve_tex.create_view(&wgpu::TextureViewDescriptor::default()); + + let jobs = vec![ClippedPrimitive { + clip_rect: Rect::from_min_size(Pos2::ZERO, egui::vec2(WIDTH as f32, HEIGHT as f32)), + primitive: Primitive::Mesh(mesh), + }]; + let screen = ScreenDescriptor { + size_in_pixels: [WIDTH, HEIGHT], + pixels_per_point: 1.0, + }; + + let mut encoder = gpu + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("c1-round2-encode"), + }); + let extra = gpu + .renderer + .update_buffers(&gpu.device, &gpu.queue, &mut encoder, &jobs, &screen); + { + let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("c1-round2-pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: &msaa_view, + resolve_target: Some(&resolve_view), + depth_slice: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(GROUND), + store: wgpu::StoreOp::Store, + }, + })], + depth_stencil_attachment: None, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + let mut pass = pass.forget_lifetime(); + gpu.renderer.render(&mut pass, &jobs, &screen); + } + gpu.queue + .submit(extra.into_iter().chain([encoder.finish()])); + + let rgba = readback(&gpu.device, &gpu.queue, &resolve_tex, WIDTH, HEIGHT)?; + Ok(FixtureDraw { + rgba, + unresolved_segments, + }) +} diff --git a/spikes/editor-toolkit/round1-candidates/c2-vello/Cargo.toml b/spikes/editor-toolkit/round1-candidates/c2-vello/Cargo.toml index 3e69518..630f462 100644 --- a/spikes/editor-toolkit/round1-candidates/c2-vello/Cargo.toml +++ b/spikes/editor-toolkit/round1-candidates/c2-vello/Cargo.toml @@ -21,3 +21,27 @@ vello = "0.9" pollster = "0.4" bytemuck = "1" anyhow = "1" + +# --- Packet 2B-C2: Round 2 text (spec/CONTRACT_EDITOR_T4_SPIKE.md Round 2; +# ROUND2_TEXT_RECIPE.md). Every dependency below is new over the Round 1 +# baseline (c20bc93) and is recorded, with its reason, in +# CandidateReport::cost::dependencies_added by src/bin/round2_text.rs. +round2-candidatekit = { path = "../../round2-candidatekit" } +round2-diff = { path = "../../round2-diff" } +round2-textkit = { path = "../../round2-textkit" } +# Outline extraction from the resolved face, converted to a kurbo BezPath, is +# the candidate-owned part of check 1 (task instructions) — round2-svgref's +# emitter builds SVG path strings for the *reference*, not kurbo geometry for +# a candidate, so it is deliberately not depended on here. Pinned to the same +# version round2-textkit/round2-svgref use so "this face's cmap/outline table +# says X" means the same thing everywhere in this packet. +ttf-parser = "=0.25.1" +serde_json = "1" +# Check 5 (accessibility, disqualifying): vello ships no accessibility layer +# of its own (contract, candidate set), so this is the same manual +# accesskit_winit route probe-vello's Round 0 readback already proved — +# reused here behind a real window rather than assumed. Versions match +# probe-vello's own pins exactly. +winit = "0.30" +accesskit = "0.24" +accesskit_winit = "0.33" diff --git a/spikes/editor-toolkit/round1-candidates/c2-vello/round2_report.json b/spikes/editor-toolkit/round1-candidates/c2-vello/round2_report.json new file mode 100644 index 0000000..d10d8a4 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c2-vello/round2_report.json @@ -0,0 +1,1749 @@ +{ + "candidate_id": "C2 vello 0.9 + kurbo (Round 2 text)", + "check1_faithful_consumption": "Pass", + "check2_fallback": "Pass", + "check3_bidi": { + "NotRun": "ROUND2_TEXT_RECIPE.md §1.2 (2026-07-29 ruling): check 3 is NOT RUN for every candidate, on both adapters — no Arabic-capable face is installed, and pin 9 makes an absent required face environmental NOT RUN. F-D's supplementary Hebrew/Latin bidi evidence is recorded separately and must never upgrade check 3 to PASS." + }, + "check4_hit_testing": "Pass", + "check5_accessibility": "Pass", + "check5_bus_unreachable_evidence": null, + "supplementary_f_d_bidi": "Pass", + "per_fixture_diffs": { + "F-A": { + "width": 1920, + "height": 1080, + "band_pixel_count": 52244, + "d1_pixels_outside_band_differing": 0, + "d1_pass": true, + "reference_ink_mass": 31933.196078430126, + "candidate_ink_mass": 31976.490196078663, + "d2_relative_delta": 0.0013557715157043208, + "d2_pass": true, + "reference_centroid": [ + 890.8902938917568, + 507.18029108563553 + ], + "candidate_centroid": [ + 890.7587757181873, + 507.20303433710444 + ], + "d3_delta": [ + 0.13151817356947504, + 0.022743251468909875 + ], + "d3_pass": true, + "in_band_max_abs_delta_luma": 128, + "in_band_count_delta_gt_report_threshold": 4286, + "d4_regions": [ + { + "label": "F-A seg0.glyph0 (face 0, gid 34)", + "reference_mass": 1962.933333333338, + "candidate_mass": 1970.2470588235328, + "relative_delta": 0.0037259163956297717, + "pass": true + }, + { + "label": "F-A seg0.glyph1 (face 0, gid 77)", + "reference_mass": 1126.7843137254906, + "candidate_mass": 1127.4156862745117, + "relative_delta": 0.0005603313263507999, + "pass": true + }, + { + "label": "F-A seg0.glyph2 (face 0, gid 77)", + "reference_mass": 1123.1372549019607, + "candidate_mass": 1136.8392156862762, + "relative_delta": 0.012199720670392721, + "pass": true + }, + { + "label": "F-A seg0.glyph3 (face 0, gid 70)", + "reference_mass": 1347.9568627450994, + "candidate_mass": 1352.4078431372557, + "relative_delta": 0.003302019905215551, + "pass": true + }, + { + "label": "F-A seg0.glyph4 (face 0, gid 72)", + "reference_mass": 2228.1019607843186, + "candidate_mass": 2215.4745098039234, + "relative_delta": 0.005667357779241938, + "pass": true + }, + { + "label": "F-A seg0.glyph5 (face 0, gid 83)", + "reference_mass": 1012.6588235294114, + "candidate_mass": 1020.921568627451, + "relative_delta": 0.008159455984634176, + "pass": true + }, + { + "label": "F-A seg0.glyph6 (face 0, gid 80)", + "reference_mass": 1436.8078431372576, + "candidate_mass": 1432.4274509803936, + "relative_delta": 0.003048697275551783, + "pass": true + }, + { + "label": "F-A seg0.glyph8 (face 0, gid 66)", + "reference_mass": 1406.149019607845, + "candidate_mass": 1406.2352941176491, + "relative_delta": 0.00006135516833637676, + "pass": true + }, + { + "label": "F-A seg0.glyph9 (face 0, gid 234)", + "reference_mass": 2515.3490196078515, + "candidate_mass": 2519.992156862748, + "relative_delta": 0.001845921666815148, + "pass": true + }, + { + "label": "F-A seg0.glyph10 (face 0, gid 70)", + "reference_mass": 1349.2196078431382, + "candidate_mass": 1348.9921568627458, + "relative_delta": 0.0001685796582485247, + "pass": true + }, + { + "label": "F-A seg0.glyph11 (face 0, gid 85)", + "reference_mass": 1090.1529411764707, + "candidate_mass": 1094.6941176470598, + "relative_delta": 0.004165632453083475, + "pass": true + }, + { + "label": "F-A seg0.glyph12 (face 0, gid 85)", + "reference_mass": 1090.2745098039222, + "candidate_mass": 1095.3098039215693, + "relative_delta": 0.0046183727789367345, + "pass": true + }, + { + "label": "F-A seg0.glyph13 (face 0, gid 86)", + "reference_mass": 1597.7372549019626, + "candidate_mass": 1609.674509803924, + "relative_delta": 0.007471350414679895, + "pass": true + }, + { + "label": "F-A seg0.glyph14 (face 0, gid 80)", + "reference_mass": 1438.9960784313753, + "candidate_mass": 1433.2745098039234, + "relative_delta": 0.003976083544083664, + "pass": true + }, + { + "label": "F-A seg0.glyph15 (face 0, gid 84)", + "reference_mass": 1181.8352941176502, + "candidate_mass": 1175.3803921568633, + "relative_delta": 0.005461761036343089, + "pass": true + }, + { + "label": "F-A seg0.glyph16 (face 0, gid 80)", + "reference_mass": 1437.9294117647094, + "candidate_mass": 1433.2745098039234, + "relative_delta": 0.003237225640355481, + "pass": true + }, + { + "label": "F-A seg0.glyph18 (face 0, gid 119)", + "reference_mass": 959.780392156863, + "candidate_mass": 959.7529411764707, + "relative_delta": 0.00002860131402617683, + "pass": true + }, + { + "label": "F-A seg0.glyph20 (face 0, gid 66)", + "reference_mass": 1401.8509803921577, + "candidate_mass": 1404.6000000000026, + "relative_delta": 0.0019609927490837425, + "pass": true + }, + { + "label": "F-A seg0.glyph21 (face 0, gid 77)", + "reference_mass": 1125.5294117647059, + "candidate_mass": 1126.929411764708, + "relative_delta": 0.001243859098987997, + "pass": true + }, + { + "label": "F-A seg0.glyph23 (face 0, gid 97)", + "reference_mass": 2139.2784313725547, + "candidate_mass": 2139.7490196078525, + "relative_delta": 0.00021997521612736234, + "pass": true + }, + { + "label": "F-A seg0.glyph24 (face 0, gid 79)", + "reference_mass": 1628.7137254901959, + "candidate_mass": 1637.3960784313776, + "relative_delta": 0.0053308035692820254, + "pass": true + }, + { + "label": "F-A seg0.glyph25 (face 0, gid 70)", + "reference_mass": 1347.239215686276, + "candidate_mass": 1349.9999999999998, + "relative_delta": 0.0020492161166176664, + "pass": true + } + ], + "d4_pass": true, + "d4_worst": { + "label": "F-A seg0.glyph2 (face 0, gid 77)", + "reference_mass": 1123.1372549019607, + "candidate_mass": 1136.8392156862762, + "relative_delta": 0.012199720670392721, + "pass": true + }, + "pass": true + }, + "F-B": { + "width": 1920, + "height": 1080, + "band_pixel_count": 16456, + "d1_pixels_outside_band_differing": 0, + "d1_pass": true, + "reference_ink_mass": 9836.71764705878, + "candidate_ink_mass": 9847.207843137221, + "d2_relative_delta": 0.00106643256976859, + "d2_pass": true, + "reference_centroid": [ + 407.43842537942237, + 504.8943554023082 + ], + "candidate_centroid": [ + 407.6930699575255, + 504.8968972193984 + ], + "d3_delta": [ + 0.25464457810312524, + 0.002541817090218501 + ], + "d3_pass": true, + "in_band_max_abs_delta_luma": 96, + "in_band_count_delta_gt_report_threshold": 1241, + "d4_regions": [ + { + "label": "F-B seg0.glyph0 (face 0, gid 36)", + "reference_mass": 1867.0078431372583, + "candidate_mass": 1865.7019607843133, + "relative_delta": 0.0006994519909196683, + "pass": true + }, + { + "label": "F-B seg0.glyph1 (face 0, gid 80)", + "reference_mass": 1438.5607843137286, + "candidate_mass": 1433.0235294117663, + "relative_delta": 0.0038491629706171315, + "pass": true + }, + { + "label": "F-B seg0.glyph2 (face 0, gid 83)", + "reference_mass": 1013.2980392156862, + "candidate_mass": 1020.4313725490197, + "relative_delta": 0.007039718875657539, + "pass": true + }, + { + "label": "F-B seg0.glyph3 (face 0, gid 80)", + "reference_mass": 1438.847058823533, + "candidate_mass": 1434.4078431372564, + "relative_delta": 0.0030852588946502185, + "pass": true + }, + { + "label": "F-B seg1.glyph0 (face 1, gid 1282)", + "reference_mass": 849.0705882352955, + "candidate_mass": 853.2901960784314, + "relative_delta": 0.004969678495054142, + "pass": true + }, + { + "label": "F-B seg1.glyph1 (face 1, gid 1281)", + "reference_mass": 1425.415686274511, + "candidate_mass": 1437.1921568627452, + "relative_delta": 0.008261779845438142, + "pass": true + }, + { + "label": "F-B seg1.glyph2 (face 1, gid 1280)", + "reference_mass": 1804.5176470588276, + "candidate_mass": 1803.160784313725, + "relative_delta": 0.000751925450723222, + "pass": true + } + ], + "d4_pass": true, + "d4_worst": { + "label": "F-B seg1.glyph1 (face 1, gid 1281)", + "reference_mass": 1425.415686274511, + "candidate_mass": 1437.1921568627452, + "relative_delta": 0.008261779845438142, + "pass": true + }, + "pass": true + }, + "F-C": { + "width": 1920, + "height": 1080, + "band_pixel_count": 9595, + "d1_pixels_outside_band_differing": 0, + "d1_pass": true, + "reference_ink_mass": 5757.713725490225, + "candidate_ink_mass": 5753.564705882305, + "d2_relative_delta": 0.0007206019273851708, + "d2_pass": true, + "reference_centroid": [ + 295.4923870245324, + 505.39794798725103 + ], + "candidate_centroid": [ + 295.5585188790057, + 505.37681328336237 + ], + "d3_delta": [ + 0.06613185447332626, + 0.021134703888662898 + ], + "d3_pass": true, + "in_band_max_abs_delta_luma": 96, + "in_band_count_delta_gt_report_threshold": 862, + "d4_regions": [ + { + "label": "F-C seg0.glyph0 (face 0, gid 36)", + "reference_mass": 1867.0078431372583, + "candidate_mass": 1865.7019607843133, + "relative_delta": 0.0006994519909196683, + "pass": true + }, + { + "label": "F-C seg0.glyph1 (face 0, gid 80)", + "reference_mass": 1438.5607843137286, + "candidate_mass": 1433.0235294117663, + "relative_delta": 0.0038491629706171315, + "pass": true + }, + { + "label": "F-C seg0.glyph2 (face 0, gid 83)", + "reference_mass": 1013.2980392156862, + "candidate_mass": 1020.4313725490197, + "relative_delta": 0.007039718875657539, + "pass": true + }, + { + "label": "F-C seg0.glyph3 (face 0, gid 80)", + "reference_mass": 1438.847058823533, + "candidate_mass": 1434.4078431372564, + "relative_delta": 0.0030852588946502185, + "pass": true + } + ], + "d4_pass": true, + "d4_worst": { + "label": "F-C seg0.glyph2 (face 0, gid 83)", + "reference_mass": 1013.2980392156862, + "candidate_mass": 1020.4313725490197, + "relative_delta": 0.007039718875657539, + "pass": true + }, + "pass": true + }, + "F-D": { + "width": 1920, + "height": 1080, + "band_pixel_count": 38964, + "d1_pixels_outside_band_differing": 0, + "d1_pass": true, + "reference_ink_mass": 23733.22352941099, + "candidate_ink_mass": 23750.796078431424, + "d2_relative_delta": 0.0007404198169143979, + "d2_pass": true, + "reference_centroid": [ + 707.2675298233589, + 507.4185305219683 + ], + "candidate_centroid": [ + 706.8135733902325, + 507.4434049104257 + ], + "d3_delta": [ + 0.45395643312645007, + 0.024874388457419627 + ], + "d3_pass": true, + "in_band_max_abs_delta_luma": 128, + "in_band_count_delta_gt_report_threshold": 3232, + "d4_regions": [ + { + "label": "F-D seg0.glyph0 (face 0, gid 34)", + "reference_mass": 1962.933333333338, + "candidate_mass": 1970.2470588235328, + "relative_delta": 0.0037259163956297717, + "pass": true + }, + { + "label": "F-D seg0.glyph1 (face 0, gid 77)", + "reference_mass": 1126.7843137254906, + "candidate_mass": 1127.4156862745117, + "relative_delta": 0.0005603313263507999, + "pass": true + }, + { + "label": "F-D seg0.glyph2 (face 0, gid 77)", + "reference_mass": 1123.1372549019607, + "candidate_mass": 1136.8392156862762, + "relative_delta": 0.012199720670392721, + "pass": true + }, + { + "label": "F-D seg0.glyph3 (face 0, gid 70)", + "reference_mass": 1347.9568627450994, + "candidate_mass": 1352.4078431372557, + "relative_delta": 0.003302019905215551, + "pass": true + }, + { + "label": "F-D seg0.glyph4 (face 0, gid 72)", + "reference_mass": 2228.1019607843186, + "candidate_mass": 2215.4745098039234, + "relative_delta": 0.005667357779241938, + "pass": true + }, + { + "label": "F-D seg0.glyph5 (face 0, gid 83)", + "reference_mass": 1012.6588235294114, + "candidate_mass": 1020.921568627451, + "relative_delta": 0.008159455984634176, + "pass": true + }, + { + "label": "F-D seg0.glyph6 (face 0, gid 80)", + "reference_mass": 1436.8078431372576, + "candidate_mass": 1432.4274509803936, + "relative_delta": 0.003048697275551783, + "pass": true + }, + { + "label": "F-D seg2.glyph1 (face 0, gid 68)", + "reference_mass": 1028.6627450980407, + "candidate_mass": 1023.6705882352942, + "relative_delta": 0.004853054984771197, + "pass": true + }, + { + "label": "F-D seg2.glyph2 (face 0, gid 80)", + "reference_mass": 1438.725490196082, + "candidate_mass": 1433.5372549019605, + "relative_delta": 0.003606132879048713, + "pass": true + }, + { + "label": "F-D seg2.glyph3 (face 0, gid 79)", + "reference_mass": 1629.4039215686275, + "candidate_mass": 1635.2980392156915, + "relative_delta": 0.0036173459318728373, + "pass": true + }, + { + "label": "F-D seg2.glyph5 (face 0, gid 67)", + "reference_mass": 1962.7607843137268, + "candidate_mass": 1961.5960784313743, + "relative_delta": 0.0005934018509340657, + "pass": true + }, + { + "label": "F-D seg2.glyph6 (face 0, gid 83)", + "reference_mass": 1013.5490196078433, + "candidate_mass": 1020.0549019607844, + "relative_delta": 0.006418912383200051, + "pass": true + }, + { + "label": "F-D seg2.glyph7 (face 0, gid 74)", + "reference_mass": 901.1450980392156, + "candidate_mass": 898.5843137254902, + "relative_delta": 0.0028417003202894663, + "pass": true + }, + { + "label": "F-D seg2.glyph8 (face 0, gid 80)", + "reference_mass": 1438.1529411764725, + "candidate_mass": 1432.7764705882369, + "relative_delta": 0.0037384553716779552, + "pass": true + }, + { + "label": "F-D seg1.glyph0 (face 1, gid 1282)", + "reference_mass": 851.0117647058834, + "candidate_mass": 853.0666666666667, + "relative_delta": 0.002414657524145445, + "pass": true + }, + { + "label": "F-D seg1.glyph1 (face 1, gid 1281)", + "reference_mass": 1426.3529411764723, + "candidate_mass": 1435.4549019607844, + "relative_delta": 0.006381282305068645, + "pass": true + }, + { + "label": "F-D seg1.glyph2 (face 1, gid 1280)", + "reference_mass": 1805.0784313725549, + "candidate_mass": 1801.0235294117651, + "relative_delta": 0.0022463854701905853, + "pass": true + } + ], + "d4_pass": true, + "d4_worst": { + "label": "F-D seg0.glyph2 (face 0, gid 77)", + "reference_mass": 1123.1372549019607, + "candidate_mass": 1136.8392156862762, + "relative_delta": 0.012199720670392721, + "pass": true + }, + "pass": true + }, + "F-E": { + "width": 1920, + "height": 1080, + "band_pixel_count": 27495, + "d1_pixels_outside_band_differing": 0, + "d1_pass": true, + "reference_ink_mass": 16241.003921568197, + "candidate_ink_mass": 16262.019607843147, + "d2_relative_delta": 0.0012939893602907742, + "d2_pass": true, + "reference_centroid": [ + 606.0381566289889, + 506.37877838133613 + ], + "candidate_centroid": [ + 606.2924338317501, + 506.4077932099732 + ], + "d3_delta": [ + 0.25427720276127275, + 0.02901482863705951 + ], + "d3_pass": true, + "in_band_max_abs_delta_luma": 112, + "in_band_count_delta_gt_report_threshold": 2223, + "d4_regions": [ + { + "label": "F-E seg0.glyph0 (face 0, gid 36)", + "reference_mass": 1867.0078431372583, + "candidate_mass": 1865.7019607843133, + "relative_delta": 0.0006994519909196683, + "pass": true + }, + { + "label": "F-E seg0.glyph1 (face 0, gid 66)", + "reference_mass": 1406.5058823529423, + "candidate_mass": 1404.9568627451004, + "relative_delta": 0.001101324656567097, + "pass": true + }, + { + "label": "F-E seg0.glyph2 (face 0, gid 71)", + "reference_mass": 1293.4627450980406, + "candidate_mass": 1291.156862745099, + "relative_delta": 0.0017827203463574063, + "pass": true + }, + { + "label": "F-E seg0.glyph3 (face 0, gid 198)", + "reference_mass": 1576.113725490198, + "candidate_mass": 1577.1450980392176, + "relative_delta": 0.0006543769858350831, + "pass": true + }, + { + "label": "F-E seg0.glyph5 (face 0, gid 119)", + "reference_mass": 959.780392156863, + "candidate_mass": 959.7529411764707, + "relative_delta": 0.00002860131402617683, + "pass": true + }, + { + "label": "F-E seg0.glyph7 (face 0, gid 83)", + "reference_mass": 1013.5764705882351, + "candidate_mass": 1025.7019607843142, + "relative_delta": 0.011963073875463992, + "pass": true + }, + { + "label": "F-E seg0.glyph8 (face 0, gid 70)", + "reference_mass": 1347.929411764708, + "candidate_mass": 1351.5176470588242, + "relative_delta": 0.002662035016669373, + "pass": true + }, + { + "label": "F-E seg0.glyph9 (face 0, gid 84)", + "reference_mass": 1183.2627450980413, + "candidate_mass": 1175.768627450981, + "relative_delta": 0.006333434968781389, + "pass": true + }, + { + "label": "F-E seg0.glyph10 (face 0, gid 86)", + "reference_mass": 1597.6784313725498, + "candidate_mass": 1603.2784313725497, + "relative_delta": 0.0035050858107842165, + "pass": true + }, + { + "label": "F-E seg0.glyph11 (face 0, gid 78)", + "reference_mass": 2515.4901960784387, + "candidate_mass": 2524.2117647058844, + "relative_delta": 0.003467144750173305, + "pass": true + }, + { + "label": "F-E seg0.glyph12 (face 0, gid 198)", + "reference_mass": 1554.384313725492, + "candidate_mass": 1554.4627450980395, + "relative_delta": 0.000050458160092726724, + "pass": true + } + ], + "d4_pass": true, + "d4_worst": { + "label": "F-E seg0.glyph7 (face 0, gid 83)", + "reference_mass": 1013.5764705882351, + "candidate_mass": 1025.7019607843142, + "relative_delta": 0.011963073875463992, + "pass": true + }, + "pass": true + } + }, + "hittest_probe_results": [ + { + "fixture_id": "F-A", + "point": { + "x": 209.765625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 278.173828125, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "actual_source_offset": 1, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 315.4296875, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "actual_source_offset": 2, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 364.697265625, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "actual_source_offset": 3, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 431.884765625, + "y": 540.0 + }, + "expected_source_offset": 4, + "expected_affinity": "Downstream", + "actual_source_offset": 4, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 493.75, + "y": 540.0 + }, + "expected_source_offset": 5, + "expected_affinity": "Downstream", + "actual_source_offset": 5, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 553.955078125, + "y": 540.0 + }, + "expected_source_offset": 6, + "expected_affinity": "Downstream", + "actual_source_offset": 6, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 604.8828125, + "y": 540.0 + }, + "expected_source_offset": 7, + "expected_affinity": "Downstream", + "actual_source_offset": 7, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 652.880859375, + "y": 540.0 + }, + "expected_source_offset": 8, + "expected_affinity": "Downstream", + "actual_source_offset": 8, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 704.833984375, + "y": 540.0 + }, + "expected_source_offset": 9, + "expected_affinity": "Downstream", + "actual_source_offset": 9, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 744.7265625, + "y": 540.0 + }, + "expected_source_offset": 10, + "expected_affinity": "Downstream", + "actual_source_offset": 10, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 795.3125, + "y": 540.0 + }, + "expected_source_offset": 11, + "expected_affinity": "Downstream", + "actual_source_offset": 11, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 846.826171875, + "y": 540.0 + }, + "expected_source_offset": 12, + "expected_affinity": "Downstream", + "actual_source_offset": 12, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 888.525390625, + "y": 540.0 + }, + "expected_source_offset": 13, + "expected_affinity": "Downstream", + "actual_source_offset": 13, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 947.998046875, + "y": 540.0 + }, + "expected_source_offset": 14, + "expected_affinity": "Downstream", + "actual_source_offset": 14, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1021.533203125, + "y": 540.0 + }, + "expected_source_offset": 15, + "expected_affinity": "Downstream", + "actual_source_offset": 15, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1083.59375, + "y": 540.0 + }, + "expected_source_offset": 16, + "expected_affinity": "Downstream", + "actual_source_offset": 16, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1145.703125, + "y": 540.0 + }, + "expected_source_offset": 17, + "expected_affinity": "Downstream", + "actual_source_offset": 17, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1196.630859375, + "y": 540.0 + }, + "expected_source_offset": 18, + "expected_affinity": "Downstream", + "actual_source_offset": 18, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1276.611328125, + "y": 540.0 + }, + "expected_source_offset": 19, + "expected_affinity": "Downstream", + "actual_source_offset": 19, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1356.640625, + "y": 540.0 + }, + "expected_source_offset": 22, + "expected_affinity": "Downstream", + "actual_source_offset": 22, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1404.638671875, + "y": 540.0 + }, + "expected_source_offset": 23, + "expected_affinity": "Downstream", + "actual_source_offset": 23, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1455.2734375, + "y": 540.0 + }, + "expected_source_offset": 24, + "expected_affinity": "Downstream", + "actual_source_offset": 24, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1489.892578125, + "y": 540.0 + }, + "expected_source_offset": 25, + "expected_affinity": "Downstream", + "actual_source_offset": 25, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1525.244140625, + "y": 540.0 + }, + "expected_source_offset": 26, + "expected_affinity": "Downstream", + "actual_source_offset": 26, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1563.96484375, + "y": 540.0 + }, + "expected_source_offset": 27, + "expected_affinity": "Downstream", + "actual_source_offset": 27, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1620.556640625, + "y": 540.0 + }, + "expected_source_offset": 28, + "expected_affinity": "Downstream", + "actual_source_offset": 28, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-A", + "point": { + "x": 1677.8125, + "y": 540.0 + }, + "expected_source_offset": 29, + "expected_affinity": "Downstream", + "actual_source_offset": 29, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 205.322265625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 285.64453125, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "actual_source_offset": 1, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 345.8984375, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "actual_source_offset": 2, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 406.103515625, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "actual_source_offset": 3, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 457.03125, + "y": 540.0 + }, + "expected_source_offset": 4, + "expected_affinity": "Downstream", + "actual_source_offset": 4, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 494.189453125, + "y": 540.0 + }, + "expected_source_offset": 9, + "expected_affinity": "Downstream", + "actual_source_offset": 9, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 546.142578125, + "y": 540.0 + }, + "expected_source_offset": 7, + "expected_affinity": "Downstream", + "actual_source_offset": 7, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-B", + "point": { + "x": 596.953125, + "y": 540.0 + }, + "expected_source_offset": 5, + "expected_affinity": "Downstream", + "actual_source_offset": 5, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 205.322265625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 285.64453125, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "actual_source_offset": 1, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 345.8984375, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "actual_source_offset": 2, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 406.103515625, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "actual_source_offset": 3, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 457.03125, + "y": 540.0 + }, + "expected_source_offset": 4, + "expected_affinity": "Downstream", + "actual_source_offset": 4, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-C", + "point": { + "x": 493.046875, + "y": 540.0 + }, + "expected_source_offset": 5, + "expected_affinity": "Downstream", + "actual_source_offset": 5, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 209.765625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 278.173828125, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "actual_source_offset": 1, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 315.4296875, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "actual_source_offset": 2, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 364.697265625, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "actual_source_offset": 3, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 431.884765625, + "y": 540.0 + }, + "expected_source_offset": 4, + "expected_affinity": "Downstream", + "actual_source_offset": 4, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 493.75, + "y": 540.0 + }, + "expected_source_offset": 5, + "expected_affinity": "Downstream", + "actual_source_offset": 5, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 553.955078125, + "y": 540.0 + }, + "expected_source_offset": 6, + "expected_affinity": "Downstream", + "actual_source_offset": 6, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 604.8828125, + "y": 540.0 + }, + "expected_source_offset": 7, + "expected_affinity": "Downstream", + "actual_source_offset": 7, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 642.041015625, + "y": 540.0 + }, + "expected_source_offset": 12, + "expected_affinity": "Downstream", + "actual_source_offset": 12, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 693.994140625, + "y": 540.0 + }, + "expected_source_offset": 10, + "expected_affinity": "Downstream", + "actual_source_offset": 10, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 763.232421875, + "y": 540.0 + }, + "expected_source_offset": 8, + "expected_affinity": "Downstream", + "actual_source_offset": 8, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 817.626953125, + "y": 540.0 + }, + "expected_source_offset": 14, + "expected_affinity": "Downstream", + "actual_source_offset": 14, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 862.01171875, + "y": 540.0 + }, + "expected_source_offset": 15, + "expected_affinity": "Downstream", + "actual_source_offset": 15, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 925.390625, + "y": 540.0 + }, + "expected_source_offset": 16, + "expected_affinity": "Downstream", + "actual_source_offset": 16, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 997.607421875, + "y": 540.0 + }, + "expected_source_offset": 17, + "expected_affinity": "Downstream", + "actual_source_offset": 17, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 1050.87890625, + "y": 540.0 + }, + "expected_source_offset": 18, + "expected_affinity": "Downstream", + "actual_source_offset": 18, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 1102.24609375, + "y": 540.0 + }, + "expected_source_offset": 19, + "expected_affinity": "Downstream", + "actual_source_offset": 19, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 1162.890625, + "y": 540.0 + }, + "expected_source_offset": 20, + "expected_affinity": "Downstream", + "actual_source_offset": 20, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 1206.54296875, + "y": 540.0 + }, + "expected_source_offset": 21, + "expected_affinity": "Downstream", + "actual_source_offset": 21, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-D", + "point": { + "x": 1244.90234375, + "y": 540.0 + }, + "expected_source_offset": 22, + "expected_affinity": "Downstream", + "actual_source_offset": 22, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 205.322265625, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 282.71484375, + "y": 540.0 + }, + "expected_source_offset": 1, + "expected_affinity": "Downstream", + "actual_source_offset": 1, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 335.400390625, + "y": 540.0 + }, + "expected_source_offset": 2, + "expected_affinity": "Downstream", + "actual_source_offset": 2, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 386.71875, + "y": 540.0 + }, + "expected_source_offset": 3, + "expected_affinity": "Downstream", + "actual_source_offset": 3, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 433.3984375, + "y": 540.0 + }, + "expected_source_offset": 6, + "expected_affinity": "Downstream", + "actual_source_offset": 6, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 513.37890625, + "y": 540.0 + }, + "expected_source_offset": 7, + "expected_affinity": "Downstream", + "actual_source_offset": 7, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 593.359375, + "y": 540.0 + }, + "expected_source_offset": 10, + "expected_affinity": "Downstream", + "actual_source_offset": 10, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 634.66796875, + "y": 540.0 + }, + "expected_source_offset": 11, + "expected_affinity": "Downstream", + "actual_source_offset": 11, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 690.625, + "y": 540.0 + }, + "expected_source_offset": 12, + "expected_affinity": "Downstream", + "actual_source_offset": 12, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 748.388671875, + "y": 540.0 + }, + "expected_source_offset": 13, + "expected_affinity": "Downstream", + "actual_source_offset": 13, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 814.111328125, + "y": 540.0 + }, + "expected_source_offset": 14, + "expected_affinity": "Downstream", + "actual_source_offset": 14, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 909.228515625, + "y": 540.0 + }, + "expected_source_offset": 15, + "expected_affinity": "Downstream", + "actual_source_offset": 15, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 139.9609375, + "y": 540.0 + }, + "expected_source_offset": 0, + "expected_affinity": "Downstream", + "actual_source_offset": 0, + "actual_affinity": "Downstream", + "pass": true + }, + { + "fixture_id": "F-E", + "point": { + "x": 985.72265625, + "y": 540.0 + }, + "expected_source_offset": 16, + "expected_affinity": "Downstream", + "actual_source_offset": 16, + "actual_affinity": "Downstream", + "pass": true + } + ], + "a11y_evidence": [ + { + "fixture_id": "F-A", + "platform": "at-spi2", + "observed_name": "Allegro affettuoso — al fine", + "observed_name_bytes_hex": "416c6c6567726f20616666657474756f736f20e2809420616c2066696e65", + "observed_role": "paragraph", + "prohibited_outcome": null, + "pass": true, + "notes": "a node with an accepted role ('paragraph') carries the accessible name byte-for-byte" + }, + { + "fixture_id": "F-B", + "platform": "at-spi2", + "observed_name": "Coro אבג", + "observed_name_bytes_hex": "436f726f20d790d791d792", + "observed_role": "paragraph", + "prohibited_outcome": null, + "pass": true, + "notes": "a node with an accepted role ('paragraph') carries the accessible name byte-for-byte" + }, + { + "fixture_id": "F-C", + "platform": "at-spi2", + "observed_name": "Coro ا", + "observed_name_bytes_hex": "436f726f20d8a7", + "observed_role": "paragraph", + "prohibited_outcome": null, + "pass": true, + "notes": "a node with an accepted role ('paragraph') carries the accessible name byte-for-byte" + }, + { + "fixture_id": "F-D", + "platform": "at-spi2", + "observed_name": "Allegro אבג con brio", + "observed_name_bytes_hex": "416c6c6567726f20d790d791d79220636f6e206272696f", + "observed_role": "paragraph", + "prohibited_outcome": null, + "pass": true, + "notes": "a node with an accepted role ('paragraph') carries the accessible name byte-for-byte" + }, + { + "fixture_id": "F-E", + "platform": "at-spi2", + "observed_name": "Café — resumé", + "observed_name_bytes_hex": "43616665cc8120e2809420726573756d65cc81", + "observed_role": "paragraph", + "prohibited_outcome": null, + "pass": true, + "notes": "a node with an accepted role ('paragraph') carries the accessible name byte-for-byte" + } + ], + "cost": { + "baseline_commit": "c20bc93", + "dependencies_added": [ + { + "name": "round2-candidatekit", + "version": "0.1.0 (path)", + "reason": "the shared fixture/oracle loader and report shape both Round 2 candidates report through (neutrality boundary)." + }, + { + "name": "round2-diff", + "version": "0.1.0 (path)", + "reason": "the bounded visual differential (D1-D4) check 1 is scored against." + }, + { + "name": "round2-textkit", + "version": "0.1.0 (path)", + "reason": "SpikeResolvedText and the declared-face loader/hasher (pin 9); the neutral staff->device transform (hittest::to_device)." + }, + { + "name": "ttf-parser", + "version": "0.25.1", + "reason": "candidate-owned outline extraction from the resolved face into a kurbo BezPath (task instructions) — not reused from round2-svgref, whose output is an SVG path string for the reference emitter, not kurbo geometry." + }, + { + "name": "serde_json", + "version": "1.0.151", + "reason": "serializes CandidateReport to JSON and parses a11y-verifier/verify.py's --json output." + }, + { + "name": "winit", + "version": "0.30.13", + "reason": "check 5 needs a real window on the AT-SPI bus; matches probe-vello's Round 0 pin exactly." + }, + { + "name": "accesskit", + "version": "0.24.1", + "reason": "the accessibility node/tree types this candidate builds by hand (vello ships no accessibility layer)." + }, + { + "name": "accesskit_winit", + "version": "0.33.2", + "reason": "the manual winit<->accesskit bridge; matches probe-vello's Round 0 pin exactly." + } + ], + "adapters": [ + { + "Implemented": { + "platform": "accesskit-0.24", + "notes": "the in-process accessibility tree this binary constructs by hand (Role::Window root, Role::Paragraph children) — the same tree every platform bridge below is built from. vello ships no accessibility layer, so accesskit was absent from this crate's Round 1 dependency graph at c20bc93 and is declared here directly.", + "integration_ownership": "CandidateOwned" + } + }, + { + "Implemented": { + "platform": "at-spi2", + "notes": "the round's own platform: a live window pushed through accesskit_winit 0.33 -> accesskit_unix 0.22.1, read back out-of-process by a11y-verifier/verify.py (an AT-SPI2 client via gi.repository.Atspi) for all five fixtures. See a11y_evidence in this run's report.", + "integration_ownership": "CandidateOwned" + } + }, + { + "NotBuilt": { + "platform": "aria", + "reason": "no wasm/web accesskit embedding target in this spike." + } + }, + { + "NotBuilt": { + "platform": "macos-nsaccessibility", + "reason": "no macOS runner available to this spike." + } + }, + { + "NotBuilt": { + "platform": "windows-uia", + "reason": "no Windows runner available to this spike." + } + } + ], + "integration_wiring": [ + "render.rs: a ttf_parser::OutlineBuilder implementation collecting one glyph's outline directly into a device-space kurbo::BezPath (scale + y-flip), reused per glyph across every segment/face in a fixture; one vello::Scene::fill(NonZero) call per glyph, matching the reference emitter's fill rule.", + "hittest.rs: an independent point -> (byte offset, affinity) resolver — every Downstream caret stop across every cluster, converted to device space via the shared to_device transform, sorted by device x, floor-looked-up against the query point. Does not call any of round2_textkit::hittest's probe-generation functions.", + "a11y_tree.rs: builds the accessible node content by hand — one Role::Window root plus five Role::Paragraph siblings (one per fixture), each carrying that fixture's exact source string as its accessible name. No platform/adapter code.", + "a11y_wiring.rs (AccessibilityIntegrationWiring, product-side only, H1/J2): manual accesskit_winit wiring behind a generic, verifier-agnostic surface -- run_window owns the winit event loop, the accesskit_winit::Adapter's lifecycle, window/bridge setup, and publishing a11y_tree.rs's tree, then blocks until any caller delivers a T via FinishHandle::finish and returns it. Nothing in this file names a verifier, a subprocess, or A11yRoundResult -- every line here is what a real editor shipping this stack would keep.", + "a11y_subprocess.rs (FixtureAndReportPlumbing, shared spike/report harness, H1/J2): drives a11y_wiring::run_window, spawning the worker thread whose only jobs are calling run_all_fixtures and delivering its result -- the coordination that exists solely because this spike scores itself out-of-process, moved out of the wiring file entirely. run_all_fixtures itself runs a11y-verifier/verify.py once per fixture as a subprocess, against a fresh run-unique --json output path deleted immediately before each invocation (F1), cross-checking the exit status against the json's own verdict and fixture_id fields before trusting either; requires the exact 'CHECK5: NOT RUN' prefix AND one of verify.py's approved environmental markers before treating exit 2 as bus-unreachable, checked as two independent conditions (H2); reduces all five fixtures' outcomes to the round's verdict only after every one has been attempted, so a FAIL found on any fixture always wins over a BusUnreachable found on another regardless of ordering (F2). This harness is common to both Round 2 candidates and is not part of either one's accessibility stack.", + "c2_round2_text.rs (FixtureAndReportPlumbing): orchestration, plus the check2/supplementary structural verifications (segment face-index and direction checks against the resolved data actually drawn from) that round2-candidatekit's neutrality boundary leaves to the candidate; the F3/H1 loc_by_part file mapping and its exhaustiveness guard." + ], + "loc_by_part": [ + { + "part": "TextRendering", + "lines": 342 + }, + { + "part": "HitTestResolution", + "lines": 254 + }, + { + "part": "AccessibilityTreeConstruction", + "lines": 109 + }, + { + "part": "AccessibilityIntegrationWiring", + "lines": 199 + }, + { + "part": "FixtureAndReportPlumbing", + "lines": 1925 + } + ] + } +} \ No newline at end of file diff --git a/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text.rs b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text.rs new file mode 100644 index 0000000..b03f351 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text.rs @@ -0,0 +1,1145 @@ +//! **Packet 2B-C2**: candidate C2 (vello) against Round 2's five text checks +//! (`spec/CONTRACT_EDITOR_T4_SPIKE.md` Round 2; `ROUND2_TEXT_RECIPE.md`). +//! +//! This is a **separate entry point** from `src/main.rs` (Round 1's frozen +//! evidence, untouched by this packet) — headless offscreen rendering for +//! checks 1/2/4 plus a real windowed accessibility mode for check 5, which +//! cannot be headless (`ROUND2_TEXT_RECIPE.md` §8 / task instructions: "This +//! needs a real window on the AT-SPI bus"). **This file is +//! `c2_round2_text.rs`**, not `main.rs` — `main.rs` is the untouched Round 1 +//! binary named above, and every reference in this file's own cost record +//! below to "this file" means `c2_round2_text.rs` specifically (an earlier +//! revision said `main.rs` in two places by mistake; both are review +//! findings F5, fixed here). +//! +//! Every rendering, hit-test-resolution, and accessibility decision in this +//! binary is candidate-owned (`round2-candidatekit`'s neutrality boundary, +//! its own module doc comment): this file and its `c2_round2_text/` +//! submodules never call into `round2-svgref` (the reference emitter) or +//! `round2_textkit::hittest`'s probe-*generation* functions — see +//! `c2_round2_text/render.rs` and `c2_round2_text/hittest.rs` for exactly +//! why. +//! +//! ## F3/H1 — the cost-table LOC categories, and why they are whole files +//! +//! Every [`ReportPart`] below maps to a **disjoint set of whole files** — +//! `loc_by_part_files` (in `main`) states the mapping once, prints it, and +//! [`assert_loc_by_part_is_exhaustive`] fails loudly if any `.rs` file under +//! this binary's Round 2 sources is unclaimed or claimed twice. +//! [`CostRecord::loc_by_part`] is `wc -l` over exactly the claimed files: +//! +//! | part | file(s) | +//! |---|---| +//! | `TextRendering` | `c2_round2_text/render.rs` | +//! | `HitTestResolution` | `c2_round2_text/hittest.rs` | +//! | `AccessibilityTreeConstruction` | `c2_round2_text/a11y_tree.rs` | +//! | `AccessibilityIntegrationWiring` | `c2_round2_text/a11y_wiring.rs` | +//! | `FixtureAndReportPlumbing` | `c2_round2_text.rs` (this file), `c2_round2_text/a11y_subprocess.rs` | +//! +//! No file contributes to two rows, and `FixtureAndReportPlumbing` is +//! legitimately backed by **two** files — the rule is "disjoint", not "one +//! file per part": a part may be the sum of several files, as long as no +//! file is counted under more than one part. +//! +//! **H1 ruling: the `verify.py` subprocess harness is not +//! `AccessibilityIntegrationWiring`.** `a11y_wiring.rs` used to also own +//! running the verifier, decoding its output, and reducing five fixtures' +//! outcomes to one — but that harness exists identically for both Round 2 +//! candidates and is not part of either one's accessibility *stack*, so it +//! is `FixtureAndReportPlumbing` (shared spike/report plumbing), not +//! wiring. It is now `a11y_subprocess.rs`: verifier subprocesses, result +//! decoding/reduction, bus-unreachable evidence, and temporary/canonical +//! evidence-file handling. `a11y_wiring.rs` keeps only the product-side +//! path — adapter lifecycle, event loop, window/bridge setup, tree +//! publication. (Earlier still, before F3, both of those were one file with +//! `a11y_tree.rs`'s tree-construction content mixed in too, split by a +//! hand-picked LOC constant; that estimate is gone, replaced first by the +//! `a11y_tree.rs`/`a11y_wiring.rs` file boundary and now by this second one.) + +#[path = "c2_round2_text/a11y_subprocess.rs"] +mod a11y_subprocess; +#[path = "c2_round2_text/a11y_tree.rs"] +mod a11y_tree; +#[path = "c2_round2_text/a11y_wiring.rs"] +mod a11y_wiring; +#[path = "c2_round2_text/hittest.rs"] +mod hittest; +#[path = "c2_round2_text/render.rs"] +mod render; + +use std::collections::{BTreeMap, BTreeSet}; +use std::path::PathBuf; + +use anyhow::{anyhow, Result}; + +use round2_candidatekit::report::{ + AdapterStatus, CostRecord, DependencyDelta, DiffReportRecord, HitTestProbeResult, + IntegrationOwnership, LocByPart, ReportPart, +}; +use round2_candidatekit::{CandidateReport, CheckOutcome}; +use round2_diff::DiffReport; +use round2_textkit::faces::{resolve_declared_chain, FaceResolution, LoadedFace}; +use round2_textkit::types::{SpikeResolvedText, SpikeTextDirection}; + +use crate::a11y_subprocess::A11yRoundResult; + +/// The Round 1 baseline commit this packet's cost delta is measured against +/// (task instructions). +const BASELINE_COMMIT: &str = "c20bc93"; + +/// `CARGO_MANIFEST_DIR` is `.../spikes/editor-toolkit/round1-candidates/c2-vello`; +/// the spike workspace root is two levels up. +fn spike_root() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .expect("spike root must exist") +} + +/// Check 1 (faithful consumption): the criterion cell is the worst of all +/// five fixtures' bounded visual differential (`ROUND2_TEXT_RECIPE.md` §10). +fn check1_outcome(diffs: &BTreeMap) -> Result { + let mut failing = Vec::new(); + for (id, d) in diffs { + if !d.pass() { + let mut rules = Vec::new(); + if !d.d1_pass { + rules.push("D1"); + } + if !d.d2_pass { + rules.push("D2"); + } + if let Some(false) = d.d3_pass { + rules.push("D3"); + } + if !d.d4_pass { + rules.push("D4"); + } + failing.push(format!("{id} ({})", rules.join(","))); + } + } + if failing.is_empty() { + Ok(CheckOutcome::Pass) + } else { + CheckOutcome::fail(format!( + "bounded visual differential (recipe §10) FAILed for: {}", + failing.join("; ") + )) + .map_err(|e| anyhow!(e)) + } +} + +/// Check 2 (fallback, forced — DISQUALIFYING): F-C's U+0627 is covered by +/// neither declared face and must be **reported**, never substituted; F-B +/// must actually traverse the declared chain (Latin head on face 0, Hebrew +/// tail on face 1), never host-substitute. Both are checked directly against +/// the resolved data this binary drew from (never re-derived from the +/// source text), plus the two fixtures' own bounded-differential result. +fn check2_outcome( + resolved_by_id: &BTreeMap<&str, &SpikeResolvedText>, + diffs: &BTreeMap, +) -> Result { + let f_c = *resolved_by_id + .get("F-C") + .ok_or_else(|| anyhow!("F-C missing from loaded fixtures"))?; + let unresolved: Vec<_> = f_c.segments.iter().filter(|s| s.face.is_none()).collect(); + if unresolved.len() != 1 { + return CheckOutcome::fail(format!( + "F-C: expected exactly one unresolved (face=None) segment, found {}", + unresolved.len() + )) + .map_err(|e| anyhow!(e)); + } + let seg = unresolved[0]; + if !seg.glyphs.is_empty() { + return CheckOutcome::fail( + "F-C: the unresolved segment carries glyphs — this consumer must never substitute a \ + fallback/`.notdef` glyph for an uncovered codepoint" + .to_string(), + ) + .map_err(|e| anyhow!(e)); + } + let range = seg.source.start as usize..seg.source.end as usize; + let uncovered_text = f_c.text.get(range.clone()).ok_or_else(|| { + anyhow!("F-C: unresolved segment source range is not on a UTF-8 boundary") + })?; + // Surfaced explicitly, per task instructions: "a candidate that silently + // draws nothing looks identical to one that correctly reported, and the + // check is about which of those you did." + println!( + "check 2 (fallback, forced): F-C reports an UNCOVERED span — byte {}..{} = {uncovered_text:?} \ + ({} codepoint(s)), resolved in NEITHER declared face. This binary drew NO ink for it and \ + substituted NOTHING — SpikeShapedSegment::glyphs is empty by construction for a face:None \ + segment (shaping is never attempted against a face that cannot represent the codepoint).", + seg.source.start, + seg.source.end, + uncovered_text.chars().count() + ); + + let f_b = *resolved_by_id + .get("F-B") + .ok_or_else(|| anyhow!("F-B missing from loaded fixtures"))?; + let f_b_faces: Vec> = f_b.segments.iter().map(|s| s.face).collect(); + if f_b_faces != [Some(0), Some(1)] { + return CheckOutcome::fail(format!( + "F-B: expected two segments resolved to faces [Some(0), Some(1)] (Latin head on face \ + 0, Hebrew tail on face 1 — the declared fallback traversal), got {f_b_faces:?}" + )) + .map_err(|e| anyhow!(e)); + } + println!( + "check 2 (fallback, forced): F-B traverses the declared chain — segment 0 -> face 0 \ + (Latin), segment 1 -> face 1 (Hebrew). No host substitution." + ); + + let d_c = diffs.get("F-C").expect("F-C diff already computed"); + if !d_c.pass() { + return CheckOutcome::fail(format!( + "F-C rendering diverged from the reference (bounded visual differential FAILed) — \ + worst region {:?}", + d_c.d4_worst + )) + .map_err(|e| anyhow!(e)); + } + let d_b = diffs.get("F-B").expect("F-B diff already computed"); + if !d_b.pass() { + return CheckOutcome::fail(format!( + "F-B rendering diverged from the reference (bounded visual differential FAILed) — \ + worst region {:?}", + d_b.d4_worst + )) + .map_err(|e| anyhow!(e)); + } + + Ok(CheckOutcome::Pass) +} + +/// Check 4 (hit testing): resolves every committed probe against this +/// binary's own `hittest::resolve_hit`, and records every result, pass and +/// fail (task instructions: "Record every probe result, pass and fail"). +fn check4_outcome( + probes: &round2_textkit::hittest::HitTestProbeFile, + resolved_by_id: &BTreeMap<&str, &SpikeResolvedText>, +) -> Result<(CheckOutcome, Vec)> { + let mut results = Vec::new(); + let mut fail_count = 0usize; + for table in &probes.fixtures { + let rt = *resolved_by_id + .get(table.fixture_id.as_str()) + .ok_or_else(|| anyhow!("{}: missing resolved text", table.fixture_id))?; + for probe in &table.probes { + let (actual_source_offset, actual_affinity) = hittest::resolve_hit(rt, &probe.point); + let pass = actual_source_offset == probe.expected_source_offset + && actual_affinity == probe.expected_affinity; + if !pass { + fail_count += 1; + } + results.push(HitTestProbeResult { + fixture_id: table.fixture_id.clone(), + point: probe.point, + expected_source_offset: probe.expected_source_offset, + expected_affinity: probe.expected_affinity, + actual_source_offset, + actual_affinity, + pass, + }); + } + } + let outcome = if fail_count == 0 { + CheckOutcome::Pass + } else { + CheckOutcome::fail(format!( + "{fail_count}/{} hit-test probes FAILed (recipe §7)", + results.len() + )) + .map_err(|e| anyhow!(e))? + }; + Ok((outcome, results)) +} + +/// F-D's supplementary bidi row (`ROUND2_TEXT_RECIPE.md` §1.2) — never +/// reaches the check 3 criterion cell (`round2_candidatekit::scoring` reads +/// only `check3_bidi`, which this binary always reports as `NotRun` per the +/// standing ruling). Verified structurally against the resolved segments +/// themselves (three segments, faces [0,1,0], directions [Ltr,Rtl,Ltr]) plus +/// F-D's own bounded-differential result. +fn supplementary_f_d_outcome( + f_d: &SpikeResolvedText, + diff_f_d: &DiffReport, +) -> Result { + let faces: Vec> = f_d.segments.iter().map(|s| s.face).collect(); + if faces != [Some(0), Some(1), Some(0)] { + return CheckOutcome::fail(format!( + "F-D: expected segment faces [Some(0), Some(1), Some(0)] (outer Latin, inner Hebrew), \ + got {faces:?}" + )) + .map_err(|e| anyhow!(e)); + } + let dirs: Vec = f_d.segments.iter().map(|s| s.direction).collect(); + if dirs + != [ + SpikeTextDirection::Ltr, + SpikeTextDirection::Rtl, + SpikeTextDirection::Ltr, + ] + { + return CheckOutcome::fail(format!( + "F-D: expected segment directions [Ltr, Rtl, Ltr] (three visual runs at levels \ + 0/1/0), got {dirs:?}" + )) + .map_err(|e| anyhow!(e)); + } + if !diff_f_d.pass() { + return CheckOutcome::fail(format!( + "F-D rendering diverged from the reference (bounded visual differential FAILed) — \ + worst region {:?}", + diff_f_d.d4_worst + )) + .map_err(|e| anyhow!(e)); + } + Ok(CheckOutcome::Pass) +} + +fn count_file_lines(path: &std::path::Path) -> u64 { + std::fs::read_to_string(path) + .map(|s| s.lines().count() as u64) + .unwrap_or(0) +} + +/// J1: aggregates one `(part, lines)` pair per *file* into exactly one row +/// per distinct `ReportPart`, order-preserving (first-seen order) — the fix +/// for the serialized report carrying two rows for the same part once a +/// part gained a second contributing file (H1's split gave +/// `FixtureAndReportPlumbing` two: `a11y_subprocess.rs` and this file). +/// +/// `ReportPart` derives neither `Ord` nor `Hash`, so this aggregates by +/// linear scan (`==`, from its `PartialEq` derive) rather than a +/// `BTreeMap`/`HashMap` key — fine at this scale (five parts, at most a +/// handful of files each). +fn aggregate_loc_by_part(per_file: &[(ReportPart, u64)]) -> Vec { + let mut aggregated: Vec<(ReportPart, u64)> = Vec::new(); + for (part, lines) in per_file { + match aggregated.iter_mut().find(|(p, _)| p == part) { + Some((_, total)) => *total += lines, + None => aggregated.push((part.clone(), *lines)), + } + } + aggregated + .into_iter() + .map(|(part, lines)| LocByPart { part, lines }) + .collect() +} + +/// The F3/H1 file-to-part mapping, stated once. `src_dir` is +/// `src/bin/c2_round2_text/`; `this_file` is `src/bin/c2_round2_text.rs` +/// itself. +fn loc_by_part_files( + src_dir: &std::path::Path, + this_file: &std::path::Path, +) -> Vec<(ReportPart, PathBuf)> { + vec![ + (ReportPart::TextRendering, src_dir.join("render.rs")), + (ReportPart::HitTestResolution, src_dir.join("hittest.rs")), + ( + ReportPart::AccessibilityTreeConstruction, + src_dir.join("a11y_tree.rs"), + ), + ( + ReportPart::AccessibilityIntegrationWiring, + src_dir.join("a11y_wiring.rs"), + ), + ( + ReportPart::FixtureAndReportPlumbing, + src_dir.join("a11y_subprocess.rs"), + ), + ( + ReportPart::FixtureAndReportPlumbing, + this_file.to_path_buf(), + ), + ] +} + +/// H1's exhaustiveness guard: every `.rs` file actually present under this +/// binary's Round 2 sources (`src_dir`'s contents, plus `this_file`) must be +/// claimed by [`loc_by_part_files`] **exactly once** — never zero times +/// (silently uncounted cost), never twice (double-counted cost). Panics, +/// naming every file that violates either half, rather than silently +/// under- or over-reporting the table C1's identical split is compared +/// against. +fn assert_loc_by_part_is_exhaustive( + claimed: &[(ReportPart, PathBuf)], + src_dir: &std::path::Path, + this_file: &std::path::Path, +) { + let mut claim_count: BTreeMap = BTreeMap::new(); + for (_part, path) in claimed { + *claim_count.entry(path.clone()).or_insert(0) += 1; + } + let claimed_set: BTreeSet = claim_count.keys().cloned().collect(); + + let mut actual: BTreeSet = BTreeSet::new(); + actual.insert(this_file.to_path_buf()); + for entry in std::fs::read_dir(src_dir) + .unwrap_or_else(|e| panic!("{}: could not list Round 2 sources: {e}", src_dir.display())) + { + let entry = entry.expect("readable directory entry"); + let path = entry.path(); + if path.extension().and_then(|e| e.to_str()) == Some("rs") { + actual.insert(path); + } + } + + let unclaimed: Vec<&PathBuf> = actual.difference(&claimed_set).collect(); + let nonexistent_claims: Vec<&PathBuf> = claimed_set.difference(&actual).collect(); + let double_claimed: Vec<(&PathBuf, u32)> = claim_count + .iter() + .filter(|(_, &n)| n > 1) + .map(|(p, &n)| (p, n)) + .collect(); + + if !unclaimed.is_empty() || !nonexistent_claims.is_empty() || !double_claimed.is_empty() { + panic!( + "loc_by_part is not exhaustive/disjoint over this binary's Round 2 sources:\n \ + unclaimed files (exist on disk, no ReportPart claims them): {unclaimed:?}\n \ + claimed files that do not exist on disk: {nonexistent_claims:?}\n \ + files claimed by more than one ReportPart entry: {double_claimed:?}" + ); + } +} + +fn main() -> Result<()> { + let spike_root = spike_root(); + println!("== Packet 2B-C2: C2 (vello) against Round 2's five text checks =="); + println!("spike root: {}", spike_root.display()); + + // ---- Load the candidate-neutral apparatus (fixtures, hit-test probes, + // reference rasters + D4 regions) — Packet 2A/2B-0, frozen, read-only. ---- + let inputs = round2_candidatekit::load_all(&spike_root).map_err(|e| anyhow!(e))?; + println!( + "loaded {} fixtures, {} reference rasters, {} hit-test probes", + inputs.fixtures.fixtures.len(), + inputs.reference.len(), + inputs + .hittest_probes + .fixtures + .iter() + .map(|f| f.probes.len()) + .sum::() + ); + + // ---- Resolve the declared face chain (pin 9): neutral geometry/data — + // "faces come from round2_textkit::faces" (task instructions). ---- + let resolved_faces = resolve_declared_chain(); + let mut faces: Vec = Vec::with_capacity(resolved_faces.len()); + for r in resolved_faces { + match r { + FaceResolution::Loaded(lf) => faces.push(lf), + FaceResolution::Missing { path } => { + // pin 14: an absent required face is an environment absence, + // never a candidate failure — this run cannot proceed at all. + anyhow::bail!( + "NOT RUN: declared face missing at {} — every check in this round requires \ + the pin-9 declared chain (environment absence, not a candidate defect)", + path.display() + ); + } + } + } + println!("resolved {} declared faces", faces.len()); + + let digest = round2_textkit::output::expected_artifact_digest().to_string(); + + // ---- Checks 1, 2 (rendering half), 4's data: render every fixture + // offscreen and diff against the frozen reference raster. ---- + let mut gpu = render::init_gpu()?; + println!( + "GPU: {} ({}), Vulkan", + gpu.adapter_name, gpu.adapter_device_type + ); + + let mut diffs: BTreeMap = BTreeMap::new(); + let mut per_fixture_diffs: BTreeMap = BTreeMap::new(); + let mut resolved_by_id: BTreeMap<&str, &SpikeResolvedText> = BTreeMap::new(); + for f in &inputs.fixtures.fixtures { + resolved_by_id.insert(f.id.as_str(), &f.resolved); + } + + for f in &inputs.fixtures.fixtures { + let candidate_rgba = render::render_fixture(&mut gpu, &f.resolved, &faces)?; + let reference = inputs + .reference + .get(&f.id) + .ok_or_else(|| anyhow!("{}: no reference fixture loaded", f.id))?; + let report = round2_diff::diff( + &reference.reference_rgba, + &candidate_rgba, + round2_candidatekit::inputs::WIDTH, + round2_candidatekit::inputs::HEIGHT, + &reference.regions, + ) + .map_err(|e| anyhow!("{}: diff failed: {e}", f.id))?; + println!( + "{}: d1={} d2={:.4}% d3={:?} d4_worst={:?} pass={}", + f.id, + report.d1_pixels_outside_band_differing, + report.d2_relative_delta * 100.0, + report.d3_delta, + report + .d4_worst + .as_ref() + .map(|w| (w.label.clone(), w.relative_delta)), + report.pass() + ); + per_fixture_diffs.insert(f.id.clone(), DiffReportRecord::from(&report)); + diffs.insert(f.id.clone(), report); + } + + let check1 = check1_outcome(&diffs)?; + let check2 = check2_outcome(&resolved_by_id, &diffs)?; + let check3 = CheckOutcome::not_run(round2_candidatekit::scoring::CHECK_3_RULING) + .map_err(|e| anyhow!(e))?; + let (check4, hittest_probe_results) = check4_outcome(&inputs.hittest_probes, &resolved_by_id)?; + let supplementary_f_d_bidi = supplementary_f_d_outcome( + resolved_by_id["F-D"], + diffs.get("F-D").expect("F-D diff computed"), + )?; + + println!("check1 (faithful consumption): {check1:?}"); + println!("check2 (fallback, forced): {check2:?}"); + println!("check3 (bidi): {check3:?}"); + println!( + "check4 (hit testing): {check4:?} ({} probes total)", + hittest_probe_results.len() + ); + println!("supplementary F-D bidi: {supplementary_f_d_bidi:?}"); + + // ---- Check 5 (accessibility, disqualifying): a real window on the + // AT-SPI bus, scored by the committed out-of-process verifier. ---- + let mut fixture_texts: [String; 5] = Default::default(); + for (slot, id) in a11y_subprocess::FIXTURE_ORDER.iter().enumerate() { + fixture_texts[slot] = resolved_by_id[*id].text.clone(); + } + let a11y_result = a11y_subprocess::run_a11y_round(&spike_root, &digest, fixture_texts)?; + let (check5, check5_bus_unreachable_evidence, a11y_evidence) = match a11y_result { + A11yRoundResult::Scored(evidence) => { + for e in &evidence { + println!( + "check5 {}: {} role={:?} name={:?} prohibited={:?} — {}", + e.fixture_id, + if e.pass { "PASS" } else { "FAIL" }, + e.observed_role, + e.observed_name, + e.prohibited_outcome, + e.notes + ); + } + let failing: Vec = evidence + .iter() + .filter(|e| !e.pass) + .map(|e| { + format!( + "{} ({})", + e.fixture_id, + e.prohibited_outcome + .clone() + .unwrap_or_else(|| "unnamed divergence".to_string()) + ) + }) + .collect(); + let outcome = if failing.is_empty() { + CheckOutcome::Pass + } else { + CheckOutcome::fail(format!("check 5 FAILed for: {}", failing.join("; "))) + .map_err(|e| anyhow!(e))? + }; + (outcome, None, evidence) + } + // F2: reaching this arm already means `a11y_subprocess::reduce_outcomes` + // found no FAIL anywhere among the fixtures it *did* score — a FAIL + // observed on any fixture, in either order relative to this + // BusUnreachable, is reported through the `Scored` arm above + // instead, never here. `partial_scored` still carries whatever was + // observed before/around the bus issue, so it is not discarded. + A11yRoundResult::BusUnreachable { + evidence: ev, + partial_scored, + } => { + println!( + "check5: NOT RUN — AT-SPI bus unreachable mid-run (probe: {}); {} fixture(s) \ + scored before the bus issue, none of them FAILed", + ev.probe_description, + partial_scored.len() + ); + let outcome = CheckOutcome::not_run(format!( + "AT-SPI bus unreachable mid-run: {}", + ev.probe_output + )) + .map_err(|e| anyhow!(e))?; + (outcome, Some(ev), partial_scored) + } + }; + println!("check5 (accessibility): {check5:?}"); + + // ---- Cost record. ---- + // F3/H1: every ReportPart maps to a disjoint set of whole files (see + // this file's own module doc comment for the table) -- printed here so + // the mapping itself, not just the resulting counts, can be checked, and + // checked exhaustively against what is actually on disk. + let src_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/bin/c2_round2_text"); + let this_file = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/bin/c2_round2_text.rs"); + let loc_by_part_files = loc_by_part_files(&src_dir, &this_file); + assert_loc_by_part_is_exhaustive(&loc_by_part_files, &src_dir, &this_file); + println!("loc_by_part file mapping (F3/H1 -- disjoint, exhaustive, per file):"); + let mut per_file_lines: Vec<(ReportPart, u64)> = Vec::with_capacity(loc_by_part_files.len()); + for (part, path) in &loc_by_part_files { + let lines = count_file_lines(path); + println!(" {part:?} <- {} ({lines} lines)", path.display()); + per_file_lines.push((part.clone(), lines)); + } + // J1: the *serialized* loc_by_part is one aggregated row per ReportPart + // -- the printed per-file mapping above stays as the auditable detail, + // but the report itself must not carry two rows for the same part + // (this file used to, for FixtureAndReportPlumbing, once it gained a + // second contributing file in H1's split) forcing every reader to know + // to sum them. + let loc_by_part = aggregate_loc_by_part(&per_file_lines); + println!("loc_by_part serialized rows (J1 -- one row per ReportPart):"); + for row in &loc_by_part { + println!(" {:?}: {} lines", row.part, row.lines); + } + + let cost = CostRecord { + baseline_commit: BASELINE_COMMIT.to_string(), + dependencies_added: vec![ + DependencyDelta { + name: "round2-candidatekit".to_string(), + version: "0.1.0 (path)".to_string(), + reason: "the shared fixture/oracle loader and report shape both Round 2 \ + candidates report through (neutrality boundary)." + .to_string(), + }, + DependencyDelta { + name: "round2-diff".to_string(), + version: "0.1.0 (path)".to_string(), + reason: "the bounded visual differential (D1-D4) check 1 is scored against." + .to_string(), + }, + DependencyDelta { + name: "round2-textkit".to_string(), + version: "0.1.0 (path)".to_string(), + reason: "SpikeResolvedText and the declared-face loader/hasher (pin 9); the \ + neutral staff->device transform (hittest::to_device)." + .to_string(), + }, + DependencyDelta { + name: "ttf-parser".to_string(), + version: "0.25.1".to_string(), + reason: "candidate-owned outline extraction from the resolved face into a kurbo \ + BezPath (task instructions) — not reused from round2-svgref, whose output is \ + an SVG path string for the reference emitter, not kurbo geometry." + .to_string(), + }, + DependencyDelta { + name: "serde_json".to_string(), + version: "1.0.151".to_string(), + reason: "serializes CandidateReport to JSON and parses a11y-verifier/verify.py's \ + --json output." + .to_string(), + }, + DependencyDelta { + name: "winit".to_string(), + version: "0.30.13".to_string(), + reason: "check 5 needs a real window on the AT-SPI bus; matches probe-vello's \ + Round 0 pin exactly." + .to_string(), + }, + DependencyDelta { + name: "accesskit".to_string(), + version: "0.24.1".to_string(), + reason: "the accessibility node/tree types this candidate builds by hand (vello \ + ships no accessibility layer)." + .to_string(), + }, + DependencyDelta { + name: "accesskit_winit".to_string(), + version: "0.33.2".to_string(), + reason: "the manual winit<->accesskit bridge; matches probe-vello's Round 0 pin \ + exactly." + .to_string(), + }, + ], + adapters: vec![ + AdapterStatus::Implemented { + platform: "accesskit-0.24".to_string(), + notes: "the in-process accessibility tree this binary constructs by hand \ + (Role::Window root, Role::Paragraph children) — the same tree every platform \ + bridge below is built from. vello ships no accessibility layer, so accesskit \ + was absent from this crate's Round 1 dependency graph at c20bc93 and is \ + declared here directly." + .to_string(), + integration_ownership: IntegrationOwnership::CandidateOwned, + }, + AdapterStatus::Implemented { + platform: "at-spi2".to_string(), + notes: format!( + "the round's own platform: a live window pushed through accesskit_winit \ + 0.33 -> accesskit_unix 0.22.1, read back out-of-process by \ + a11y-verifier/verify.py (an AT-SPI2 client via gi.repository.Atspi) for all \ + five fixtures. See {} in this run's report.", + "a11y_evidence" + ), + integration_ownership: IntegrationOwnership::CandidateOwned, + }, + AdapterStatus::NotBuilt { + platform: "aria".to_string(), + reason: "no wasm/web accesskit embedding target in this spike.".to_string(), + }, + AdapterStatus::NotBuilt { + platform: "macos-nsaccessibility".to_string(), + reason: "no macOS runner available to this spike.".to_string(), + }, + AdapterStatus::NotBuilt { + platform: "windows-uia".to_string(), + reason: "no Windows runner available to this spike.".to_string(), + }, + ], + integration_wiring: vec![ + "render.rs: a ttf_parser::OutlineBuilder implementation collecting one glyph's \ + outline directly into a device-space kurbo::BezPath (scale + y-flip), reused per \ + glyph across every segment/face in a fixture; one vello::Scene::fill(NonZero) \ + call per glyph, matching the reference emitter's fill rule." + .to_string(), + "hittest.rs: an independent point -> (byte offset, affinity) resolver — every \ + Downstream caret stop across every cluster, converted to device space via the \ + shared to_device transform, sorted by device x, floor-looked-up against the \ + query point. Does not call any of round2_textkit::hittest's probe-generation \ + functions." + .to_string(), + "a11y_tree.rs: builds the accessible node content by hand — one Role::Window root \ + plus five Role::Paragraph siblings (one per fixture), each carrying that \ + fixture's exact source string as its accessible name. No platform/adapter code." + .to_string(), + "a11y_wiring.rs (AccessibilityIntegrationWiring, product-side only, H1/J2): manual \ + accesskit_winit wiring behind a generic, verifier-agnostic surface -- \ + run_window owns the winit event loop, the accesskit_winit::Adapter's \ + lifecycle, window/bridge setup, and publishing a11y_tree.rs's tree, then blocks \ + until any caller delivers a T via FinishHandle::finish and returns it. Nothing \ + in this file names a verifier, a subprocess, or A11yRoundResult -- every line \ + here is what a real editor shipping this stack would keep." + .to_string(), + "a11y_subprocess.rs (FixtureAndReportPlumbing, shared spike/report harness, H1/J2): \ + drives a11y_wiring::run_window, spawning the worker thread whose only jobs are \ + calling run_all_fixtures and delivering its result -- the coordination that \ + exists solely because this spike scores itself out-of-process, moved out of the \ + wiring file entirely. run_all_fixtures itself runs a11y-verifier/verify.py once \ + per fixture as a subprocess, against a fresh run-unique --json output path \ + deleted immediately before each invocation (F1), cross-checking the exit status \ + against the json's own verdict and fixture_id fields before trusting either; \ + requires the exact 'CHECK5: NOT RUN' prefix AND one of verify.py's approved \ + environmental markers before treating exit 2 as bus-unreachable, checked as two \ + independent conditions (H2); reduces all five fixtures' outcomes to the round's \ + verdict only after every one has been attempted, so a FAIL found on any fixture \ + always wins over a BusUnreachable found on another regardless of ordering (F2). \ + This harness is common to both Round 2 candidates and is not part of either \ + one's accessibility stack." + .to_string(), + "c2_round2_text.rs (FixtureAndReportPlumbing): orchestration, plus the \ + check2/supplementary structural verifications (segment face-index and direction \ + checks against the resolved data actually drawn from) that \ + round2-candidatekit's neutrality boundary leaves to the candidate; the F3/H1 \ + loc_by_part file mapping and its exhaustiveness guard." + .to_string(), + ], + loc_by_part, + }; + + let report = CandidateReport { + candidate_id: "C2 vello 0.9 + kurbo (Round 2 text)".to_string(), + check1_faithful_consumption: check1, + check2_fallback: check2, + check3_bidi: check3, + check4_hit_testing: check4, + check5_accessibility: check5, + check5_bus_unreachable_evidence, + supplementary_f_d_bidi, + per_fixture_diffs, + hittest_probe_results, + a11y_evidence, + cost, + }; + + let out_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("round2_report.json"); + std::fs::write(&out_path, serde_json::to_string_pretty(&report)?)?; + println!("wrote report to {}", out_path.display()); + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use round2_textkit::identity::{ + SemVerRecord, SpikeShaperId, SpikeTextShapingIdentity, SpikeUnicodeComponent, + }; + use round2_textkit::types::{ + SpikeBoundingBox, SpikeClusterMap, SpikeGlyphStyle, SpikeLanguageTag, SpikePoint, + SpikePositionedGlyph, SpikeProvenance, SpikeScriptTag, SpikeShapedSegment, SpikeStaffSpace, + SpikeTextAlign, SpikeTypedObjectId, + }; + + fn dummy_identity() -> SpikeTextShapingIdentity { + SpikeTextShapingIdentity { + faces: Vec::new(), + shaper: SpikeShaperId("rustybuzz".to_string()), + shaper_version: SemVerRecord { + major: 0, + minor: 20, + patch: 1, + }, + features: Vec::new(), + unicode_bidi: SpikeUnicodeComponent { + impl_name: "unicode-bidi".to_string(), + crate_version: "0.3.18".to_string(), + unicode_version: Some("16.0.0".to_string()), + }, + unicode_segmentation: SpikeUnicodeComponent { + impl_name: "unicode-segmentation".to_string(), + crate_version: "1.13.3".to_string(), + unicode_version: Some("17.0.0".to_string()), + }, + } + } + + fn dummy_provenance() -> SpikeProvenance { + SpikeProvenance { + source: SpikeTypedObjectId { + discriminant: 0, + canonical_bytes_hex: "00".repeat(18), + }, + synthesis: None, + dependencies: Vec::new(), + stable_id: 0, + } + } + + fn seg( + face: Option, + glyphs: Vec, + source: std::ops::Range, + direction: SpikeTextDirection, + ) -> SpikeShapedSegment { + SpikeShapedSegment { + face, + glyphs, + source, + direction, + script: SpikeScriptTag("Latn".to_string()), + language: SpikeLanguageTag(None), + size: SpikeStaffSpace(1.28), + } + } + + fn one_glyph(id: u32) -> SpikePositionedGlyph { + SpikePositionedGlyph { + glyph_id: id, + offset: SpikePoint::new(0.0, 0.0), + transform: None, + } + } + + fn rt_with_segments(text: &str, segments: Vec) -> SpikeResolvedText { + SpikeResolvedText { + provenance: dummy_provenance(), + text: text.to_string(), + shaping: dummy_identity(), + segments, + clusters: SpikeClusterMap::default(), + bounds: SpikeBoundingBox { + left: 0.0, + bottom: 0.0, + right: 1.0, + top: 1.0, + }, + reserved_box: SpikeBoundingBox { + left: 0.0, + bottom: 0.0, + right: 1.0, + top: 1.0, + }, + origin: SpikePoint::new(0.0, 0.0), + align: SpikeTextAlign::Start, + style: SpikeGlyphStyle { rgba: 0 }, + layer: 0, + } + } + + /// A minimal, honest stand-in for F-B/F-C's real resolved shape (recipe + /// §4): F-C has an unresolved (face:None, glyphs:empty) second segment; + /// F-B's second segment resolved to face 1 — exactly the two structural + /// facts `check2_outcome` verifies. + fn base_fixtures() -> BTreeMap<&'static str, SpikeResolvedText> { + let mut m = BTreeMap::new(); + m.insert( + "F-C", + rt_with_segments( + "Coro \u{0627}", + vec![ + seg(Some(0), vec![one_glyph(1)], 0..5, SpikeTextDirection::Ltr), + seg(None, vec![], 5..7, SpikeTextDirection::Rtl), + ], + ), + ); + m.insert( + "F-B", + rt_with_segments( + "Coro \u{05D0}\u{05D1}\u{05D2}", + vec![ + seg(Some(0), vec![one_glyph(1)], 0..5, SpikeTextDirection::Ltr), + seg(Some(1), vec![one_glyph(2)], 5..11, SpikeTextDirection::Rtl), + ], + ), + ); + m + } + + fn as_refs<'a>( + m: &'a BTreeMap<&'static str, SpikeResolvedText>, + ) -> BTreeMap<&'a str, &'a SpikeResolvedText> { + m.iter().map(|(k, v)| (*k, v)).collect() + } + + fn passing_diff() -> DiffReport { + DiffReport { + width: 1, + height: 1, + band_pixel_count: 0, + d1_pixels_outside_band_differing: 0, + d1_pass: true, + reference_ink_mass: 0.0, + candidate_ink_mass: 0.0, + d2_relative_delta: 0.0, + d2_pass: true, + reference_centroid: None, + candidate_centroid: None, + d3_delta: None, + d3_pass: None, + in_band_max_abs_delta_luma: 0, + in_band_count_delta_gt_report_threshold: 0, + d4_regions: Vec::new(), + d4_pass: true, + d4_worst: None, + } + } + + fn passing_diffs() -> BTreeMap { + let mut d = BTreeMap::new(); + d.insert("F-B".to_string(), passing_diff()); + d.insert("F-C".to_string(), passing_diff()); + d + } + + #[test] + fn check2_passes_on_honest_data() { + let fixtures = base_fixtures(); + let refs = as_refs(&fixtures); + let outcome = check2_outcome(&refs, &passing_diffs()).unwrap(); + assert!(outcome.is_pass(), "{outcome:?}"); + } + + /// Mutation-first: if F-C's unresolved segment carries a glyph (a + /// substituted fallback/`.notdef`, exactly what check 2 forbids), the + /// check must FAIL naming that specifically — this is the guard that + /// would catch a regression where `render.rs` started drawing something + /// for an uncovered span. + #[test] + fn check2_fails_if_the_unresolved_segment_carries_a_glyph() { + let mut fixtures = base_fixtures(); + fixtures.get_mut("F-C").unwrap().segments[1] + .glyphs + .push(one_glyph(999)); + let refs = as_refs(&fixtures); + let outcome = check2_outcome(&refs, &passing_diffs()).unwrap(); + assert!( + matches!(&outcome, CheckOutcome::Fail(r) if r.contains("carries glyphs")), + "{outcome:?}" + ); + } + + /// Mutation-first: if F-B's Hebrew segment never actually traversed to + /// the second declared face (host substitution instead of fallback), + /// the check must FAIL. + #[test] + fn check2_fails_if_f_b_never_traversed_to_the_second_face() { + let mut fixtures = base_fixtures(); + fixtures.get_mut("F-B").unwrap().segments[1].face = Some(0); + let refs = as_refs(&fixtures); + let outcome = check2_outcome(&refs, &passing_diffs()).unwrap(); + assert!(outcome.is_fail(), "{outcome:?}"); + } + + #[test] + fn check1_fails_and_names_the_fixture_when_a_diff_fails() { + let mut diffs = passing_diffs(); + let mut broken = passing_diff(); + broken.d1_pass = false; + broken.d1_pixels_outside_band_differing = 42; + diffs.insert("F-C".to_string(), broken); + let outcome = check1_outcome(&diffs).unwrap(); + assert!( + matches!(&outcome, CheckOutcome::Fail(r) if r.contains("F-C") && r.contains("D1")), + "{outcome:?}" + ); + } + + #[test] + fn check1_passes_when_every_diff_passes() { + let outcome = check1_outcome(&passing_diffs()).unwrap(); + assert!(outcome.is_pass(), "{outcome:?}"); + } + + // ---- H1/F3: the loc_by_part mapping is exhaustive and disjoint over + // the real, on-disk Round 2 sources ---- + + fn real_src_dir() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/bin/c2_round2_text") + } + + fn real_this_file() -> PathBuf { + PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("src/bin/c2_round2_text.rs") + } + + #[test] + fn the_real_mapping_is_exhaustive_and_disjoint() { + let src_dir = real_src_dir(); + let this_file = real_this_file(); + let claimed = loc_by_part_files(&src_dir, &this_file); + // Must not panic. + assert_loc_by_part_is_exhaustive(&claimed, &src_dir, &this_file); + } + + /// Required kill: a mapping missing one real file (here, dropping + /// `hittest.rs`'s claim) must be rejected, naming it as unclaimed — + /// this is the exact failure mode C1 hit ("its mapping silently omitted + /// a file") that this guard exists to catch. + #[test] + #[should_panic(expected = "unclaimed files")] + fn a_mapping_missing_a_real_file_is_rejected() { + let src_dir = real_src_dir(); + let this_file = real_this_file(); + let mut claimed = loc_by_part_files(&src_dir, &this_file); + claimed.retain(|(_, p)| p != &src_dir.join("hittest.rs")); + assert_loc_by_part_is_exhaustive(&claimed, &src_dir, &this_file); + } + + /// Required kill: a file claimed by two parts must be rejected, naming + /// it as double-claimed. + #[test] + #[should_panic(expected = "more than one ReportPart")] + fn a_file_claimed_twice_is_rejected() { + let src_dir = real_src_dir(); + let this_file = real_this_file(); + let mut claimed = loc_by_part_files(&src_dir, &this_file); + claimed.push((ReportPart::TextRendering, src_dir.join("hittest.rs"))); + assert_loc_by_part_is_exhaustive(&claimed, &src_dir, &this_file); + } + + /// Required kill: a claim naming a file that does not exist on disk + /// must be rejected too — the guard is checking the *mapping*, not just + /// counting whatever the mapping happens to list. + #[test] + #[should_panic(expected = "do not exist on disk")] + fn a_claim_naming_a_nonexistent_file_is_rejected() { + let src_dir = real_src_dir(); + let this_file = real_this_file(); + let mut claimed = loc_by_part_files(&src_dir, &this_file); + claimed.push(( + ReportPart::Other("bogus".to_string()), + src_dir.join("nope.rs"), + )); + assert_loc_by_part_is_exhaustive(&claimed, &src_dir, &this_file); + } + + // ---- J1: the serialized loc_by_part is one aggregated row per + // ReportPart, and each row's count is the true sum of its files ---- + + /// Required kill, half 1: no `ReportPart` appears twice in the + /// aggregated output, even though the input names + /// `FixtureAndReportPlumbing` twice (two files). + #[test] + fn aggregate_loc_by_part_has_no_duplicate_parts() { + let per_file = vec![ + (ReportPart::TextRendering, 342), + (ReportPart::HitTestResolution, 254), + (ReportPart::FixtureAndReportPlumbing, 736), + (ReportPart::FixtureAndReportPlumbing, 1024), + ]; + let aggregated = aggregate_loc_by_part(&per_file); + let mut seen: Vec = Vec::new(); + for row in &aggregated { + assert!( + !seen.contains(&row.part), + "{:?} appears twice in {aggregated:?}", + row.part + ); + seen.push(row.part.clone()); + } + } + + /// Required kill, half 2 — **this is the one that matters most**: each + /// row's line count is the true sum of every file mapped to it, not + /// (for example) the last file's count with earlier ones silently + /// dropped. A uniqueness check alone would pass a buggy aggregator that + /// kept only the last-seen file per part; this catches that directly. + #[test] + fn aggregate_loc_by_part_sums_are_correct_not_just_unique() { + let per_file = vec![ + (ReportPart::TextRendering, 342), + (ReportPart::HitTestResolution, 254), + (ReportPart::FixtureAndReportPlumbing, 736), + (ReportPart::FixtureAndReportPlumbing, 1024), + ]; + let aggregated = aggregate_loc_by_part(&per_file); + let find = |part: &ReportPart| aggregated.iter().find(|r| &r.part == part).unwrap(); + assert_eq!(find(&ReportPart::TextRendering).lines, 342); + assert_eq!(find(&ReportPart::HitTestResolution).lines, 254); + assert_eq!( + find(&ReportPart::FixtureAndReportPlumbing).lines, + 736 + 1024, + "must be the SUM of both contributing files, not just one of them" + ); + } + + /// The same two properties, grounded against the real on-disk mapping + /// rather than synthetic data — end-to-end proof that what `main` will + /// actually serialize is exhaustive, disjoint, and correctly summed. + #[test] + fn the_real_aggregation_has_no_duplicate_parts_and_correct_sums() { + let src_dir = real_src_dir(); + let this_file = real_this_file(); + let files = loc_by_part_files(&src_dir, &this_file); + let per_file: Vec<(ReportPart, u64)> = files + .iter() + .map(|(p, path)| (p.clone(), count_file_lines(path))) + .collect(); + let aggregated = aggregate_loc_by_part(&per_file); + + let mut seen: Vec = Vec::new(); + for row in &aggregated { + assert!(!seen.contains(&row.part), "{:?} appears twice", row.part); + seen.push(row.part.clone()); + } + + let mut expected_by_part: Vec<(ReportPart, u64)> = Vec::new(); + for (part, path) in &files { + let lines = count_file_lines(path); + match expected_by_part.iter_mut().find(|(p, _)| p == part) { + Some((_, total)) => *total += lines, + None => expected_by_part.push((part.clone(), lines)), + } + } + for (part, expected) in &expected_by_part { + let row = aggregated.iter().find(|r| &r.part == part).unwrap(); + assert_eq!(row.lines, *expected, "{part:?}"); + } + } +} diff --git a/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/a11y_subprocess.rs b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/a11y_subprocess.rs new file mode 100644 index 0000000..8e6bc10 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/a11y_subprocess.rs @@ -0,0 +1,780 @@ +//! Check 5, `ReportPart::FixtureAndReportPlumbing`: the shared spike/report +//! harness around `a11y-verifier/verify.py` — verifier subprocesses, result +//! decoding and reduction, bus-unreachable evidence, and temporary/canonical +//! evidence-file handling. +//! +//! **Ruling (H1): this is harness, not product.** `a11y-verifier/verify.py` +//! exists once, is shared by both Round 2 candidates, and is not part of +//! either candidate's own accessibility stack — running it, trusting its +//! output only when the exit status and the JSON output actually agree, and +//! reducing five per-fixture outcomes to one round verdict is exactly the +//! kind of harness plumbing `ReportPart::FixtureAndReportPlumbing` is for, +//! not `AccessibilityIntegrationWiring`. An earlier revision of this packet +//! put all of this in the same file as the winit/`accesskit_winit` adapter +//! lifecycle (`a11y_wiring.rs`) — that file is now product-only; this one is +//! everything the ruling names as harness. +//! +//! ## F1/F2 — the two review findings this file's shape enforces +//! +//! **F1 (freshness).** Every fixture's `--json` output path is unique to +//! *this run* ([`run_nonce`], mixing pid + a timestamp) and is deleted +//! immediately before its `verify.py` invocation is spawned +//! ([`run_all_fixtures`]), so a read can never see anything this run did not +//! itself write. On top of that, [`interpret_verify_output`] cross-checks +//! the exit status against the JSON's own `verdict` field and its +//! `fixture_id` field, and refuses (hard error, never silently trusts +//! either) if they disagree. +//! +//! **F2 (ordering).** [`reduce_outcomes`] is a pure function over *every* +//! fixture's outcome, decided only after all five have been attempted — a +//! disqualifying `FAIL` found on any fixture always wins over an +//! environmental `BusUnreachable` found on another, in **either** order, +//! because [`run_all_fixtures`]'s loop never short-circuits on the first +//! `BusUnreachable`. +//! +//! ## H2 — the exit-2 conjunction, and why it is two separate conditions +//! +//! Exit 2 is only accepted as [`FixtureOutcome::BusUnreachable`] when +//! **both**, independently: +//! +//! - `inv.stdout` **begins with** [`CHECK5_NOT_RUN_PREFIX`] — the exact +//! prefix `a11y-verifier/verify.py` prints for check 5's NOT RUN case, +//! never for a usage error (which prints under a distinct `usage error` +//! sentence instead — see that file's own `run_check5`); +//! - `inv.stdout` contains one of [`CHECK5_ENVIRONMENTAL_MARKERS`] — +//! transcribed verbatim from `verify.py`'s own source, not guessed, with +//! the exact call site named against each one. +//! +//! Both conditions are required, tested **separately** (each of the two +//! `interpret_exit_2_*_alone_is_a_hard_error` tests below holds the other +//! condition satisfied while breaking just the one it names), because a +//! conjunction whose two halves are only ever exercised together is not +//! actually verified — either half could be silently dropped and every +//! previously-passing test would keep passing. +//! +//! ## J2 — the worker thread and the readiness handoff moved here too +//! +//! [`run_a11y_round`] now owns spawning the worker thread that runs +//! [`run_all_fixtures`] and delivering its result — the *coordination*, +//! not the window. It drives `a11y_wiring::run_window`'s generic, +//! verifier-agnostic surface (`FinishHandle>`): the +//! callback `run_window` invokes the instant the tree is actually live does +//! nothing but spawn a thread and return immediately, so the event loop +//! (product-side, `a11y_wiring.rs`) is never blocked by this file's work. + +use std::path::{Path, PathBuf}; +use std::process::Command; + +use anyhow::{anyhow, bail, Result}; + +use round2_candidatekit::{A11yEvidence, BusUnreachableEvidence}; + +use crate::a11y_wiring::run_window; + +/// Must match the binary target name (`c2_round2_text.rs` -> Cargo target +/// `c2_round2_text`): `accesskit_unix` (the AT-SPI2 bridge `accesskit_winit` +/// uses on Linux) derives the AT-SPI *application* name from +/// `std::env::current_exe()`'s file name (`accesskit_unix::context::app_name`, +/// verified in this workspace's lockfile at +/// `accesskit_unix-0.22.1/src/context.rs:36`), and +/// `a11y-verifier/verify.py`'s `--app-name` is a substring match against +/// that. **F4**: renamed from `round2_text` to `c2_round2_text` because both +/// candidate packages produced a binary named `round2_text`, colliding in +/// the shared `target/release/` output directory. +pub const APP_NAME: &str = "c2_round2_text"; + +/// The exact five fixture ids, recipe §2 order — restated (not read back out +/// of `round2-textkit`), the same discipline every crate in this packet +/// uses so a copy built independently still agrees on the roster. +pub const FIXTURE_ORDER: [&str; 5] = ["F-A", "F-B", "F-C", "F-D", "F-E"]; + +/// **H2**: the exact prefix `a11y-verifier/verify.py` prints for every +/// check-5 `NOT RUN` case — transcribed verbatim from that file (never +/// modified here, and never guessed): every `print(f"CHECK5: NOT RUN — +/// ...")` call, plus the one call site in `main()` that prints under a +/// dynamic `{label}` that is `"CHECK5"` in check-5 mode +/// (`a11y-verifier/verify.py:1092`), all begin with exactly this text. +/// `verify.py`'s usage-error branches (a bad `--expectations`, a digest +/// mismatch, an unknown `--fixture`, ...) print under a distinct `"CHECK5: +/// usage error — ..."` sentence instead and therefore never match this +/// prefix. +const CHECK5_NOT_RUN_PREFIX: &str = "CHECK5: NOT RUN"; + +/// **H2**: every environmental-cause marker `a11y-verifier/verify.py` +/// actually prints after [`CHECK5_NOT_RUN_PREFIX`], transcribed verbatim +/// from that file's source (never guessed), one entry per call site: +/// +/// - `run_check5`, `Atspi.init()` raising: `verify.py:961` +/// (`f"CHECK5: NOT RUN — Atspi.init() failed: {exc}"`) +/// - `run_check5`, `Atspi.get_desktop(0)` raising: `verify.py:973` +/// (`f"CHECK5: NOT RUN — Atspi.get_desktop(0) failed: {exc}"`) +/// - `run_check5`, `Atspi.get_desktop(0)` returning `None`: `verify.py:976` +/// (`"CHECK5: NOT RUN — Atspi.get_desktop(0) returned None (no AT-SPI \ +/// registry?)"`) +/// - `run_check5`, `desktop.get_child_count()` raising: `verify.py:984` +/// (`f"CHECK5: NOT RUN — desktop.get_child_count() failed: {exc}"`) +/// - `main`, the `gi.repository.Atspi` import itself failing — reached +/// *before* `run_check5` even starts: `verify.py:1092` +/// (`f"{label}: NOT RUN — could not import gi.repository.Atspi: {exc}"`, +/// `label == "CHECK5"` in check-5 mode) +/// +/// Matched as a substring of `inv.stdout` *after* [`CHECK5_NOT_RUN_PREFIX`] +/// has already been confirmed present — the two checks are independent +/// (H2), so this list is consulted regardless of what precedes it in the +/// calling code, but the marker text itself never appears in any of +/// `verify.py`'s usage-error prints (`verify.py:934`, `:944`, `:953`), which +/// is what makes it a safe positive signal once the prefix is also +/// satisfied. +const CHECK5_ENVIRONMENTAL_MARKERS: &[&str] = &[ + "Atspi.init() failed", + "Atspi.get_desktop(0) failed", + "Atspi.get_desktop(0) returned None", + "desktop.get_child_count() failed", + "could not import gi.repository.Atspi", +]; + +/// The outcome of one full a11y round: either every fixture that could be +/// scored was, or the platform accessibility bus was found unreachable for +/// at least one fixture **and no fixture failed** — see [`reduce_outcomes`] +/// for why those two conditions must both hold (F2). `BusUnreachable` still +/// carries whatever fixtures *did* get scored before/around the bus issue +/// (`partial_scored`), so the report is not forced to discard real evidence +/// just because the round overall reads `NotRun`. +pub enum A11yRoundResult { + Scored(Vec), + BusUnreachable { + evidence: BusUnreachableEvidence, + partial_scored: Vec, + }, +} + +/// One fixture's outcome, before the round-level F2 reduction. +#[derive(Debug)] +enum FixtureOutcome { + Scored(A11yEvidence), + BusUnreachable(BusUnreachableEvidence), +} + +/// One `verify.py` invocation's raw result, in a form +/// [`interpret_verify_output`] can be exercised against without spawning a +/// subprocess (F1/H2's mutation tests). +struct VerifyInvocation { + fixture_id: String, + exit_code: Option, + stdout: String, + stderr: String, + /// The fresh, run-unique path this invocation was told to write its + /// `--json` output to — already deleted (if anything occupied it) + /// immediately before the subprocess was spawned. See this module's F1 + /// doc section. + json_path: PathBuf, +} + +fn evidence_from_json(fixture_id: &str, v: &serde_json::Value) -> Result { + let verdict = v + .get("verdict") + .and_then(|x| x.as_str()) + .ok_or_else(|| anyhow!("{fixture_id}: verify.py's json output is missing 'verdict'"))?; + Ok(A11yEvidence { + fixture_id: fixture_id.to_string(), + platform: "at-spi2".to_string(), + observed_name: v + .get("observed_name") + .and_then(|x| x.as_str()) + .map(str::to_string), + observed_name_bytes_hex: v + .get("observed_name_hex") + .and_then(|x| x.as_str()) + .map(str::to_string), + observed_role: v + .get("observed_role") + .and_then(|x| x.as_str()) + .map(str::to_string), + prohibited_outcome: v + .get("prohibited_outcome") + .and_then(|x| x.as_str()) + .map(str::to_string), + pass: verdict == "PASS", + notes: v + .get("reason") + .and_then(|x| x.as_str()) + .unwrap_or("") + .to_string(), + }) +} + +/// Interprets one already-completed `verify.py` invocation (F1, H2). Never +/// trusts a file's mere presence or a bare exit code alone: +/// +/// - exit 0/1: reads `inv.json_path`, and requires **both** its `verdict` +/// field to agree with what the exit code implies (`0` -> `"PASS"`, `1` +/// -> `"FAIL"`) **and** its `fixture_id` field to equal `inv.fixture_id`. +/// Either disagreement is a hard error. +/// - exit 2: refuses to treat it as [`FixtureOutcome::BusUnreachable`] unless +/// **all three**, independently: `inv.json_path` is absent (a fresh scored +/// output existing alongside an exit-2 status is a contradiction, not +/// evidence); `inv.stdout` begins with [`CHECK5_NOT_RUN_PREFIX`]; and +/// `inv.stdout` contains one of [`CHECK5_ENVIRONMENTAL_MARKERS`] (H2). +/// Every other exit-2 shape (a usage error, a digest mismatch, ...) is a +/// hard error, never silently promoted to environmental absence. +fn interpret_verify_output(inv: &VerifyInvocation) -> Result { + match inv.exit_code { + Some(0) | Some(1) => { + let code = inv.exit_code.expect("matched Some above"); + let expected_verdict = if code == 0 { "PASS" } else { "FAIL" }; + let text = std::fs::read_to_string(&inv.json_path).map_err(|e| { + anyhow!( + "{}: verify.py exited {code} but its fresh --json output at {} could not be \ + read: {e}\nstdout:\n{}", + inv.fixture_id, + inv.json_path.display(), + inv.stdout + ) + })?; + let v: serde_json::Value = serde_json::from_str(&text).map_err(|e| { + anyhow!( + "{}: failed to parse verify.py's json output: {e}", + inv.fixture_id + ) + })?; + let json_fixture_id = + v.get("fixture_id") + .and_then(|x| x.as_str()) + .ok_or_else(|| { + anyhow!( + "{}: verify.py's json output is missing 'fixture_id'", + inv.fixture_id + ) + })?; + if json_fixture_id != inv.fixture_id { + bail!( + "{}: verify.py's --json output at {} names fixture_id {json_fixture_id:?}, \ + not the fixture this invocation asked for — refusing to attribute someone \ + else's verdict", + inv.fixture_id, + inv.json_path.display() + ); + } + let verdict = v.get("verdict").and_then(|x| x.as_str()).ok_or_else(|| { + anyhow!( + "{}: verify.py's json output is missing 'verdict'", + inv.fixture_id + ) + })?; + if verdict != expected_verdict { + bail!( + "{}: verify.py exited {code} (implying {expected_verdict:?}) but its own \ + json output reports verdict {verdict:?} — exit status and json output \ + disagree, refusing to trust either", + inv.fixture_id + ); + } + Ok(FixtureOutcome::Scored(evidence_from_json( + &inv.fixture_id, + &v, + )?)) + } + Some(2) => { + if inv.json_path.exists() { + bail!( + "{}: verify.py exited 2 (usage/NOT RUN) but a fresh --json output exists at \ + {} anyway — an exit-2 run must never have written scored output, so this is \ + a contradiction rather than evidence of anything", + inv.fixture_id, + inv.json_path.display() + ); + } + // H2: the two halves of the conjunction, computed and checked + // independently -- see this module's doc comment for why they + // must never be collapsed into one combined test of "looks + // environmental". + let has_prefix = inv.stdout.starts_with(CHECK5_NOT_RUN_PREFIX); + let has_marker = CHECK5_ENVIRONMENTAL_MARKERS + .iter() + .any(|marker| inv.stdout.contains(marker)); + if has_prefix && has_marker { + return Ok(FixtureOutcome::BusUnreachable(BusUnreachableEvidence { + probe_description: format!( + "python3 a11y-verifier/verify.py --fixture {} --app-name {APP_NAME} \ + (AT-SPI2 client via gi.repository.Atspi)", + inv.fixture_id + ), + probe_output: inv.stdout.clone(), + })); + } + bail!( + "{}: verify.py exited 2 but stdout does not satisfy both required conditions \ + (has_prefix={has_prefix}, has_marker={has_marker}) — treating as a hard usage \ + error, not environmental NOT RUN: {}\n{}", + inv.fixture_id, + inv.stdout, + inv.stderr + ); + } + other => bail!( + "{}: verify.py exited with unexpected status {other:?}\nstdout:\n{}\nstderr:\n{}", + inv.fixture_id, + inv.stdout, + inv.stderr + ), + } +} + +/// Reduces every fixture's [`FixtureOutcome`] (collected in whatever order +/// they were attempted) to the round's overall result (F2). +/// +/// A disqualifying `FAIL` found on **any** fixture always wins over a +/// `BusUnreachable` found on another — an environmental `NotRun` is only +/// admissible when **nothing failed**. The caller never short-circuits on +/// the first `BusUnreachable` (see [`run_all_fixtures`]), so both orderings +/// — a fail observed before a bus issue, or after one — reach this function +/// with the same two facts and therefore produce the same verdict. +fn reduce_outcomes(outcomes: Vec) -> A11yRoundResult { + let mut scored = Vec::new(); + let mut bus_unreachable: Option = None; + for o in outcomes { + match o { + FixtureOutcome::Scored(ev) => scored.push(ev), + FixtureOutcome::BusUnreachable(ev) => { + if bus_unreachable.is_none() { + bus_unreachable = Some(ev); + } + } + } + } + let any_fail = scored.iter().any(|e| !e.pass); + match bus_unreachable { + Some(evidence) if !any_fail => A11yRoundResult::BusUnreachable { + evidence, + partial_scored: scored, + }, + _ => A11yRoundResult::Scored(scored), + } +} + +/// A per-process, per-call identifier mixed into every `--json` output path +/// this run creates (F1) — process id plus a nanosecond timestamp, cheap and +/// dependency-free. Not a cryptographic uniqueness guarantee by itself +/// (that is what the pre-spawn deletion in [`run_all_fixtures`] and the +/// exit-status/verdict/fixture-id cross-checks in +/// [`interpret_verify_output`] are for); it is the first line of defense, +/// making an accidental collision with another run's leftover file +/// vanishingly unlikely rather than structural. +fn run_nonce() -> String { + let pid = std::process::id(); + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_nanos()) + .unwrap_or(0); + format!("{pid}-{nanos}") +} + +fn fresh_json_path(run_nonce: &str, fixture_id: &str) -> PathBuf { + std::env::temp_dir().join(format!("c2-round2-a11y-{run_nonce}-{fixture_id}.json")) +} + +/// Runs `a11y-verifier/verify.py` once per fixture, against the one live +/// window `a11y_wiring.rs` builds, out-of-process — the committed verifier +/// is the only thing that ever classifies a tree (task instructions: +/// "Scoring is not yours to decide. Run the committed verifier +/// out-of-process."). +/// +/// **Never short-circuits (F2):** every fixture in [`FIXTURE_ORDER`] is +/// attempted regardless of what earlier fixtures returned, and the round's +/// overall verdict is decided once, by [`reduce_outcomes`], only after all +/// five outcomes are in hand. +fn run_all_fixtures(spike_root: &Path, digest: &str) -> Result { + let verify_py = spike_root.join("a11y-verifier/verify.py"); + let expectations = spike_root.join("round2-a11y-oracle/a11y_expectations.json"); + let nonce = run_nonce(); + let mut outcomes = Vec::with_capacity(FIXTURE_ORDER.len()); + + for fixture_id in FIXTURE_ORDER { + let json_path = fresh_json_path(&nonce, fixture_id); + // F1: never read a file this run did not write. Deleting whatever + // (if anything) already occupies this path, immediately before + // spawning, makes that a filesystem-level guarantee rather than + // something inferred from the exit code alone. + let _ = std::fs::remove_file(&json_path); + + let output = Command::new("python3") + .arg(&verify_py) + .arg("--expectations") + .arg(&expectations) + .arg("--fixture") + .arg(fixture_id) + .arg("--app-name") + .arg(APP_NAME) + .arg("--expect-source-digest") + .arg(digest) + .arg("--json") + .arg(&json_path) + .arg("--timeout") + .arg("10") + .current_dir(spike_root) + .output() + .map_err(|e| anyhow!("failed to spawn verify.py for {fixture_id}: {e}"))?; + + let inv = VerifyInvocation { + fixture_id: fixture_id.to_string(), + exit_code: output.status.code(), + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + json_path, + }; + // A hard interpretation error (exit status/json disagreement, a + // fixture-id mismatch, an unrecognized exit code, ...) is a harness + // defect, not a legitimate outcome to defer judgement on — it + // aborts the whole round immediately, unlike a `FAIL` or a + // `BusUnreachable`, both of which are trustworthy typed outcomes + // `reduce_outcomes` is free to weigh against each other. + outcomes.push(interpret_verify_output(&inv)?); + } + + Ok(reduce_outcomes(outcomes)) +} + +/// Opens the one probe window (`a11y_wiring::run_window`, product-side) and +/// scores all five fixtures against it (`run_all_fixtures`, this file), +/// then closes the window. +/// +/// **J2**: this function, not `a11y_wiring.rs`, owns the coordination — the +/// worker thread, the fact that it runs `run_all_fixtures`, and delivering +/// the result. It drives `run_window`'s generic surface with `T = +/// Result`: the callback handed to `on_tree_published` +/// does nothing but spawn a thread and return immediately (never blocking +/// the event loop), and that thread's only two jobs are calling +/// `run_all_fixtures` and calling [`crate::a11y_wiring::FinishHandle::finish`] +/// with what it got. +/// +/// `fixture_texts` must be in [`FIXTURE_ORDER`]'s order (F-A..F-E) — the +/// caller (`c2_round2_text.rs`) builds it directly from the loaded +/// `SpikeResolvedText::text` fields, never from a literal restated here, so +/// a fixture whose source string changed is exercised as it actually is. +pub fn run_a11y_round( + spike_root: &Path, + digest: &str, + fixture_texts: [String; 5], +) -> Result { + let spike_root = spike_root.to_path_buf(); + let digest = digest.to_string(); + run_window(fixture_texts, move |handle| { + std::thread::spawn(move || { + let result = run_all_fixtures(&spike_root, &digest); + handle.finish(result); + }); + })? +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scratch_json_path(name: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "c2-round2-a11y-subprocess-test-{name}-{}.json", + std::process::id() + )) + } + + fn write_json(path: &Path, body: &serde_json::Value) { + std::fs::write(path, serde_json::to_string_pretty(body).unwrap()).unwrap(); + } + + fn passing_body(fixture_id: &str) -> serde_json::Value { + serde_json::json!({ + "fixture_id": fixture_id, + "verdict": "PASS", + "reason": "a node with an accepted role carries the accessible name byte-for-byte", + "observed_role": "paragraph", + "observed_name": "whatever", + "observed_name_hex": "77686174657665", + "prohibited_outcome": null, + "walked_tree": [] + }) + } + + // ---- F1: freshness / exit-status-vs-json agreement ---- + + /// Required kill (F1): a **stale** file at this run's json path claims + /// `PASS`, but this invocation's exit code says `FAIL` (1) — the stale + /// file must never be picked up as this fixture's evidence. + #[test] + fn interpret_rejects_a_stale_file_whose_verdict_disagrees_with_the_exit_code() { + let path = scratch_json_path("stale-verdict"); + write_json(&path, &passing_body("F-A")); + let inv = VerifyInvocation { + fixture_id: "F-A".to_string(), + exit_code: Some(1), + stdout: "CHECK5: FAIL\n".to_string(), + stderr: String::new(), + json_path: path.clone(), + }; + let err = interpret_verify_output(&inv).unwrap_err(); + assert!(err.to_string().contains("disagree"), "{err}"); + let _ = std::fs::remove_file(&path); + } + + /// Required kill (F1): a fresh file that names a **different** fixture + /// id must never be attributed to this one, even if exit code and + /// verdict otherwise agree. + #[test] + fn interpret_rejects_a_json_whose_fixture_id_does_not_match() { + let path = scratch_json_path("wrong-fixture-id"); + write_json(&path, &passing_body("F-B")); + let inv = VerifyInvocation { + fixture_id: "F-A".to_string(), + exit_code: Some(0), + stdout: "CHECK5: PASS\n".to_string(), + stderr: String::new(), + json_path: path.clone(), + }; + let err = interpret_verify_output(&inv).unwrap_err(); + assert!(err.to_string().contains("not the fixture"), "{err}"); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn interpret_accepts_a_fresh_agreeing_pass() { + let path = scratch_json_path("agreeing-pass"); + write_json(&path, &passing_body("F-A")); + let inv = VerifyInvocation { + fixture_id: "F-A".to_string(), + exit_code: Some(0), + stdout: "CHECK5: PASS\n".to_string(), + stderr: String::new(), + json_path: path.clone(), + }; + let outcome = interpret_verify_output(&inv).unwrap(); + assert!(matches!(outcome, FixtureOutcome::Scored(e) if e.pass)); + let _ = std::fs::remove_file(&path); + } + + /// Required kill (F1): exit 2 with generic usage-error stdout (no + /// prefix, no marker) and **no** fresh output present must be a hard + /// error, never silently promoted to bus-unreachable merely because + /// there is nothing to read. + #[test] + fn interpret_exit_2_without_prefix_or_marker_and_no_fresh_output_is_a_hard_error() { + let path = scratch_json_path("exit2-no-markers"); + let _ = std::fs::remove_file(&path); // guarantee absence + let inv = VerifyInvocation { + fixture_id: "F-A".to_string(), + exit_code: Some(2), + stdout: "CHECK5: usage error — bad --fixture value\n".to_string(), + stderr: String::new(), + json_path: path, + }; + let err = interpret_verify_output(&inv).unwrap_err(); + assert!(err.to_string().contains("usage error"), "{err}"); + } + + #[test] + fn interpret_exit_2_with_prefix_and_marker_and_no_fresh_output_is_bus_unreachable() { + let path = scratch_json_path("exit2-with-markers"); + let _ = std::fs::remove_file(&path); + let inv = VerifyInvocation { + fixture_id: "F-A".to_string(), + exit_code: Some(2), + stdout: "CHECK5: NOT RUN — Atspi.init() failed: no bus\n".to_string(), + stderr: String::new(), + json_path: path, + }; + let outcome = interpret_verify_output(&inv).unwrap(); + assert!(matches!(outcome, FixtureOutcome::BusUnreachable(_))); + } + + /// **H2, required kill 1 of 2 (prefix present, marker absent).** stdout + /// begins with the exact `CHECK5: NOT RUN` prefix, but names a cause + /// this module does not recognise as environmental — must be a hard + /// error. If the marker half of the conjunction were ever dropped + /// (accept on prefix alone), this stdout would wrongly pass. + #[test] + fn interpret_exit_2_with_prefix_but_no_recognised_marker_is_a_hard_error() { + let path = scratch_json_path("h2-prefix-no-marker"); + let _ = std::fs::remove_file(&path); + let inv = VerifyInvocation { + fixture_id: "F-A".to_string(), + exit_code: Some(2), + stdout: "CHECK5: NOT RUN — something we do not recognise\n".to_string(), + stderr: String::new(), + json_path: path, + }; + let err = interpret_verify_output(&inv).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("hard usage error"), "{msg}"); + // The message must report *which half* was unmet: prefix true, + // marker false — proof this is the marker-missing case specifically, + // not a generic rejection. + assert!(msg.contains("has_prefix=true"), "{msg}"); + assert!(msg.contains("has_marker=false"), "{msg}"); + } + + /// **H2, required kill 2 of 2 (marker present, prefix absent).** stdout + /// contains a recognised environmental marker, but does not begin with + /// the required `CHECK5: NOT RUN` prefix — must be a hard error. If the + /// prefix half of the conjunction were ever dropped (accept on marker + /// alone), this stdout would wrongly pass. + #[test] + fn interpret_exit_2_with_marker_but_no_prefix_is_a_hard_error() { + let path = scratch_json_path("h2-marker-no-prefix"); + let _ = std::fs::remove_file(&path); + let inv = VerifyInvocation { + fixture_id: "F-A".to_string(), + exit_code: Some(2), + // A recognised marker string is present, but as a *substring* + // of some other sentence, not as the required prefix. + stdout: "some unrelated wrapper reported: Atspi.init() failed somewhere downstream\n" + .to_string(), + stderr: String::new(), + json_path: path, + }; + let err = interpret_verify_output(&inv).unwrap_err(); + let msg = err.to_string(); + assert!(msg.contains("hard usage error"), "{msg}"); + // The message must report *which half* was unmet: marker true, + // prefix false — proof this is the prefix-missing case specifically, + // not a generic rejection. + assert!(msg.contains("has_prefix=false"), "{msg}"); + assert!(msg.contains("has_marker=true"), "{msg}"); + } + + /// Required kill (F1): exit 2 with the required prefix and marker, but a + /// fresh json output **exists anyway** — a contradiction (an exit-2 run + /// must never have written scored output), so this must be a hard + /// error, not accepted as bus-unreachable evidence. + #[test] + fn interpret_exit_2_with_prefix_and_marker_but_a_fresh_json_present_is_a_hard_error() { + let path = scratch_json_path("exit2-contradiction"); + write_json(&path, &passing_body("F-A")); + let inv = VerifyInvocation { + fixture_id: "F-A".to_string(), + exit_code: Some(2), + stdout: "CHECK5: NOT RUN — Atspi.init() failed: no bus\n".to_string(), + stderr: String::new(), + json_path: path.clone(), + }; + let err = interpret_verify_output(&inv).unwrap_err(); + assert!(err.to_string().contains("contradiction"), "{err}"); + let _ = std::fs::remove_file(&path); + } + + #[test] + fn interpret_missing_json_on_exit_0_is_a_hard_error() { + let path = scratch_json_path("missing-on-exit0"); + let _ = std::fs::remove_file(&path); + let inv = VerifyInvocation { + fixture_id: "F-A".to_string(), + exit_code: Some(0), + stdout: String::new(), + stderr: String::new(), + json_path: path, + }; + let err = interpret_verify_output(&inv).unwrap_err(); + assert!(err.to_string().contains("could not be read"), "{err}"); + } + + // ---- F2: a FAIL wins over a BusUnreachable found elsewhere, in either order ---- + + fn fail_evidence(id: &str) -> A11yEvidence { + A11yEvidence { + fixture_id: id.to_string(), + platform: "at-spi2".to_string(), + observed_name: Some("".to_string()), + observed_name_bytes_hex: Some("".to_string()), + observed_role: None, + prohibited_outcome: Some("absent-from-tree".to_string()), + pass: false, + notes: "no accessible-text-candidate node found".to_string(), + } + } + + fn pass_evidence(id: &str) -> A11yEvidence { + A11yEvidence { + fixture_id: id.to_string(), + platform: "at-spi2".to_string(), + observed_name: Some("x".to_string()), + observed_name_bytes_hex: Some("78".to_string()), + observed_role: Some("paragraph".to_string()), + prohibited_outcome: None, + pass: true, + notes: "byte-for-byte".to_string(), + } + } + + fn some_bus_evidence() -> BusUnreachableEvidence { + BusUnreachableEvidence { + probe_description: "test probe".to_string(), + probe_output: "CHECK5: NOT RUN — Atspi.init() failed: no bus".to_string(), + } + } + + /// Required kill (F2): a FAIL observed before a bus-unreachable outcome + /// still disqualifies — the round must not report NotRun. + #[test] + fn a_fail_before_a_bus_unreachable_still_wins() { + let outcomes = vec![ + FixtureOutcome::Scored(fail_evidence("F-A")), + FixtureOutcome::BusUnreachable(some_bus_evidence()), + ]; + let result = reduce_outcomes(outcomes); + match result { + A11yRoundResult::Scored(evidence) => { + assert!(evidence.iter().any(|e| !e.pass), "the FAIL must survive"); + } + A11yRoundResult::BusUnreachable { .. } => { + panic!("a FAIL found anywhere must never be erased by a later BusUnreachable") + } + } + } + + /// Required kill (F2), the other ordering: a bus-unreachable observed + /// **before** a FAIL must reach the exact same verdict as the previous + /// test — ordering must never decide a disqualifying check. + #[test] + fn a_bus_unreachable_before_a_fail_still_loses_to_the_fail() { + let outcomes = vec![ + FixtureOutcome::BusUnreachable(some_bus_evidence()), + FixtureOutcome::Scored(fail_evidence("F-C")), + ]; + let result = reduce_outcomes(outcomes); + match result { + A11yRoundResult::Scored(evidence) => { + assert!(evidence.iter().any(|e| !e.pass), "the FAIL must survive"); + } + A11yRoundResult::BusUnreachable { .. } => { + panic!( + "the FAIL must win regardless of whether the BusUnreachable was observed \ + before or after it" + ) + } + } + } + + /// A bus-unreachable with **no** FAIL anywhere is the legitimate + /// environmental-absence case — this is the one place `BusUnreachable` + /// is allowed to be the verdict. + #[test] + fn a_bus_unreachable_with_no_fail_anywhere_is_not_run() { + let outcomes = vec![ + FixtureOutcome::Scored(pass_evidence("F-A")), + FixtureOutcome::BusUnreachable(some_bus_evidence()), + FixtureOutcome::Scored(pass_evidence("F-D")), + ]; + let result = reduce_outcomes(outcomes); + assert!(matches!(result, A11yRoundResult::BusUnreachable { .. })); + } + + #[test] + fn all_pass_and_no_bus_issue_is_scored() { + let outcomes = vec![ + FixtureOutcome::Scored(pass_evidence("F-A")), + FixtureOutcome::Scored(pass_evidence("F-B")), + ]; + let result = reduce_outcomes(outcomes); + match result { + A11yRoundResult::Scored(evidence) => assert_eq!(evidence.len(), 2), + A11yRoundResult::BusUnreachable { .. } => panic!("no bus issue was reported"), + } + } +} diff --git a/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/a11y_tree.rs b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/a11y_tree.rs new file mode 100644 index 0000000..dbc2bff --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/a11y_tree.rs @@ -0,0 +1,109 @@ +//! Check 5, `ReportPart::AccessibilityTreeConstruction`: building the +//! accessible node(s) — role, name, relationships — derived from the +//! resolved text. **Semantic content only.** Getting this tree to the +//! platform (adapter lifecycle, event-loop plumbing, window/bridge setup, +//! subprocess orchestration of the verifier) is a *different* part of the +//! cost table and lives in `a11y_wiring.rs`, never here — the coordinator's +//! common attribution rule requires every `ReportPart` to map to a disjoint +//! set of whole files, and this file is exactly and only the semantic half. +//! +//! **One window, five sibling nodes.** winit does not support tearing an +//! `EventLoop` down and building a second one in the same process on every +//! platform, so rather than open and close five windows in sequence, +//! `build_initial_tree` builds one window's tree carrying one +//! `Role::Paragraph` child per fixture (F-A..F-E, name = that fixture's +//! exact source string) — `a11y_wiring.rs` scores all five fixtures against +//! that single live window, one `a11y-verifier/verify.py` subprocess +//! invocation per fixture. + +use accesskit::{Node as AccessNode, NodeId as AccessNodeId, Role, Tree, TreeId, TreeUpdate}; + +pub const WINDOW_TITLE: &str = "EpiphanyC2Round2Text"; +pub const ROOT_ID: AccessNodeId = AccessNodeId(0); +pub const FIXTURE_NODE_IDS: [AccessNodeId; 5] = [ + AccessNodeId(1), + AccessNodeId(2), + AccessNodeId(3), + AccessNodeId(4), + AccessNodeId(5), +]; + +/// One `Role::Paragraph` node whose accessible name is `text` **verbatim** — +/// the fixture's exact source string, never a shaped/ligated rendering of +/// it. `Role::Paragraph` maps to AT-SPI2 role `"paragraph"`, one of recipe +/// §8.2's accepted at-spi2 roles (verified against +/// `accesskit_atspi_common-0.19.1`'s `Role::Paragraph => AtspiRole::Paragraph` +/// mapping and `atspi-common-0.13.0`'s role-name table `"paragraph"`, both in +/// this workspace's lockfile). +/// +/// **Deliberately not `Role::Label`**, despite it also being accepted: +/// `accesskit_consumer::Node::label_comes_from_value` special-cases exactly +/// `Role::Label` to read the accessible name from the node's *value* +/// property rather than its *label* property (`accesskit_consumer-0.36.0` +/// `node.rs:735`, in this workspace's lockfile). Measured directly on this +/// packet's first run: a `Role::Label` node with `set_label(text)` and no +/// `set_value` reached AT-SPI with an accessible name of `""` — precisely +/// the `name-empty` prohibited outcome recipe §8.3 pins, and not a +/// substitution or a drop, but a role/property mismatch this candidate's own +/// choice of role caused. `Role::Paragraph` carries no such special case, so +/// `set_label` alone is sufficient. +pub fn build_fixture_node(text: &str) -> AccessNode { + let mut node = AccessNode::new(Role::Paragraph); + node.set_label(text); + node +} + +pub fn build_root() -> AccessNode { + let mut node = AccessNode::new(Role::Window); + node.set_children(FIXTURE_NODE_IDS.to_vec()); + node.set_label(WINDOW_TITLE); + node +} + +pub fn build_initial_tree(fixture_texts: &[String; 5]) -> TreeUpdate { + let mut nodes = vec![(ROOT_ID, build_root())]; + for (id, text) in FIXTURE_NODE_IDS.iter().zip(fixture_texts.iter()) { + nodes.push((*id, build_fixture_node(text))); + } + TreeUpdate { + nodes, + tree: Some(Tree::new(ROOT_ID)), + tree_id: TreeId::ROOT, + focus: FIXTURE_NODE_IDS[0], + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_root_carries_all_five_fixture_children_in_order() { + let root = build_root(); + assert_eq!(root.role(), Role::Window); + assert_eq!(root.children(), &FIXTURE_NODE_IDS[..]); + } + + #[test] + fn a_fixture_node_is_a_paragraph_carrying_the_exact_text_as_its_label() { + let node = build_fixture_node("Coro \u{0627}"); + assert_eq!(node.role(), Role::Paragraph); + assert_eq!(node.label(), Some("Coro \u{0627}")); + } + + #[test] + fn the_initial_tree_has_six_nodes_and_focuses_the_first_fixture() { + let texts: [String; 5] = [ + "a".to_string(), + "b".to_string(), + "c".to_string(), + "d".to_string(), + "e".to_string(), + ]; + let update = build_initial_tree(&texts); + assert_eq!(update.nodes.len(), 6); + assert_eq!(update.focus, FIXTURE_NODE_IDS[0]); + assert_eq!(update.nodes[1].1.label(), Some("a")); + assert_eq!(update.nodes[5].1.label(), Some("e")); + } +} diff --git a/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/a11y_wiring.rs b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/a11y_wiring.rs new file mode 100644 index 0000000..6efa027 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/a11y_wiring.rs @@ -0,0 +1,199 @@ +//! Check 5, `ReportPart::AccessibilityIntegrationWiring`: getting the tree +//! `a11y_tree.rs` builds onto the platform — **product-side only**: adapter +//! lifecycle, event loop, window and bridge setup, and tree publication. +//! +//! **Ruling (H1/J2): nothing that exists only to drive or await the +//! verifier lives here.** A real editor shipping this stack keeps exactly +//! what this file has — the window, the `accesskit_winit::Adapter`, the +//! event loop, publishing the tree `a11y_tree.rs` builds — and none of the +//! coordination that spawns `a11y-verifier/verify.py`, waits for it, or +//! decides what its output means, because that machinery exists only +//! because this spike scores itself out-of-process. That coordination is +//! `a11y_subprocess.rs`'s (`ReportPart::FixtureAndReportPlumbing`). An +//! earlier revision kept the worker thread, the readiness channel, and the +//! verifier's own result type in this file because the event loop "must run +//! while the subprocess does" — true, but that is a reason to expose a +//! narrow product-side surface the plumbing drives, not a reason to keep +//! the coordination itself here. +//! +//! **The seam, concretely.** [`run_window`] is generic over `T` and knows +//! nothing about verifiers, subprocesses, or `A11yRoundResult` — it runs the +//! window, calls `on_tree_published` exactly once (synchronously, from the +//! winit thread, the instant the tree has actually been pushed to the +//! platform), and blocks until *something* calls [`FinishHandle::finish`] +//! with a `T`, then closes the window and returns that `T`. What `T` is, +//! what `on_tree_published` does with the handle it receives (spawn a +//! thread; run a subprocess; anything), and how the result gets computed +//! are entirely the caller's concern (`a11y_subprocess::run_a11y_round`, +//! the only caller in this packet). This is deliberately reusable for +//! reasons that have nothing to do with check 5's verifier — the window +//! lifecycle a real product needs is exactly this and no more. +//! +//! vello ships no accessibility layer of its own, so — as `probe-vello`'s +//! Round 0 precedent already established for this candidate — this is a +//! **manual `accesskit_winit` wiring**: an accessibility tree built by hand +//! (`a11y_tree.rs`) and pushed through `accesskit_winit::Adapter`, driven +//! from a real winit `ApplicationHandler`. Unlike `probe-vello`, this mode +//! does not also drive a vello render pass: check 5 asks only whether the +//! run appears in the live platform tree as its source string, and the +//! headless rendering that answers check 1 already lives in `render.rs`. +//! Skipping the GPU surface here is a real simplification, named as one +//! rather than silently taken — see `c2_round2_text.rs`'s cost record. + +use std::sync::Arc; + +use accesskit_winit::{Adapter, Event as AccessKitEvent, WindowEvent as AccessKitWindowEvent}; +use anyhow::{anyhow, Result}; +use winit::application::ApplicationHandler; +use winit::dpi::LogicalSize; +use winit::event::WindowEvent; +use winit::event_loop::{ActiveEventLoop, EventLoop, EventLoopProxy}; +use winit::window::{Window, WindowId}; + +use crate::a11y_tree::{build_initial_tree, WINDOW_TITLE}; + +enum AppEvent { + AccessKit(AccessKitEvent), + /// Delivered by [`FinishHandle::finish`] — the window closes and + /// [`run_window`] returns this value. This is the entire coordination + /// surface: what produces `T`, and when, is never this file's concern. + Finish(T), +} + +impl From for AppEvent { + fn from(e: AccessKitEvent) -> Self { + AppEvent::AccessKit(e) + } +} + +/// Handed to `on_tree_published` exactly once, the instant [`run_window`]'s +/// tree has actually been pushed to the platform. Calling +/// [`FinishHandle::finish`] (from any thread) is the only way the window +/// closes and `run_window` returns — the window otherwise waits +/// indefinitely, which is why every caller must eventually call it. +pub struct FinishHandle { + proxy: EventLoopProxy>, +} + +impl FinishHandle { + pub fn finish(&self, value: T) { + let _ = self.proxy.send_event(AppEvent::Finish(value)); + } +} + +struct A11yApp { + proxy: EventLoopProxy>, + fixture_texts: [String; 5], + window: Option>, + adapter: Option, + on_tree_published: Option) + Send>>, + result: Option, +} + +impl ApplicationHandler> for A11yApp { + fn resumed(&mut self, event_loop: &ActiveEventLoop) { + if self.window.is_some() { + return; + } + let attrs = Window::default_attributes() + .with_inner_size(LogicalSize::new(480.0, 320.0)) + .with_title(WINDOW_TITLE); + let window = Arc::new( + event_loop + .create_window(attrs) + .expect("failed to create the round 2 a11y probe window"), + ); + // Manual accesskit_winit wiring, exactly probe-vello's Round 0 route + // (this file's module doc comment), minus the vello render pass. + let adapter = Adapter::with_event_loop_proxy(event_loop, &window, self.proxy.clone()); + self.window = Some(window); + self.adapter = Some(adapter); + } + + fn window_event( + &mut self, + event_loop: &ActiveEventLoop, + window_id: WindowId, + event: WindowEvent, + ) { + let Some(window) = self.window.clone() else { + return; + }; + if window.id() != window_id { + return; + } + if let Some(adapter) = &mut self.adapter { + adapter.process_event(&window, &event); + } + if let WindowEvent::CloseRequested = event { + event_loop.exit(); + } + } + + fn user_event(&mut self, event_loop: &ActiveEventLoop, event: AppEvent) { + match event { + AppEvent::AccessKit(ak_event) => { + if let AccessKitWindowEvent::InitialTreeRequested = ak_event.window_event { + let texts = self.fixture_texts.clone(); + if let Some(adapter) = &mut self.adapter { + // Tree publication: push the tree a11y_tree.rs + // built, onto the platform, via the adapter this + // file owns the lifecycle of. + adapter.update_if_active(move || build_initial_tree(&texts)); + } + // The tree is now actually live. Notify the caller + // exactly once, synchronously — this file has no + // opinion on what happens next, only that it *can* + // happen now. The callback must not block (it runs on + // the event loop's own thread); every real caller + // spawns a thread and returns immediately. + if let Some(cb) = self.on_tree_published.take() { + let handle = FinishHandle { + proxy: self.proxy.clone(), + }; + cb(handle); + } + } + } + AppEvent::Finish(value) => { + self.result = Some(value); + event_loop.exit(); + } + } + } +} + +/// Opens the one probe window and builds its accessibility tree +/// (`a11y_tree::build_initial_tree`); calls `on_tree_published` exactly +/// once, the instant that tree is actually live, handing it a +/// [`FinishHandle`]; blocks until something calls +/// [`FinishHandle::finish`], then closes the window and returns the +/// delivered value. +/// +/// This function is the entire product-side surface (H1/J2's ruling): it +/// knows nothing about verifiers, subprocesses, or check-5 scoring — `T` is +/// whatever the caller needs delivered, and `on_tree_published` is where the +/// caller's own coordination (spawning a thread, running a subprocess, +/// anything) begins. `a11y_subprocess::run_a11y_round` is the only caller +/// in this packet. +pub fn run_window( + fixture_texts: [String; 5], + on_tree_published: impl FnOnce(FinishHandle) + Send + 'static, +) -> Result { + let event_loop = EventLoop::>::with_user_event().build()?; + let proxy = event_loop.create_proxy(); + + let mut app = A11yApp { + proxy, + fixture_texts, + window: None, + adapter: None, + on_tree_published: Some(Box::new(on_tree_published)), + result: None, + }; + event_loop.run_app(&mut app)?; + + app.result.take().ok_or_else(|| { + anyhow!("a11y probe window closed with no value delivered via FinishHandle::finish") + }) +} diff --git a/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/hittest.rs b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/hittest.rs new file mode 100644 index 0000000..c2c91f8 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/hittest.rs @@ -0,0 +1,254 @@ +//! Check 4 (hit testing): point -> (byte offset, affinity) resolution. +//! +//! **This is the candidate-owned part.** `round2-candidatekit` loads the +//! committed `hittest_probes.json` (the *expected* answers) but explicitly +//! must not resolve one — that is what this check measures +//! (`round2_candidatekit::inputs` module doc: "Loading the expected answers +//! ... is neutral. Computing them is the candidate's job"). This module does +//! not call any of `round2_textkit::hittest`'s probe-*generation* functions +//! (`build_probe_table`, `build_all`, ...) — those built the very expected +//! values this module is scored against, so calling them here would make the +//! check circular. The only thing reused from that module is +//! `to_device` — sanctioned by the task instructions as neutral geometry the +//! reference also uses. +//! +//! ## The resolution rule this module implements +//! +//! `ROUND2_TEXT_RECIPE.md` §7's closing paragraph, restated in +//! `round2_textkit::hittest`'s own module doc comment as the semantics the +//! *committed probe table* assumes (not as code this module may call): every +//! grapheme's own `Downstream` caret stop, resolved to device space and +//! sorted by device x, partitions the line into non-overlapping intervals +//! with no gaps. A query point resolves to the stop that begins the interval +//! containing it — "floor" to the nearest stop at or before the point, never +//! a nearest-neighbour vote. Every probe in the committed table expects +//! `Downstream` affinity (recipe §7's own stated consequence of this rule), +//! so this resolver always returns `Downstream`. + +use round2_textkit::hittest::{to_device, DevicePoint}; +use round2_textkit::types::{SpikeCaretAffinity, SpikeResolvedText}; + +/// One grapheme's own `Downstream` caret stop, in device space. +struct Stop { + device_x: f64, + source_offset: u32, +} + +/// Every `Downstream` caret stop in `rt`, sorted ascending by device x. An +/// RTL segment's clusters are byte-ascending but device-x-descending, so +/// sorting by device x (not source order) is what makes the floor lookup +/// below correct on F-B/F-D's Hebrew segments as well as the LTR ones. +fn downstream_stops_by_device_x(rt: &SpikeResolvedText) -> Vec { + let mut stops: Vec = rt + .clusters + .clusters + .iter() + .flat_map(|c| c.caret_stops.iter()) + .filter(|s| s.affinity == SpikeCaretAffinity::Downstream) + .map(|s| { + let d = to_device(rt, &s.position); + Stop { + device_x: d.x, + source_offset: s.source_offset, + } + }) + .collect(); + stops.sort_by(|a, b| { + a.device_x + .partial_cmp(&b.device_x) + .expect("device x is always finite") + }); + stops +} + +/// Resolves one device point to `(byte offset, affinity)` against `rt`'s own +/// resolved caret-stop data — this candidate's own hit-test implementation, +/// not a lookup into any precommitted table. +/// +/// # Panics +/// +/// Panics if `rt` has no caret stops at all (every fixture in this recipe +/// has at least one grapheme, so this never fires on the committed set; a +/// degenerate empty-text fixture would need a different contract, not a +/// silently invented answer). +pub fn resolve_hit(rt: &SpikeResolvedText, point: &DevicePoint) -> (u32, SpikeCaretAffinity) { + let stops = downstream_stops_by_device_x(rt); + assert!( + !stops.is_empty(), + "resolve_hit: no Downstream caret stops in this SpikeResolvedText — nothing to resolve against" + ); + + // Floor: the last stop whose device x is <= the query point's x. Before + // the first stop, clamp to the first (recipe §7: a probe placed before + // the first caret stop still expects that stop's own offset). + let mut chosen = &stops[0]; + for s in &stops { + if s.device_x <= point.x { + chosen = s; + } else { + break; + } + } + (chosen.source_offset, SpikeCaretAffinity::Downstream) +} + +#[cfg(test)] +mod tests { + use super::*; + use round2_textkit::identity::{ + SemVerRecord, SpikeShaperId, SpikeTextShapingIdentity, SpikeUnicodeComponent, + }; + use round2_textkit::types::{ + SpikeBoundingBox, SpikeCaretStop, SpikeCluster, SpikeClusterMap, SpikeGlyphStyle, + SpikeLanguageTag, SpikePoint, SpikePositionedGlyph, SpikeProvenance, SpikeScriptTag, + SpikeShapedSegment, SpikeStaffSpace, SpikeTextAlign, SpikeTextDirection, + SpikeTypedObjectId, + }; + + fn dummy_identity() -> SpikeTextShapingIdentity { + SpikeTextShapingIdentity { + faces: Vec::new(), + shaper: SpikeShaperId("rustybuzz".to_string()), + shaper_version: SemVerRecord { + major: 0, + minor: 20, + patch: 1, + }, + features: Vec::new(), + unicode_bidi: SpikeUnicodeComponent { + impl_name: "unicode-bidi".to_string(), + crate_version: "0.3.18".to_string(), + unicode_version: Some("16.0.0".to_string()), + }, + unicode_segmentation: SpikeUnicodeComponent { + impl_name: "unicode-segmentation".to_string(), + crate_version: "1.13.3".to_string(), + unicode_version: Some("17.0.0".to_string()), + }, + } + } + + fn dummy_provenance() -> SpikeProvenance { + SpikeProvenance { + source: SpikeTypedObjectId { + discriminant: 0, + canonical_bytes_hex: "00".repeat(18), + }, + synthesis: None, + dependencies: Vec::new(), + stable_id: 0, + } + } + + /// Three graphemes at staff-space x = 0.0, 1.0, 2.0 (device x 100, 200, + /// 300 relative to origin 0,0) — enough to test floor lookup at an + /// interior midpoint, before the first stop, and after the last. + fn three_stops() -> SpikeResolvedText { + let seg = SpikeShapedSegment { + face: Some(0), + glyphs: vec![ + SpikePositionedGlyph { + glyph_id: 1, + offset: SpikePoint::new(0.0, 0.0), + transform: None, + }, + SpikePositionedGlyph { + glyph_id: 2, + offset: SpikePoint::new(1.0, 0.0), + transform: None, + }, + SpikePositionedGlyph { + glyph_id: 3, + offset: SpikePoint::new(2.0, 0.0), + transform: None, + }, + ], + source: 0..3, + direction: SpikeTextDirection::Ltr, + script: SpikeScriptTag("Latn".to_string()), + language: SpikeLanguageTag(None), + size: SpikeStaffSpace(1.28), + }; + let mk = |byte: u32, x: f64| SpikeCluster { + source: byte..byte + 1, + segment: 0, + glyph_indices: vec![byte], + resolved: true, + grapheme_count: 1, + caret_stops: vec![SpikeCaretStop { + source_offset: byte, + position: SpikePoint::new(x, 0.0), + affinity: SpikeCaretAffinity::Downstream, + }], + }; + SpikeResolvedText { + provenance: dummy_provenance(), + text: "abc".to_string(), + shaping: dummy_identity(), + segments: vec![seg], + clusters: SpikeClusterMap { + clusters: vec![mk(0, 0.0), mk(1, 1.0), mk(2, 2.0)], + }, + bounds: SpikeBoundingBox { + left: 0.0, + bottom: 0.0, + right: 2.0, + top: 1.0, + }, + reserved_box: SpikeBoundingBox { + left: 0.0, + bottom: 0.0, + right: 2.0, + top: 1.0, + }, + origin: SpikePoint::new(0.0, 0.0), + align: SpikeTextAlign::Start, + style: SpikeGlyphStyle { rgba: 0x0000_00ff }, + layer: 0, + } + } + + /// Mutation-first: an interior point between stop 0 (device x=0) and + /// stop 1 (device x=100) must resolve to stop 0's offset, not stop 1's — + /// the floor rule, not a nearest-neighbour vote (which would flip the + /// answer past the literal midpoint at x=50, not matter here, but would + /// give the WRONG answer at e.g. x=90 under a nearest-stop rule, since + /// 90 is nearer to 100 than to 0). This point (x=60) is nearer to 0? no — + /// 60 is nearer to 100 under Euclidean distance (|60-0|=60 > |60-100|=40 + /// is false, so pick a point where floor and nearest disagree instead: + /// x=90 is nearer to stop 1 (distance 10) than stop 0 (distance 90), so a + /// nearest-neighbour implementation would wrongly return offset 1 here, + /// while the correct floor rule returns offset 0. + #[test] + fn floor_not_nearest_neighbour() { + let rt = three_stops(); + let (offset, affinity) = resolve_hit(&rt, &DevicePoint { x: 90.0, y: 0.0 }); + assert_eq!( + offset, 0, + "floor rule must pick the stop at-or-before the point, not the nearer one" + ); + assert_eq!(affinity, SpikeCaretAffinity::Downstream); + } + + #[test] + fn before_the_first_stop_clamps_to_it() { + let rt = three_stops(); + let (offset, _) = resolve_hit(&rt, &DevicePoint { x: -50.0, y: 0.0 }); + assert_eq!(offset, 0); + } + + #[test] + fn after_the_last_stop_resolves_to_it() { + let rt = three_stops(); + let (offset, _) = resolve_hit(&rt, &DevicePoint { x: 1000.0, y: 0.0 }); + assert_eq!(offset, 2); + } + + #[test] + fn exactly_on_a_stop_resolves_to_that_stop() { + let rt = three_stops(); + // byte 1 sits at staff x=1.0 -> device x=100.0. + let (offset, _) = resolve_hit(&rt, &DevicePoint { x: 100.0, y: 0.0 }); + assert_eq!(offset, 1); + } +} diff --git a/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/render.rs b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/render.rs new file mode 100644 index 0000000..19848b0 --- /dev/null +++ b/spikes/editor-toolkit/round1-candidates/c2-vello/src/bin/c2_round2_text/render.rs @@ -0,0 +1,342 @@ +//! Check 1 (faithful consumption) + check 2's rendering half: offscreen vello +//! rendering of a `SpikeResolvedText`, drawing exactly the resolved +//! `(face, glyph_id, offset)` triples — no text layout API, no font +//! fallback, no rustybuzz. +//! +//! **The candidate-owned part named in the task instructions**: outline +//! extraction from the face and conversion to a `kurbo::BezPath`. This module +//! implements `ttf_parser::OutlineBuilder` directly, the same way +//! `round2-svgref` does for its own (SVG-string, reference-only) output — but +//! that crate is deliberately not depended on here: its output type is a +//! `` string for the reference emitter, not `kurbo` geometry +//! for a candidate to feed `vello::Scene::fill`. + +use anyhow::{anyhow, Result}; +use ttf_parser::{Face as TtfFace, GlyphId, OutlineBuilder}; +use vello::kurbo::{Affine, BezPath, Point as KPoint}; +use vello::peniko::{color::palette, Color, Fill}; +use vello::wgpu; +use vello::{AaConfig, AaSupport, RenderParams, Renderer, RendererOptions, Scene}; + +use round2_candidatekit::inputs::{HEIGHT, WIDTH}; +use round2_textkit::faces::LoadedFace; +use round2_textkit::hittest::to_device; +use round2_textkit::types::SpikeResolvedText; +use round2_textkit::DEVICE_SCALE; + +/// Opaque black ink on an opaque white ground (recipe §3/§10: "verified: +/// every reference pixel has alpha 255, ground is #ffffff, ink is #000000"), +/// the same convention Round 1 used. +const INK: Color = palette::css::BLACK; +const GROUND: Color = palette::css::WHITE; + +/// vello's `render_to_texture` requires `Rgba8Unorm` + `STORAGE_BINDING` — +/// same literal deviation from pin 4's "sRGB target format" Round 1's C2 +/// documented and for the same reason; immaterial here since `round2_diff` +/// compares luma classifications, not raw channel values under a transfer +/// function. +const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8Unorm; + +/// Same AA config Round 1's C2 used, so this packet's choice is traceable to +/// that precedent rather than picked fresh; Round 2's check 1 is a bounded +/// visual differential against a reference raster (recipe §10), not a +/// pixel-exact comparison, so the AA method does not change the outcome the +/// way it would in Round 4's timings. +const AA: AaConfig = AaConfig::Msaa8; + +pub struct Gpu { + pub device: wgpu::Device, + pub queue: wgpu::Queue, + pub renderer: Renderer, + pub adapter_name: String, + pub adapter_device_type: String, +} + +/// Initializes one headless wgpu device + vello renderer, reused across all +/// five fixtures. Prefers the integrated adapter (pin 4: "the integrated +/// adapter's figures decide"), falling back to the first enumerated Vulkan +/// adapter — Round 2's check 1 is a correctness check, not the adapter-class +/// comparison pin 4 requires for Round 4's timings, so a single adapter is +/// sufficient here and the one chosen is recorded in the report. +pub fn init_gpu() -> Result { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::VULKAN, + ..wgpu::InstanceDescriptor::new_without_display_handle() + }); + let adapters = pollster::block_on(instance.enumerate_adapters(wgpu::Backends::VULKAN)); + if adapters.is_empty() { + return Err(anyhow!( + "NOT RUN: no Vulkan adapters enumerated — environment absence, not a candidate defect" + )); + } + let adapter = adapters + .iter() + .find(|a| a.get_info().device_type == wgpu::DeviceType::IntegratedGpu) + .unwrap_or(&adapters[0]); + let info = adapter.get_info(); + + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("c2-vello-round2-text"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::default(), + memory_hints: wgpu::MemoryHints::default(), + trace: wgpu::Trace::Off, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + }))?; + + let renderer = Renderer::new( + &device, + RendererOptions { + use_cpu: false, + antialiasing_support: AaSupport::all(), + num_init_threads: None, + pipeline_cache: None, + }, + ) + .map_err(|e| anyhow!("vello Renderer::new failed: {e}"))?; + + Ok(Gpu { + device, + queue, + renderer, + adapter_name: info.name.clone(), + adapter_device_type: format!("{:?}", info.device_type), + }) +} + +/// Collects one glyph's outline into a device-space `kurbo::BezPath`, +/// scaling by `em_px / units_per_em` and flipping y (font space is y-up, +/// device space is y-down) — the same rule Round 1's `build_path` used for +/// Bravura outlines, applied here to a `ttf_parser` face outline instead of +/// a typed `PathCommand` sequence. +/// +/// Returns `None` for a glyph with no outline (whitespace, or — under W3-F3's +/// invariant — a glyph id that does not exist in this face at all, which +/// never happens here because `seg.face` is only ever `Some` when resolution +/// found real coverage). +fn build_glyph_bezpath( + face: &TtfFace, + glyph_id: u16, + em_px: f64, + origin: KPoint, +) -> Option { + struct Sink { + path: BezPath, + scale: f64, + ox: f64, + oy: f64, + open: bool, + any: bool, + } + impl Sink { + fn map(&self, x: f32, y: f32) -> KPoint { + KPoint::new( + self.ox + x as f64 * self.scale, + self.oy - y as f64 * self.scale, + ) + } + } + impl OutlineBuilder for Sink { + fn move_to(&mut self, x: f32, y: f32) { + if self.open { + self.path.close_path(); + } + let p = self.map(x, y); + self.path.move_to(p); + self.open = true; + self.any = true; + } + fn line_to(&mut self, x: f32, y: f32) { + self.path.line_to(self.map(x, y)); + } + fn quad_to(&mut self, x1: f32, y1: f32, x: f32, y: f32) { + self.path.quad_to(self.map(x1, y1), self.map(x, y)); + } + fn curve_to(&mut self, x1: f32, y1: f32, x2: f32, y2: f32, x: f32, y: f32) { + self.path + .curve_to(self.map(x1, y1), self.map(x2, y2), self.map(x, y)); + } + fn close(&mut self) { + self.path.close_path(); + self.open = false; + } + } + + let upem = face.units_per_em() as f64; + if upem <= 0.0 { + return None; + } + let mut sink = Sink { + path: BezPath::new(), + scale: em_px / upem, + ox: origin.x, + oy: origin.y, + open: false, + any: false, + }; + face.outline_glyph(GlyphId(glyph_id), &mut sink)?; + if sink.open { + sink.path.close_path(); + } + if !sink.any { + return None; + } + Some(sink.path) +} + +/// Builds one `vello::Scene` for `rt`, drawing exactly the resolved +/// `(face, glyph_id, offset)` triples — **never re-shaping**. A segment whose +/// `face` is `None` (F-C's uncovered Arabic letter) has no glyphs by +/// construction (W3-F3 / `invariants::assert_unresolved_clusters_are_diagnostic`, +/// asserted on every loaded fixture by `FixtureFile::validate`), so the loop +/// below draws nothing for it and substitutes nothing — there is no +/// "draw `.notdef`" branch to suppress because shaping was never attempted +/// against a face that does not cover the codepoint. +fn build_scene(rt: &SpikeResolvedText, faces: &[LoadedFace]) -> Result { + let mut scene = Scene::new(); + for (seg_idx, seg) in rt.segments.iter().enumerate() { + let Some(face_idx) = seg.face else { + // Uncovered span: `seg.glyphs` is guaranteed empty here. Nothing + // drawn, nothing substituted. + continue; + }; + let loaded = faces.get(face_idx as usize).ok_or_else(|| { + anyhow!( + "segment {seg_idx} resolved to face {face_idx}, but only {} faces are loaded", + faces.len() + ) + })?; + let face = TtfFace::parse(&loaded.bytes, loaded.identity.face_index) + .map_err(|e| anyhow!("face {face_idx} failed to parse: {e}"))?; + let em_px = seg.size.0 * DEVICE_SCALE; + for g in &seg.glyphs { + let device = to_device(rt, &g.offset); + let origin = KPoint::new(device.x, device.y); + if let Some(path) = build_glyph_bezpath(&face, g.glyph_id as u16, em_px, origin) { + // NonZero: the reference emitter (`round2-svgref`) fills with + // fill-rule nonzero, and the recipe states this is the rule + // to match (§3: "Fill rule nonzero, as the reference emitter + // uses"). + scene.fill(Fill::NonZero, Affine::IDENTITY, INK, None, &path); + } + // `None`: a whitespace glyph with an empty outline. Not an + // error — `round2-svgref` treats this identically (`empty`, not + // a failure), and the fixture's own glyph/segment counts already + // account for it. + } + } + Ok(scene) +} + +/// Copies a rendered texture back to host memory as tightly packed RGBA, +/// undoing wgpu's 256-byte row-stride padding. Identical in shape to Round +/// 1's C2 `readback` (duplicated rather than shared, so `src/main.rs` stays +/// byte-identical and untouched by this packet). +fn readback( + device: &wgpu::Device, + queue: &wgpu::Queue, + texture: &wgpu::Texture, + width: u32, + height: u32, +) -> Result> { + let unpadded = width * 4; + let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT; + let padded = unpadded.div_ceil(align) * align; + + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("c2-round2-readback"), + size: (padded as u64) * (height as u64), + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("c2-round2-copy"), + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded), + rows_per_image: Some(height), + }, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + queue.submit([encoder.finish()]); + + let slice = buffer.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + device.poll(wgpu::PollType::wait_indefinitely())?; + rx.recv() + .map_err(|e| anyhow!("readback channel closed: {e}"))? + .map_err(|e| anyhow!("buffer map failed: {e}"))?; + + let mapped = slice.get_mapped_range(); + let mut out = Vec::with_capacity((unpadded as usize) * (height as usize)); + for row in 0..height as usize { + let start = row * padded as usize; + out.extend_from_slice(&mapped[start..start + unpadded as usize]); + } + drop(mapped); + buffer.unmap(); + Ok(out) +} + +/// Renders `rt` offscreen at pin 4's 1920x1080, opaque white ground, opaque +/// black ink, returning tightly packed RGBA8 — the exact shape +/// `round2_diff::diff` and `round2-candidatekit`'s loader require. +pub fn render_fixture( + gpu: &mut Gpu, + rt: &SpikeResolvedText, + faces: &[LoadedFace], +) -> Result> { + let scene = build_scene(rt, faces)?; + + let texture = gpu.device.create_texture(&wgpu::TextureDescriptor { + label: Some("c2-round2-target"), + size: wgpu::Extent3d { + width: WIDTH, + height: HEIGHT, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: FORMAT, + usage: wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + gpu.renderer + .render_to_texture( + &gpu.device, + &gpu.queue, + &scene, + &view, + &RenderParams { + base_color: GROUND, + width: WIDTH, + height: HEIGHT, + antialiasing_method: AA, + }, + ) + .map_err(|e| anyhow!("vello render_to_texture failed: {e}"))?; + + readback(&gpu.device, &gpu.queue, &texture, WIDTH, HEIGHT) +}