From e2979dfccda91e209d24d8fce7fa2ed92a4236b2 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Thu, 30 Jul 2026 21:42:05 -0400 Subject: [PATCH] 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) +}