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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RSX4zSLgKvtiXaPjnMqLGz
This commit is contained in:
Levi Neuwirth 2026-07-30 21:42:05 -04:00
parent ad4f6ed4f3
commit e2979dfccd
25 changed files with 9315 additions and 0 deletions

View File

@ -659,6 +659,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bytemuck", "bytemuck",
"eframe",
"egui", "egui",
"egui-wgpu", "egui-wgpu",
"epiphany-layout-ir", "epiphany-layout-ir",
@ -667,18 +668,31 @@ dependencies = [
"lyon_tessellation", "lyon_tessellation",
"pollster", "pollster",
"round1-harness", "round1-harness",
"round2-candidatekit",
"round2-diff",
"round2-textkit",
"serde_json",
"ttf-parser",
] ]
[[package]] [[package]]
name = "c2-vello" name = "c2-vello"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"accesskit",
"accesskit_winit 0.33.2",
"anyhow", "anyhow",
"bytemuck", "bytemuck",
"epiphany-layout-ir", "epiphany-layout-ir",
"pollster", "pollster",
"round1-harness", "round1-harness",
"round2-candidatekit",
"round2-diff",
"round2-textkit",
"serde_json",
"ttf-parser",
"vello", "vello",
"winit",
] ]
[[package]] [[package]]

View File

@ -25,3 +25,29 @@ lyon_tessellation = "1.0"
pollster = "0.4" pollster = "0.4"
bytemuck = "1" bytemuck = "1"
anyhow = "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"

View File

@ -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'"
]
}

View File

@ -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'"
]
}

View File

@ -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'"
]
}

View File

@ -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'"
]
}

View File

@ -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'"
]
}

File diff suppressed because it is too large Load Diff

View File

@ -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,
}))
}),
)
}

View File

@ -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,
});
});
}

File diff suppressed because it is too large Load Diff

View File

@ -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<String> = 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 <F-A|F-B|F-C|F-D|F-E>");
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)
}

View File

@ -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<LoadedFace> = 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<ttf_parser::Face> = 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<String, DiffReportRecord> = BTreeMap::new();
let mut unresolved_by_fixture: BTreeMap<String, Vec<String>> = BTreeMap::new();
let mut check1_failures: Vec<String> = 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<GlyphRegion> = reference.regions.clone();
let diff = round2_diff::diff(
&reference.reference_rgba,
&draw.rgba,
render_target::WIDTH,
render_target::HEIGHT,
&regions,
)
.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<String> = 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<Vec<String>> {
fn walk(dir: &Path, base: &Path, out: &mut Vec<String>) -> 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<Vec<LocByPart>> {
let dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let count = |rel: &str| -> Result<u64> {
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:?}");
}
}

View File

@ -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<LyonPath> {
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<Vec<u8>> {
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"
);
}
}

View File

@ -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<Stop> {
let mut stops: Vec<Stop> = 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
);
}
}

View File

@ -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;

View File

@ -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<GpuCtx> {
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<Vec<u8>> {
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<u8>,
pub unresolved_segments: Vec<String>,
}
/// 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<FixtureDraw> {
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("<non-UTF8-boundary>");
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,
})
}

View File

@ -21,3 +21,27 @@ vello = "0.9"
pollster = "0.4" pollster = "0.4"
bytemuck = "1" bytemuck = "1"
anyhow = "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"

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -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<Result<A11yRoundResult>>`): 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<A11yEvidence>),
BusUnreachable {
evidence: BusUnreachableEvidence,
partial_scored: Vec<A11yEvidence>,
},
}
/// 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<i32>,
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<A11yEvidence> {
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<FixtureOutcome> {
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<FixtureOutcome>) -> A11yRoundResult {
let mut scored = Vec::new();
let mut bus_unreachable: Option<BusUnreachableEvidence> = 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<A11yRoundResult> {
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<A11yRoundResult>`: 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<A11yRoundResult> {
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"),
}
}
}

View File

@ -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"));
}
}

View File

@ -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<T> {
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<T> From<AccessKitEvent> for AppEvent<T> {
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<T: Send + 'static> {
proxy: EventLoopProxy<AppEvent<T>>,
}
impl<T: Send + 'static> FinishHandle<T> {
pub fn finish(&self, value: T) {
let _ = self.proxy.send_event(AppEvent::Finish(value));
}
}
struct A11yApp<T: Send + 'static> {
proxy: EventLoopProxy<AppEvent<T>>,
fixture_texts: [String; 5],
window: Option<Arc<Window>>,
adapter: Option<Adapter>,
on_tree_published: Option<Box<dyn FnOnce(FinishHandle<T>) + Send>>,
result: Option<T>,
}
impl<T: Send + 'static> ApplicationHandler<AppEvent<T>> for A11yApp<T> {
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<T>) {
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<T: Send + 'static>(
fixture_texts: [String; 5],
on_tree_published: impl FnOnce(FinishHandle<T>) + Send + 'static,
) -> Result<T> {
let event_loop = EventLoop::<AppEvent<T>>::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")
})
}

View File

@ -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<Stop> {
let mut stops: Vec<Stop> = 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);
}
}

View File

@ -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
//! `<path d="...">` 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<Gpu> {
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<BezPath> {
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<Scene> {
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<Vec<u8>> {
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<Vec<u8>> {
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)
}