Merge editor-t4-packet2b: the T4 spike's Round 2 text packet

Five commits, all confined to spikes/editor-toolkit/. Built in a worktree
so the shared crates/ tree could stay mid-edit for the genesis track
without turning the spike gate red under review.

The pin-13 apparatus (694d135) and its three schema amendments were each
committed and user-reviewed before either candidate existed, so neither
candidate could shape what it would be measured against. e2979df lands
both candidates from one commit for the same reason.
This commit is contained in:
Levi Neuwirth 2026-07-30 21:49:44 -04:00
commit f33673deef
41 changed files with 15022 additions and 38 deletions

View File

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

View File

@ -12,6 +12,8 @@ members = [
"round2-textkit",
"round2-svgref",
"round2-reference",
"round2-candidatekit",
"round2-a11y-oracle",
]
# a11y-verifier is a standalone Python script (a11y-verifier/verify.py), not

View File

@ -0,0 +1 @@
__pycache__/

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -25,3 +25,29 @@ lyon_tessellation = "1.0"
pollster = "0.4"
bytemuck = "1"
anyhow = "1"
# --- Packet 2B-C1 additions (Round 2, text) ---
#
# round2-candidatekit / round2-diff / round2-textkit: the candidate-neutral
# apparatus (fixture loading, the bounded visual differential, the report
# shape and scoring rule) Round 2 requires every candidate to consume rather
# than re-derive. See src/bin/round2_text.rs.
round2-candidatekit = { path = "../../round2-candidatekit" }
round2-diff = { path = "../../round2-diff" }
round2-textkit = { path = "../../round2-textkit" }
# Glyph outline extraction from the two declared host faces (TeX Gyre
# Pagella, Liberation Serif) is candidate-owned work (recipe: "Outline
# extraction from the face and conversion to a lyon path IS yours to
# write"). Pinned to the exact version round2-textkit shapes fixtures
# against, so a `ttf-parser` behavioural difference cannot be mistaken for a
# rendering bug in this candidate's own code.
ttf-parser = "=0.25.1"
# Writing round2_candidatekit::CandidateReport to disk.
serde_json = "1"
# eframe/winit windowed route for check 5 (accessibility): Round 1's binary
# is headless (offscreen wgpu only, no winit/eframe at all), and check 5
# requires a real window on the live AT-SPI2 bus (recipe: "your Round 1
# binary is headless, so this is a separate mode"). See
# src/bin/round2_a11y.rs, the same first-party AccessKit route
# `probe-egui`'s Round 0 binary used.
eframe = "0.35"

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"
bytemuck = "1"
anyhow = "1"
# --- Packet 2B-C2: Round 2 text (spec/CONTRACT_EDITOR_T4_SPIKE.md Round 2;
# ROUND2_TEXT_RECIPE.md). Every dependency below is new over the Round 1
# baseline (c20bc93) and is recorded, with its reason, in
# CandidateReport::cost::dependencies_added by src/bin/round2_text.rs.
round2-candidatekit = { path = "../../round2-candidatekit" }
round2-diff = { path = "../../round2-diff" }
round2-textkit = { path = "../../round2-textkit" }
# Outline extraction from the resolved face, converted to a kurbo BezPath, is
# the candidate-owned part of check 1 (task instructions) — round2-svgref's
# emitter builds SVG path strings for the *reference*, not kurbo geometry for
# a candidate, so it is deliberately not depended on here. Pinned to the same
# version round2-textkit/round2-svgref use so "this face's cmap/outline table
# says X" means the same thing everywhere in this packet.
ttf-parser = "=0.25.1"
serde_json = "1"
# Check 5 (accessibility, disqualifying): vello ships no accessibility layer
# of its own (contract, candidate set), so this is the same manual
# accesskit_winit route probe-vello's Round 0 readback already proved —
# reused here behind a real window rather than assumed. Versions match
# probe-vello's own pins exactly.
winit = "0.30"
accesskit = "0.24"
accesskit_winit = "0.33"

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

View File

@ -0,0 +1,40 @@
[package]
name = "round2-a11y-oracle"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
# Packet 2B-A (ROUND2_TEXT_RECIPE.md §8, spec/CONTRACT_EDITOR_T4_SPIKE.md pin
# 13): the check-5 accessibility oracle's *comparison-data* half. This crate
# reads the already-committed, already-validated `round2-textkit/fixtures.json`
# (via `round2_textkit::output::load_fixtures`, which validates it against its
# embedded digest — see that crate) and writes
# `round2-a11y-oracle/a11y_expectations.json`: per fixture, the exact accepted/
# prohibited at-spi2 roles and the exact byte strings each `PROHIBITED_OUTCOMES`
# classification would produce, so `a11y-verifier/verify.py`'s live-tree mode
# compares observed bytes against precommitted bytes rather than guessing what
# a wrong name "looks like".
#
# This crate must exist and be reviewed *before* either Round 2 candidate
# builds a tree (pin 13): if a candidate's own tree shaped what this oracle
# expects, the oracle would no longer be neutral evidence.
#
# Deliberately no rendering, windowing, or accessibility crate here — this
# crate never touches a live tree; that is `a11y-verifier/verify.py`'s job.
[dependencies]
round2-textkit = { path = "../round2-textkit" }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
# Pinned to the same version already resolved transitively (epiphany-core /
# epiphany-ops both already pull it), so adding this dependency changes
# nothing in Cargo.lock's resolution.
unicode-normalization = "=0.1.25"
# Same version round2-textkit shapes fixtures with, for the same reason it
# pins it: grapheme-boundary derivation here must agree with the grapheme
# boundaries `crate::hittest`'s caret stops were built from.
unicode-segmentation = "=1.13.3"
[[bin]]
name = "generate_a11y_expectations"
path = "src/bin/generate_a11y_expectations.rs"

View File

@ -0,0 +1,144 @@
{
"contract": "spec/CONTRACT_EDITOR_T4_SPIKE.md pin 13",
"recipe": "spikes/editor-toolkit/ROUND2_TEXT_RECIPE.md §8",
"platform": "at-spi2",
"source_fixtures_digest": "acc13c0d02624a0741cca5dffa7470a8971d3ecef5c6fb6f9e533ded684e7ed1",
"fixtures": [
{
"fixture_id": "F-A",
"expected_name": "Allegro affettuoso — al fine",
"expected_name_hex": "416c6c6567726f20616666657474756f736f20e2809420616c2066696e65",
"expected_name_byte_len": 30,
"accepted_roles": [
"label",
"static",
"text",
"paragraph"
],
"prohibited_roles": [
"image",
"canvas",
"filler",
"panel",
"unknown"
],
"source_atoms": [
"Allegro affettuoso — al fine"
],
"alternative_forms": {
"name-is-shaped-glyphs": [
"Allegro afettuoso — al fne",
"Allegro affettuoso — al fine"
]
}
},
{
"fixture_id": "F-B",
"expected_name": "Coro אבג",
"expected_name_hex": "436f726f20d790d791d792",
"expected_name_byte_len": 11,
"accepted_roles": [
"label",
"static",
"text",
"paragraph"
],
"prohibited_roles": [
"image",
"canvas",
"filler",
"panel",
"unknown"
],
"source_atoms": [
"Coro ",
"אבג"
],
"alternative_forms": {},
"visual_order_name": "Coro גבא",
"visual_order_name_hex": "436f726f20d792d791d790"
},
{
"fixture_id": "F-C",
"expected_name": "Coro ا",
"expected_name_hex": "436f726f20d8a7",
"expected_name_byte_len": 7,
"accepted_roles": [
"label",
"static",
"text",
"paragraph"
],
"prohibited_roles": [
"image",
"canvas",
"filler",
"panel",
"unknown"
],
"source_atoms": [
"Coro ",
"ا"
],
"alternative_forms": {
"name-drops-unresolved-codepoints": [
"Coro "
]
}
},
{
"fixture_id": "F-D",
"expected_name": "Allegro אבג con brio",
"expected_name_hex": "416c6c6567726f20d790d791d79220636f6e206272696f",
"expected_name_byte_len": 23,
"accepted_roles": [
"label",
"static",
"text",
"paragraph"
],
"prohibited_roles": [
"image",
"canvas",
"filler",
"panel",
"unknown"
],
"source_atoms": [
"Allegro ",
"אבג",
" con brio"
],
"alternative_forms": {},
"visual_order_name": "Allegro גבא con brio",
"visual_order_name_hex": "416c6c6567726f20d792d791d79020636f6e206272696f"
},
{
"fixture_id": "F-E",
"expected_name": "Café — resumé",
"expected_name_hex": "43616665cc8120e2809420726573756d65cc81",
"expected_name_byte_len": 19,
"accepted_roles": [
"label",
"static",
"text",
"paragraph"
],
"prohibited_roles": [
"image",
"canvas",
"filler",
"panel",
"unknown"
],
"source_atoms": [
"Café — resumé"
],
"alternative_forms": {
"name-normalized": [
"Café — resumé"
]
}
}
]
}

View File

@ -0,0 +1,58 @@
//! `generate_a11y_expectations` — Packet 2B-A's entry point.
//!
//! Loads `round2-textkit/fixtures.json` (validating it against its own
//! embedded digest via `round2_textkit::output::load_fixtures`), derives this
//! machine's check-5 comparison data for all five fixtures, and writes
//! `round2-a11y-oracle/a11y_expectations.json`.
//!
//! Exit behavior mirrors `round2-textkit`'s own `bin/generate`: a missing
//! `fixtures.json` (never generated, or generated on a machine without the
//! declared faces) is reported and this binary exits non-zero rather than
//! writing a partial or empty file — pin 13's ordering requires the oracle to
//! exist and be reviewed before a candidate consumes it, so silently writing
//! nothing would be worse than a loud failure.
use std::path::PathBuf;
fn main() {
let manifest_dir = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let textkit_dir = manifest_dir
.parent()
.expect("round2-a11y-oracle has a parent directory")
.join("round2-textkit");
let fixtures_path = textkit_dir.join("fixtures.json");
let fixtures = round2_textkit::output::load_fixtures(&fixtures_path).unwrap_or_else(|e| {
panic!(
"{}: {e} — run `cargo run -p round2-textkit --bin generate` first",
fixtures_path.display()
)
});
let expectations = round2_a11y_oracle::build_expectations_file(&fixtures);
let out_path = manifest_dir.join("a11y_expectations.json");
let json = serde_json::to_string_pretty(&expectations)
.expect("ExpectationsFile is always serializable");
std::fs::write(&out_path, format!("{json}\n"))
.unwrap_or_else(|e| panic!("failed to write {}: {e}", out_path.display()));
println!(
"wrote {} ({} fixtures, platform {})",
out_path.display(),
expectations.fixtures.len(),
expectations.platform
);
for f in &expectations.fixtures {
println!(
" {}: {} alternative form(s), visual_order_name {}",
f.fixture_id,
f.alternative_forms.len(),
if f.visual_order_name.is_some() {
"present"
} else {
"omitted (identical to expected_name)"
}
);
}
}

View File

@ -0,0 +1,113 @@
//! Findings routed back to `spikes/editor-toolkit/ROUND2_TEXT_RECIPE.md`,
//! discovered while building this crate.
//!
//! Recorded here — not only in a review conversation — so whoever next
//! revises the recipe finds it in the artifact rather than a chat transcript,
//! the same discipline `round2_textkit::findings` uses for the findings it
//! routes back to the W3 `.tex` amendment. These are findings *about the
//! recipe's own prose*, not about `epiphany-layout-ir`, so they are recorded
//! here rather than in `round2_textkit::findings`.
//!
//! This crate does not edit `ROUND2_TEXT_RECIPE.md` — that document belongs
//! to the coordinator's commit and a separate review thread.
/// Recipe §8.1 claims: "a tree assembled by walking the visual runs left to
/// right produces a different string, and only there \[F-D\]."
///
/// That is false under this crate's own generated data. F-B diverges the
/// same way: its logical name is `"Coro אבג"` and
/// `round2_a11y_oracle::visual_order_form` produces `"Coro גבא"` for it — a
/// real, non-empty `visual_order_name` entry in `a11y_expectations.json`,
/// exactly the same mechanism F-D exercises.
///
/// The general shape, not just the one counterexample: under
/// [`crate::visual_order_form`]'s model (concatenate segments in stored
/// order, reversing an `Rtl` segment's own text by grapheme), **any
/// non-palindromic** RTL run of two or more graphemes diverges under a
/// visual-order walk, because reversing a grapheme sequence is a no-op
/// exactly when that sequence is a palindrome (a repeated single grapheme,
/// e.g. `"aa"`, is a palindrome and is therefore **not** a counterexample to
/// this narrower claim — it was a counterexample to the unqualified "any RTL
/// run of two or more graphemes" claim an earlier revision of this finding
/// made). F-D is not the *unique* case; it is the case where the RTL run is
/// *interior* to the string (`"Allegro "` ... `"אבג"` ... `" con brio"`)
/// rather than trailing (`"Coro "` ... `"אבג"`), which is why F-D's
/// divergence reads as obviously wrong to a human glancing at it and F-B's —
/// a suffix silently reversed — reads as more easily missed. That
/// readability difference is a real reason to prefer F-D as the check-5
/// accessibility exemplar; it is not a reason to claim F-B does not exhibit
/// the same property.
///
/// The recipe should either say "F-D and F-B" at §8.1, or drop the
/// uniqueness claim and state the actual distinguishing property: F-D is the
/// fixture where the RTL run is interior, not the fixture where the
/// divergence uniquely occurs.
///
/// ## The same stale claim is also baked into a digest-bound artifact
///
/// The recipe's prose is not the only place this claim lives.
/// `round2-textkit/src/a11y.rs`'s `note_for("F-D")` reads: "the concatenation
/// is logical-order, so a tree built by walking the visual runs left to
/// right fails here and only here" — the identical uniqueness claim, in
/// code. That note is compiled into every generated `fixtures.json` as
/// `fixtures[3].accessibility.note`, and `fixtures.json`'s own
/// `EXPECTED_ARTIFACT_DIGEST_HEX` (`round2_textkit::output`) binds the whole
/// serialized file, note text included, to `acc13c0d…` — a frozen,
/// user-reviewed artifact (Packet 2A). Editing the note's wording to correct
/// the claim would change that digest and break every consumer pinned to it,
/// which is a strictly larger and differently-scoped change than this
/// finding.
///
/// **This half of the finding is tracked, not fixed**, and is recorded
/// explicitly so a later reader does not "helpfully" edit
/// `round2-textkit/src/a11y.rs`'s F-D note on the strength of this finding
/// alone and silently move `acc13c0d…` out from under Packet 2A. Fixing it
/// is a decision for whoever owns that digest and that packet's re-freeze,
/// not a drive-by edit from this crate.
pub const RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D: &str = "ROUND2_TEXT_RECIPE.md §8.1 claims \
visual-order-walk assembly produces a different string \"and only there [F-D]\". It does \
not: F-B's visual_order_name (\"Coro גבא\") also differs from its expected_name (\"Coro \
אבג\"), and under this crate's visual_order_form, any non-palindromic RTL run of two or more \
graphemes diverges the same way (a repeated-grapheme run like \"aa\" is a palindrome and does \
not diverge, which is why the claim is qualified). F-D is not unique in exhibiting the \
divergence; it is the fixture where the RTL run is interior to the string rather than \
trailing, which is why the divergence is more obviously wrong to a reader. The recipe should \
say \"F-D and F-B\" or state the interior-run property instead of a uniqueness claim. The \
identical stale claim is also baked into round2-textkit/src/a11y.rs's note_for(\"F-D\") \
(\"fails here and only here\"), which is compiled into fixtures.json and covered by its \
frozen EXPECTED_ARTIFACT_DIGEST_HEX (acc13c0d...) that half is TRACKED, NOT FIXED here, \
because correcting it would move the digest and break Packet 2A; do not edit that note on \
the strength of this finding alone.";
#[cfg(test)]
mod tests {
use super::*;
/// The finding must actually name both fixtures — a mutation that
/// silently dropped one of them from the constant would still compile
/// and would still "record a finding," just not the right one.
#[test]
fn the_finding_names_both_f_d_and_f_b() {
assert!(RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D.contains("F-D"));
assert!(RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D.contains("F-B"));
}
/// The universal claim must be qualified — an unqualified "any RTL run
/// of two or more graphemes diverges" is false (a palindromic run does
/// not), which is exactly the over-claim B3 asked to be narrowed.
#[test]
fn the_finding_qualifies_the_claim_as_non_palindromic() {
assert!(RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D.contains("non-palindromic"));
}
/// The digest-bound, tracked-not-fixed half of the finding must name the
/// actual frozen digest prefix and say explicitly that it is not fixed
/// here — a reader skimming only for "is this fixed" must not be able to
/// mistake "recorded" for "corrected."
#[test]
fn the_finding_names_the_frozen_digest_and_says_tracked_not_fixed() {
assert!(RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D.contains("acc13c0d"));
assert!(RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D.contains("TRACKED, NOT FIXED"));
assert!(RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D.contains("a11y.rs"));
}
}

View File

@ -0,0 +1,971 @@
//! Packet 2B-A: the check-5 accessibility oracle's comparison-data half.
//!
//! `spec/CONTRACT_EDITOR_T4_SPIKE.md` pin 13 requires this oracle to be
//! committed and reviewed **before any candidate builds a tree against it** —
//! if a candidate wrote the verifier, the oracle would be shaped by that
//! candidate's tree, which is exactly what pin 13 forbids. This crate is
//! therefore a separate, candidate-neutral packet from either Round 2
//! candidate: it reads the already-committed, already-validated
//! `round2-textkit/fixtures.json` (`ROUND2_TEXT_RECIPE.md` §8;
//! `round2_textkit::a11y`) and derives every byte string a live AT-SPI
//! readback would need to compare against, so the verifier's classification
//! is a comparison against precommitted data, never a heuristic guess about
//! what a wrong name "looks like" (`ROUND2_TEXT_RECIPE.md` §8.1).
//!
//! ## What is derived, and what is restated
//!
//! `expected_name` / `expected_name_hex` / `expected_name_byte_len` and the
//! at-spi2 accepted/prohibited role sets are **restated** — they already
//! exist verbatim on each fixture's `SpikeAccessibilityExpectation`
//! (`round2_textkit::a11y`), computed and validated there. This crate does
//! not recompute them from `resolved.text` a second time; it reads the
//! oracle's own already-validated fields, the same discipline
//! `round2-textkit::output::FixtureFile::validate` uses for everything else.
//!
//! `alternative_forms` and `visual_order_name` are **derived** here, from
//! `SpikeResolvedText`'s own segment and cluster data — never hard-coded to a
//! particular codepoint or glyph id, so the derivation is reproducible from
//! `fixtures.json` alone and does not silently drift from it:
//!
//! * **`name-normalized`** — the NFC normalization of `text`
//! (`unicode-normalization`). Differs only for F-E (recipe §2: F-E is
//! deliberately NFD).
//! * **`name-drops-unresolved-codepoints`** — `text` with every segment whose
//! `face` is `None` removed (`SpikeShapedSegment::face`, `W3-F3`). Differs
//! only for F-C, whose U+0627 is covered by neither declared face.
//! * **`name-is-shaped-glyphs`** — two independently derived forms, both
//! modelling "the tree exposes what was drawn rather than what was said":
//! a cluster-collapse form ([`shaped_glyphs_form`]) and, where derivable, a
//! standard-ligature presentation-form substitution
//! ([`shaped_glyphs_presentation_form`]). F-A's `ff`/`fi` ligatures are the
//! case this fixture set exercises for both. See each function's doc
//! comment for exactly what it does and does not derive from the fixture
//! record.
//! * **`visual_order_name`** — concatenates every segment's source text in
//! the *stored* (logical) segment order, but reverses an `Rtl` segment's
//! own text by extended grapheme cluster before appending it. This
//! reproduces "a tree assembled by walking the visual runs left to right"
//! (recipe §8.1) for every fixture in this set, all of which nest at most
//! one `Rtl` run inside an `Ltr` base paragraph (recipe §4: base level 0,
//! Hebrew segments at level 1) — a single odd-level run does not change
//! the *order* of the run sequence under UAX#9 reordering, only the
//! *internal* order of that run's own text. **This is not a general bidi
//! run-reordering implementation**; it is correct for this fixture set and
//! would need revisiting for a fixture with nested embedding levels beyond
//! 0/1, which none of F-A..F-E have (measured, recipe §4). It also
//! diverges from the recipe's own claim about which fixture this
//! is unique to — see [`findings::RECIPE_F1_VISUAL_ORDER_NOT_UNIQUE_TO_F_D`].
//!
//! ## Fail-closed on a colliding classification (O1)
//!
//! An earlier version of this crate could emit the *same string* under two
//! different `PROHIBITED_OUTCOMES` names for one fixture — F-C's unresolved
//! cluster produced `"Coro "` under both `name-drops-unresolved-codepoints`
//! and `name-is-shaped-glyphs`, because "drop the unresolved codepoint" and
//! "collapse a zero-glyph cluster" were, for that cluster, the same
//! operation. Which classification a verifier reported was then an artifact
//! of `BTreeMap` iteration (alphabetical) order, not a property of the
//! observation — the oracle was returning two different confident answers
//! for one input. That is fixed two ways, and both are load-bearing:
//!
//! 1. [`shaped_glyphs_form`] no longer collapses a *fully* unresolved
//! cluster (zero glyphs) — collapsing to "what was drawn" presumes
//! something was drawn; a wholly unresolved cluster's only legitimate
//! classification is `name-drops-unresolved-codepoints`. This is enough
//! to make F-C's two forms genuinely equal to `expected_name` again (no
//! codepoint was shape-collapsed), so `name-is-shaped-glyphs` is correctly
//! omitted for F-C by the ordinary omit-if-identical rule.
//! 2. [`build_expectation`] additionally **refuses to build** a fixture whose
//! candidate forms collide across two different outcome names, panicking
//! and naming the fixture and both outcomes — a generation-time backstop
//! for any future fixture or derivation that reintroduces the same
//! ambiguity, independent of whether fix 1 above happens to prevent it.
use std::collections::BTreeMap;
use round2_textkit::a11y::PROHIBITED_OUTCOMES;
use round2_textkit::output::{FixtureFile, FixtureRecord};
use round2_textkit::types::{SpikeResolvedText, SpikeTextDirection};
use serde::{Deserialize, Serialize};
use unicode_normalization::UnicodeNormalization;
use unicode_segmentation::UnicodeSegmentation;
pub mod findings;
/// The one platform row this oracle emits: this machine's live AT client is
/// AT-SPI2 (recipe §8.2, round0-evidence's precedent). Candidates targeting
/// another platform stay covered by the recipe's own table; encoding all five
/// rows here would not make them checkable on a machine that cannot reach
/// them.
pub const PLATFORM: &str = "at-spi2";
/// One fixture's precommitted check-5 comparison data.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FixtureExpectation {
pub fixture_id: String,
/// The exact source string, restated from
/// `SpikeAccessibilityExpectation::name` (`round2_textkit::a11y`), not
/// recomputed — see the module doc comment.
pub expected_name: String,
pub expected_name_hex: String,
pub expected_name_byte_len: usize,
/// This machine's platform row (`at-spi2`) of recipe §8.2's accepted-role
/// table, restated from the fixture's own
/// `SpikeAccessibilityExpectation::accepted_roles`.
pub accepted_roles: Vec<String>,
pub prohibited_roles: Vec<String>,
/// D1: the per-segment source texts, in the segments' own stored
/// (logical, ascending-source) order — e.g. F-C's `["Coro ", "ا"]`,
/// F-D's `["Allegro ", "אבג", " con brio"]`. §8.1 explicitly permits "a
/// tree that exposes one text node per direction run," and §8.3
/// requires an unresolved codepoint (F-C's `ا`) to appear in the name
/// regardless of whether it drew ink — but a lone unresolved segment can
/// be a single character, which falls below any reasonable
/// coincidence-guarded length floor a verifier-side substring rule would
/// use. This field lets the verifier match a node's name against a
/// precommitted exact component instead of guessing from length alone —
/// the same "precommitted comparison data, not a heuristic" discipline
/// this whole struct already uses everywhere else. `"".join(source_atoms)
/// == expected_name` always holds (see `source_atoms`'s own doc comment
/// and its test coverage).
pub source_atoms: Vec<String>,
/// Keyed by a `PROHIBITED_OUTCOMES` name; every precommitted string that
/// classification would produce for this fixture, matched if the
/// observed name equals **any** entry in the list (O2: a single outcome
/// can have more than one plausible precommitted rendering — e.g.
/// `name-is-shaped-glyphs` carries both a cluster-collapse form and a
/// standard-ligature presentation-form substitution for F-A). An outcome
/// absent from this map produced no form distinguishable from
/// `expected_name` for this fixture (see the module doc comment) and so
/// cannot classify anything. The same string never appears under two
/// different outcome keys for one fixture — [`build_expectation`]
/// refuses to build a file where it would (O1).
pub alternative_forms: BTreeMap<String, Vec<String>>,
/// The concatenation a tree assembled by walking visual runs left to
/// right would produce, only when it differs from `expected_name` (§8.1).
#[serde(skip_serializing_if = "Option::is_none")]
pub visual_order_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub visual_order_name_hex: Option<String>,
}
/// The complete artifact `a11y_expectations.json` carries.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct ExpectationsFile {
pub contract: String,
pub recipe: String,
pub platform: String,
/// Traceability to the exact `fixtures.json` this file was derived from
/// (`round2_textkit::output::artifact_digest`) — so a verifier run
/// against a stale copy of either file is a detectable mismatch rather
/// than a silent one, the same discipline `fixtures.json` itself uses for
/// the two declared face hashes.
pub source_fixtures_digest: String,
pub fixtures: Vec<FixtureExpectation>,
}
fn hex_lower(bytes: &[u8]) -> String {
use std::fmt::Write as _;
let mut s = String::with_capacity(bytes.len() * 2);
for b in bytes {
let _ = write!(s, "{b:02x}");
}
s
}
/// NFC normalization of `text`. §8.3's `name-normalized`: F-E's NFD source is
/// the only fixture where this differs from `text`.
pub fn nfc_form(text: &str) -> String {
text.nfc().collect()
}
/// `text` with every segment whose `face` is `None` removed, in the
/// segments' own stored (logical, ascending-source) order. §8.3's
/// `name-drops-unresolved-codepoints`: F-C's U+0627 (covered by neither
/// declared face) is the only case in this fixture set.
///
/// Derived entirely from `resolved.segments[*].face` and `.source` — never
/// from a hard-coded codepoint, so a future fixture with a different
/// uncovered span is handled the same way without a code change.
pub fn drop_unresolved_codepoints_form(resolved: &SpikeResolvedText) -> String {
let mut out = String::new();
for seg in &resolved.segments {
if seg.face.is_none() {
continue;
}
let start = seg.source.start as usize;
let end = seg.source.end as usize;
out.push_str(&resolved.text[start..end]);
}
out
}
/// The per-segment source texts, in the segments' own stored (logical,
/// ascending-source) order (D1). Every segment contributes an atom,
/// resolved or not — F-C's unresolved `ا` is included exactly like any
/// other segment, because the property this field exists to let a verifier
/// check ("does some node's name match one exact source component") is
/// just as true for an unresolved segment as a resolved one, and singling
/// it out would be exactly the kind of per-fixture special case this crate
/// avoids elsewhere.
///
/// Derived entirely from `resolved.segments[*].source` — never from a
/// hard-coded codepoint or fixture id, so a future fixture's own segment
/// boundaries are picked up the same way without a code change.
/// `source_atoms(resolved).concat() == resolved.text` always holds, because
/// W3 invariant 2 (asserted elsewhere in this pipeline) requires segment
/// source ranges to partition the whole string totally, in logical order.
pub fn source_atoms(resolved: &SpikeResolvedText) -> Vec<String> {
resolved
.segments
.iter()
.map(|seg| {
let start = seg.source.start as usize;
let end = seg.source.end as usize;
resolved.text[start..end].to_string()
})
.collect()
}
/// The run's text as a tree exposing "what was drawn" rather than "what was
/// said" would read it, by collapsing each cluster to as many leading
/// graphemes as it has glyphs. §8.3's `name-is-shaped-glyphs`: F-A's `ff`/`fi`
/// ligatures are the case this fixture set exercises.
///
/// Walks `resolved.clusters.clusters` in its own documented ascending-source
/// order (`SpikeClusterMap`'s doc comment). For a cluster whose glyph count is
/// **strictly between zero and** its `grapheme_count` — a genuine ligature
/// drew fewer, but more than zero, glyphs than there are graphemes to
/// report — only that many leading graphemes of the cluster's own source text
/// are kept. A cluster with `glyphs == graphemes` (ordinary) or `glyphs == 0`
/// (**wholly unresolved** — O1: nothing was drawn, so there is no partial
/// "what was drawn" to report; that is `name-drops-unresolved-codepoints`'s
/// classification, not this one) contributes its whole source text unchanged.
/// Nothing here is specific to `ff`/`fi`: the rule is "one reportable unit per
/// glyph, when at least one glyph exists," derived purely from each cluster's
/// own `glyph_indices.len()` and `grapheme_count`.
pub fn shaped_glyphs_form(resolved: &SpikeResolvedText) -> String {
let mut out = String::new();
for cluster in &resolved.clusters.clusters {
let start = cluster.source.start as usize;
let end = cluster.source.end as usize;
let chunk = &resolved.text[start..end];
let glyph_count = cluster.glyph_indices.len() as u32;
if glyph_count > 0 && glyph_count < cluster.grapheme_count {
let kept: String = chunk.graphemes(true).take(glyph_count as usize).collect();
out.push_str(&kept);
} else {
out.push_str(chunk);
}
}
out
}
/// The standard Unicode Latin ligature presentation forms (Alphabetic
/// Presentation Forms block, U+FB00-U+FB06) that a shaper's default `liga`
/// feature can produce. This table is **fixed Unicode data, not derived from
/// `fixtures.json`** — this crate deliberately carries no font/cmap
/// dependency (see the crate doc comment on why: it never touches a live
/// tree, and adding one here would be the wrong layer for it), so there is no
/// way to derive "this glyph id denotes U+FB00" from the fixture record
/// alone. What **is** derived from the fixture, for every entry
/// [`shaped_glyphs_presentation_form`] produces, is *which* clusters this
/// table applies to (the same glyph-count-vs-grapheme-count ligature
/// detection [`shaped_glyphs_form`] uses) and *what source text* each one
/// spans; the table is only ever consulted as a lookup keyed by that
/// already-derived source text, never used to invent a cluster boundary of
/// its own.
const LATIN_LIGATURE_PRESENTATION_FORMS: &[(&str, char)] = &[
("ff", '\u{FB00}'),
("fi", '\u{FB01}'),
("fl", '\u{FB02}'),
("ffi", '\u{FB03}'),
("ffl", '\u{FB04}'),
("st", '\u{FB06}'),
];
/// A second, independently plausible rendering of "the tree exposes what was
/// drawn" (§8.3's `name-is-shaped-glyphs`): a tree that reverse-mapped glyph
/// ids through a cmap would most plausibly emit the *standard ligature
/// presentation-form codepoint* (e.g. U+FB00 for `ff`) rather than
/// [`shaped_glyphs_form`]'s truncate-to-glyph-count text. Returns `None` if
/// this fixture has no ligature cluster, **or** if it has one whose source
/// text is not in [`LATIN_LIGATURE_PRESENTATION_FORMS`] — this function never
/// guesses a codepoint it cannot look up.
pub fn shaped_glyphs_presentation_form(resolved: &SpikeResolvedText) -> Option<String> {
let mut out = String::new();
let mut substituted_any = false;
for cluster in &resolved.clusters.clusters {
let start = cluster.source.start as usize;
let end = cluster.source.end as usize;
let chunk = &resolved.text[start..end];
let glyph_count = cluster.glyph_indices.len() as u32;
let is_ligature = glyph_count > 0 && glyph_count < cluster.grapheme_count;
if is_ligature {
match LATIN_LIGATURE_PRESENTATION_FORMS
.iter()
.find(|(seq, _)| *seq == chunk)
{
Some((_, presentation_char)) => {
out.push(*presentation_char);
substituted_any = true;
}
// A ligature cluster whose source text has no known
// presentation-form codepoint: this function cannot honestly
// produce a full-string answer, so it produces none at all
// rather than a partially-substituted guess.
None => return None,
}
} else {
out.push_str(chunk);
}
}
substituted_any.then_some(out)
}
/// The concatenation a tree assembled by walking the run's visual runs left
/// to right would produce (§8.1). See the module doc comment for exactly
/// what this does and does not model.
pub fn visual_order_form(resolved: &SpikeResolvedText) -> String {
let mut out = String::new();
for seg in &resolved.segments {
let start = seg.source.start as usize;
let end = seg.source.end as usize;
let chunk = &resolved.text[start..end];
match seg.direction {
SpikeTextDirection::Rtl => {
let reversed: String = chunk.graphemes(true).rev().collect();
out.push_str(&reversed);
}
SpikeTextDirection::Ltr => out.push_str(chunk),
}
}
out
}
/// Groups a fixture's candidate `(outcome, form)` pairs into
/// `alternative_forms`, in three steps:
///
/// 1. drop any candidate whose form is byte-identical to `expected_name` (it
/// cannot classify anything — see the module doc comment);
/// 2. **refuse** (panic, naming `fixture_id` and both outcomes) if the same
/// remaining form string is produced by two *different* outcome names —
/// O1's fail-closed backstop, independent of whichever derivation bug did
/// or did not cause it;
/// 3. otherwise group by outcome, deduplicating repeated identical forms
/// within one outcome's own list (the same classification derived twice is
/// not a collision), and drop any outcome left with an empty list.
///
/// Kept as its own function, separate from [`build_expectation`], so it has a
/// unit test that can hand-construct a collision without needing a real
/// `FixtureRecord` to provoke one.
fn group_alternative_forms(
fixture_id: &str,
expected_name: &str,
candidates: Vec<(&'static str, String)>,
) -> BTreeMap<String, Vec<String>> {
let mut owner_of: BTreeMap<String, &'static str> = BTreeMap::new();
let mut grouped: BTreeMap<String, Vec<String>> = BTreeMap::new();
for (outcome, form) in candidates {
debug_assert!(
PROHIBITED_OUTCOMES.contains(&outcome),
"{outcome} must be one of round2_textkit::a11y::PROHIBITED_OUTCOMES"
);
if form == expected_name {
continue;
}
match owner_of.get(&form) {
Some(&existing_outcome) if existing_outcome != outcome => {
panic!(
"{fixture_id}: alternative forms {existing_outcome:?} and {outcome:?} both \
produce {form:?} an oracle that returns two different classifications for \
the same observed string must fail closed, not pick one by BTreeMap \
iteration order (O1). Fix the derivation so the two outcomes do not collide, \
or establish that only one of them legitimately applies to this fixture."
);
}
Some(_) => {
// Same outcome producing an identical form a second time
// (e.g. two independent derivations that happen to agree) —
// not a collision, just redundant; skip the duplicate.
}
None => {
owner_of.insert(form.clone(), outcome);
grouped.entry(outcome.to_string()).or_default().push(form);
}
}
}
grouped
}
/// Builds one fixture's [`FixtureExpectation`] from its already-validated
/// `FixtureRecord`.
///
/// `expected_name` and the role sets are restated from
/// `record.accessibility`, not recomputed from `record.resolved.text` — that
/// field was already checked against the recipe §2 literal by
/// `FixtureFile::validate` (via `load_fixtures`) before this function ever
/// runs, so re-deriving it here would be a second, redundant source of
/// truth rather than a check.
///
/// Panics (via [`group_alternative_forms`]) if two different outcome names
/// would classify the same observed string for this fixture (O1).
pub fn build_expectation(record: &FixtureRecord) -> FixtureExpectation {
let a = &record.accessibility;
let resolved = &record.resolved;
let accepted_roles = a
.accepted_roles
.iter()
.find(|m| m.platform == PLATFORM)
.unwrap_or_else(|| panic!("{}: no {PLATFORM} row in accepted_roles", record.id))
.tokens
.clone();
let prohibited_roles = a
.prohibited_roles
.iter()
.find(|m| m.platform == PLATFORM)
.unwrap_or_else(|| panic!("{}: no {PLATFORM} row in prohibited_roles", record.id))
.tokens
.clone();
let mut candidates: Vec<(&'static str, String)> = vec![
("name-normalized", nfc_form(&a.name)),
(
"name-drops-unresolved-codepoints",
drop_unresolved_codepoints_form(resolved),
),
("name-is-shaped-glyphs", shaped_glyphs_form(resolved)),
];
if let Some(presentation) = shaped_glyphs_presentation_form(resolved) {
candidates.push(("name-is-shaped-glyphs", presentation));
}
let alternative_forms = group_alternative_forms(&record.id, &a.name, candidates);
let visual = visual_order_form(resolved);
let (visual_order_name, visual_order_name_hex) = if visual != a.name {
let hex = hex_lower(visual.as_bytes());
(Some(visual), Some(hex))
} else {
(None, None)
};
FixtureExpectation {
fixture_id: record.id.clone(),
expected_name: a.name.clone(),
expected_name_hex: a.name_bytes_hex.clone(),
expected_name_byte_len: a.name_byte_len,
accepted_roles,
prohibited_roles,
source_atoms: source_atoms(resolved),
alternative_forms,
visual_order_name,
visual_order_name_hex,
}
}
/// Builds the complete [`ExpectationsFile`] from an already-loaded, already-
/// validated `FixtureFile` (`round2_textkit::output::load_fixtures`).
pub fn build_expectations_file(file: &FixtureFile) -> ExpectationsFile {
ExpectationsFile {
contract: "spec/CONTRACT_EDITOR_T4_SPIKE.md pin 13".to_string(),
recipe: "spikes/editor-toolkit/ROUND2_TEXT_RECIPE.md §8".to_string(),
platform: PLATFORM.to_string(),
source_fixtures_digest: round2_textkit::output::artifact_digest(file),
fixtures: file.fixtures.iter().map(build_expectation).collect(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use round2_textkit::faces::{resolve_declared_chain, FaceResolution, LoadedFace};
use round2_textkit::fixtures::{build_fixture, FIXTURES};
use round2_textkit::output::build_fixture_file;
/// Builds a real `FixtureFile` end to end against the actual declared
/// faces on this machine, the same path `round2-textkit`'s own tests and
/// `bin/generate.rs` take. `None` (test skipped, not failed — pin 14) if
/// either declared face is absent; on this development machine both are
/// present.
fn real_fixture_file() -> Option<FixtureFile> {
let resolved = resolve_declared_chain();
let mut loaded: Vec<LoadedFace> = Vec::new();
for r in resolved {
match r {
FaceResolution::Loaded(lf) => loaded.push(lf),
FaceResolution::Missing { .. } => return None,
}
}
let built: Vec<(String, String, SpikeResolvedText)> = FIXTURES
.iter()
.enumerate()
.map(|(i, def)| {
let rt = build_fixture(def, &loaded, i as u64);
(def.id.to_string(), def.purpose.to_string(), rt)
})
.collect();
Some(build_fixture_file(&loaded, built).expect("every fixture has a precommitted note"))
}
fn require_file() -> FixtureFile {
real_fixture_file().expect(
"this test requires the two round2-textkit declared faces to be present on the \
machine running it",
)
}
fn expectation_for<'a>(exp: &'a ExpectationsFile, id: &str) -> &'a FixtureExpectation {
exp.fixtures
.iter()
.find(|f| f.fixture_id == id)
.unwrap_or_else(|| panic!("no expectation built for {id}"))
}
/// F-E's NFC form must differ from its (NFD) source text — the whole
/// reason F-E exists (recipe §2). If `nfc_form` stopped normalizing, or
/// F-E's source stopped being NFD, this fails.
#[test]
fn f_e_nfc_form_differs_from_its_text() {
let file = require_file();
let f_e = file.fixtures.iter().find(|f| f.id == "F-E").unwrap();
let nfc = nfc_form(&f_e.resolved.text);
assert_ne!(
nfc, f_e.resolved.text,
"F-E's NFC form must differ from its NFD source"
);
// And it must actually surface in alternative_forms, keyed correctly.
let exp = build_expectations_file(&file);
let e = expectation_for(&exp, "F-E");
assert_eq!(
e.alternative_forms.get("name-normalized"),
Some(&vec![nfc]),
"F-E must carry a name-normalized alternative form equal to its NFC"
);
}
/// F-C's dropped-codepoint form must be shorter than its source by
/// *exactly* the byte span of its unresolved (face: None) segment — not
/// merely shorter by some amount.
#[test]
fn f_c_dropped_codepoint_form_is_shorter_by_exactly_the_unresolved_span() {
let file = require_file();
let f_c = file.fixtures.iter().find(|f| f.id == "F-C").unwrap();
let unresolved_span: usize = f_c
.resolved
.segments
.iter()
.filter(|s| s.face.is_none())
.map(|s| (s.source.end - s.source.start) as usize)
.sum();
assert!(
unresolved_span > 0,
"anchor: F-C must have at least one unresolved segment"
);
let dropped = drop_unresolved_codepoints_form(&f_c.resolved);
assert_eq!(
f_c.resolved.text.len() - dropped.len(),
unresolved_span,
"F-C's dropped-codepoint form must be shorter by exactly its unresolved span"
);
assert_ne!(dropped, f_c.resolved.text);
let exp = build_expectations_file(&file);
let e = expectation_for(&exp, "F-C");
assert_eq!(
e.alternative_forms.get("name-drops-unresolved-codepoints"),
Some(&vec![dropped])
);
}
/// O1's regression lock: F-C must carry exactly one alternative-outcome
/// classification (`name-drops-unresolved-codepoints`), never a second,
/// colliding `name-is-shaped-glyphs` entry for the same string. Before
/// the O1 fix, [`shaped_glyphs_form`] collapsed F-C's wholly-unresolved
/// cluster to nothing, which is byte-identical to the dropped-codepoint
/// form — this pins that `name-is-shaped-glyphs` is now correctly absent
/// for F-C (because it is byte-identical to `expected_name` once
/// zero-glyph clusters are left untouched), not merely that it happens
/// to agree with the other outcome.
#[test]
fn f_c_carries_no_shaped_glyphs_alternative_form() {
let file = require_file();
let f_c = file.fixtures.iter().find(|f| f.id == "F-C").unwrap();
assert_eq!(
shaped_glyphs_form(&f_c.resolved),
f_c.resolved.text,
"anchor: with the O1 fix, F-C's cluster-collapse form must equal its source text"
);
let exp = build_expectations_file(&file);
let e = expectation_for(&exp, "F-C");
assert!(
!e.alternative_forms.contains_key("name-is-shaped-glyphs"),
"F-C must not carry a name-is-shaped-glyphs alternative form: {:?}",
e.alternative_forms
);
assert_eq!(e.alternative_forms.len(), 1);
}
/// F-A's shaped-glyphs forms must differ from its source text — the
/// `ff`/`fi` ligature case §8.3 names — and both the cluster-collapse
/// form and the standard-ligature presentation-form substitution (O2)
/// must be present in the list.
#[test]
fn f_a_shaped_glyphs_forms_differ_from_its_text() {
let file = require_file();
let f_a = file.fixtures.iter().find(|f| f.id == "F-A").unwrap();
let has_ligature_cluster = f_a
.resolved
.clusters
.clusters
.iter()
.any(|c| (c.glyph_indices.len() as u32) < c.grapheme_count);
assert!(
has_ligature_cluster,
"anchor: F-A must have at least one cluster with fewer glyphs than graphemes"
);
let collapsed = shaped_glyphs_form(&f_a.resolved);
assert_ne!(collapsed, f_a.resolved.text);
let presentation = shaped_glyphs_presentation_form(&f_a.resolved).expect(
"F-A's ligature clusters (ff, fi) are both in LATIN_LIGATURE_PRESENTATION_FORMS",
);
assert_ne!(presentation, f_a.resolved.text);
assert_ne!(
presentation, collapsed,
"the two shaped-glyphs forms must be genuinely distinct renderings"
);
assert!(
presentation.contains('\u{FB00}'),
"F-A's presentation form must substitute U+FB00 for the ff ligature: {presentation:?}"
);
assert!(
presentation.contains('\u{FB01}'),
"F-A's presentation form must substitute U+FB01 for the fi ligature: {presentation:?}"
);
let exp = build_expectations_file(&file);
let e = expectation_for(&exp, "F-A");
let forms = e
.alternative_forms
.get("name-is-shaped-glyphs")
.expect("F-A must carry a name-is-shaped-glyphs entry");
assert!(forms.contains(&collapsed), "{forms:?}");
assert!(forms.contains(&presentation), "{forms:?}");
assert_eq!(forms.len(), 2, "{forms:?}");
}
/// F-D's visual-order form must differ from its logical text — the
/// composition trap §8.1 names.
#[test]
fn f_d_visual_order_form_differs_from_its_logical_text() {
let file = require_file();
let f_d = file.fixtures.iter().find(|f| f.id == "F-D").unwrap();
let has_rtl_segment = f_d
.resolved
.segments
.iter()
.any(|s| matches!(s.direction, SpikeTextDirection::Rtl));
assert!(has_rtl_segment, "anchor: F-D must have an Rtl segment");
let visual = visual_order_form(&f_d.resolved);
assert_ne!(visual, f_d.resolved.text);
let exp = build_expectations_file(&file);
let e = expectation_for(&exp, "F-D");
assert_eq!(e.visual_order_name.as_ref(), Some(&visual));
assert_eq!(
e.visual_order_name_hex.as_deref(),
Some(hex_lower(visual.as_bytes()).as_str())
);
}
/// D1: F-C's source atoms must be exactly its two segments, and the
/// unresolved one must stand alone as a single character — the specific
/// case a verifier-side length-2 substring rule cannot catch, and the
/// whole reason this field exists.
#[test]
fn f_c_source_atoms_are_its_two_segments_one_of_them_single_character() {
let file = require_file();
let f_c = file.fixtures.iter().find(|f| f.id == "F-C").unwrap();
assert_eq!(
f_c.resolved.segments.len(),
2,
"anchor: F-C must have two segments"
);
let expected: Vec<String> = f_c
.resolved
.segments
.iter()
.map(|s| f_c.resolved.text[s.source.start as usize..s.source.end as usize].to_string())
.collect();
let atoms = source_atoms(&f_c.resolved);
assert_eq!(atoms, expected);
let (_, unresolved_atom) = f_c
.resolved
.segments
.iter()
.zip(atoms.iter())
.find(|(s, _)| s.face.is_none())
.expect("anchor: F-C must have an unresolved segment");
assert_eq!(
unresolved_atom.chars().count(),
1,
"F-C's unresolved atom must be exactly one character: {unresolved_atom:?}"
);
let exp = build_expectations_file(&file);
let e = expectation_for(&exp, "F-C");
assert_eq!(e.source_atoms, atoms);
}
/// D1: F-D's source atoms must be exactly its three segments.
#[test]
fn f_d_source_atoms_are_its_three_segments() {
let file = require_file();
let f_d = file.fixtures.iter().find(|f| f.id == "F-D").unwrap();
assert_eq!(
f_d.resolved.segments.len(),
3,
"anchor: F-D must have three segments"
);
let expected: Vec<String> = f_d
.resolved
.segments
.iter()
.map(|s| f_d.resolved.text[s.source.start as usize..s.source.end as usize].to_string())
.collect();
let atoms = source_atoms(&f_d.resolved);
assert_eq!(atoms, expected);
let exp = build_expectations_file(&file);
let e = expectation_for(&exp, "F-D");
assert_eq!(e.source_atoms, atoms);
}
/// Every fixture's source atoms must concatenate back to its own
/// `expected_name` — the general partition property `source_atoms`'s own
/// doc comment claims, checked here on the real generated data rather
/// than only asserted in prose.
#[test]
fn source_atoms_concatenate_to_expected_name_for_every_fixture() {
let file = require_file();
let exp = build_expectations_file(&file);
for f in &exp.fixtures {
let joined: String = f.source_atoms.concat();
assert_eq!(
joined, f.expected_name,
"{}: source_atoms must concatenate to expected_name",
f.fixture_id
);
}
}
/// An alternative form byte-identical to `expected_name` must be omitted
/// entirely, never present with a value equal to the expectation — the
/// mutation this guards against is a verifier that reports a "match" as
/// a diagnosed FAIL because a no-op entry happened to be present.
#[test]
fn identical_alternative_forms_are_omitted_not_recorded_as_equal() {
let file = require_file();
let exp = build_expectations_file(&file);
for f in &exp.fixtures {
for (outcome, forms) in &f.alternative_forms {
assert!(
!forms.is_empty(),
"{}: {outcome} must not be present with an empty list",
f.fixture_id
);
for form in forms {
assert_ne!(
form, &f.expected_name,
"{}: alternative form {outcome} must not be recorded when byte-identical \
to expected_name",
f.fixture_id
);
}
}
if let Some(v) = &f.visual_order_name {
assert_ne!(v, &f.expected_name, "{}: visual_order_name", f.fixture_id);
}
}
}
/// No fixture's `alternative_forms` may contain the same string under two
/// different outcome keys (O1) — re-checked here on the real, generated
/// data, in addition to [`group_alternative_forms_refuses_a_collision`]'s
/// synthetic unit test.
#[test]
fn no_fixture_has_the_same_form_under_two_outcomes() {
let file = require_file();
let exp = build_expectations_file(&file);
for f in &exp.fixtures {
let mut seen: BTreeMap<&String, &String> = BTreeMap::new();
for (outcome, forms) in &f.alternative_forms {
for form in forms {
if let Some(existing) = seen.insert(form, outcome) {
panic!(
"{}: {form:?} appears under both {existing:?} and {outcome:?}",
f.fixture_id
);
}
}
}
}
}
/// Every alternative-form key must be one of `PROHIBITED_OUTCOMES` — a
/// typo'd or invented key would silently fail to classify anything the
/// verifier actually checks for.
#[test]
fn every_alternative_form_key_is_a_prohibited_outcome() {
let file = require_file();
let exp = build_expectations_file(&file);
for f in &exp.fixtures {
for outcome in f.alternative_forms.keys() {
assert!(
PROHIBITED_OUTCOMES.contains(&outcome.as_str()),
"{}: {outcome:?} is not in PROHIBITED_OUTCOMES",
f.fixture_id
);
}
}
}
/// The at-spi2 role rows restated here must equal
/// `round2_textkit::a11y`'s own at-spi2 row — this is the platform this
/// machine's live AT-SPI2 client actually queries (recipe §8.2,
/// round0-evidence's precedent).
#[test]
fn accepted_and_prohibited_roles_match_the_atspi2_row() {
let file = require_file();
let exp = build_expectations_file(&file);
let expected_accepted: Vec<String> = round2_textkit::a11y::ACCEPTED_ROLE_TABLE
.iter()
.find(|(p, _)| *p == PLATFORM)
.unwrap()
.1
.iter()
.map(|s| s.to_string())
.collect();
let expected_prohibited: Vec<String> = round2_textkit::a11y::PROHIBITED_ROLE_TABLE
.iter()
.find(|(p, _)| *p == PLATFORM)
.unwrap()
.1
.iter()
.map(|s| s.to_string())
.collect();
for f in &exp.fixtures {
assert_eq!(f.accepted_roles, expected_accepted);
assert_eq!(f.prohibited_roles, expected_prohibited);
}
}
/// JSON round-trips without loss — the shape a consumer other than this
/// crate (`a11y-verifier/verify.py`) will actually read.
#[test]
fn json_round_trip_preserves_the_expectations() {
let file = require_file();
let exp = build_expectations_file(&file);
let json = serde_json::to_string_pretty(&exp).unwrap();
let reloaded: ExpectationsFile = serde_json::from_str(&json).unwrap();
assert_eq!(reloaded, exp);
}
/// All five fixtures must be present, in order.
#[test]
fn all_five_fixtures_are_present_in_order() {
let file = require_file();
let exp = build_expectations_file(&file);
let ids: Vec<&str> = exp.fixtures.iter().map(|f| f.fixture_id.as_str()).collect();
assert_eq!(ids, vec!["F-A", "F-B", "F-C", "F-D", "F-E"]);
}
/// B2: `source_fixtures_digest` must equal
/// `round2_textkit::output::expected_artifact_digest()` — the same
/// literal `round2-textkit`'s own `bin/generate` prints and its
/// `FixtureFile::validate` checks the *loaded* file against. This is the
/// generation-time half of B2's staleness guard: if `fixtures.json` ever
/// legitimately changes (a new frozen digest), this test catches that
/// `round2-a11y-oracle` was not regenerated against it, at test time,
/// before `a11y-verifier/verify.py`'s `--expect-source-digest` check
/// would ever catch it live.
#[test]
fn source_fixtures_digest_matches_round2_textkit_expected_digest() {
let file = require_file();
let exp = build_expectations_file(&file);
assert_eq!(
exp.source_fixtures_digest,
round2_textkit::output::expected_artifact_digest()
);
}
// ---- O1: group_alternative_forms, exercised directly (no live fixture
// data required, so the collision-refusal logic itself is under test
// regardless of whether any current fixture happens to trigger it). ----
#[test]
fn group_alternative_forms_refuses_a_collision() {
let result = std::panic::catch_unwind(|| {
group_alternative_forms(
"F-TEST",
"expected",
vec![
("name-normalized", "same-string".to_string()),
("name-is-shaped-glyphs", "same-string".to_string()),
],
)
});
let err = result.expect_err("a collision between two outcomes must panic");
let msg = err
.downcast_ref::<String>()
.cloned()
.or_else(|| err.downcast_ref::<&str>().map(|s| s.to_string()))
.expect("panic payload must be a string");
assert!(msg.contains("F-TEST"), "{msg}");
assert!(msg.contains("name-normalized"), "{msg}");
assert!(msg.contains("name-is-shaped-glyphs"), "{msg}");
}
/// Mutation guard: the same outcome producing the same form twice (e.g.
/// two derivations that happen to agree) must NOT panic — only a
/// cross-outcome collision is refused. Without this test, a mutation that
/// made the collision check fire on any duplicate (not just a
/// cross-outcome one) would still pass
/// `group_alternative_forms_refuses_a_collision` above.
#[test]
fn group_alternative_forms_deduplicates_a_same_outcome_repeat_without_panicking() {
let grouped = group_alternative_forms(
"F-TEST",
"expected",
vec![
("name-normalized", "same-string".to_string()),
("name-normalized", "same-string".to_string()),
],
);
assert_eq!(
grouped.get("name-normalized"),
Some(&vec!["same-string".to_string()])
);
}
#[test]
fn group_alternative_forms_omits_forms_identical_to_expected_name() {
let grouped = group_alternative_forms(
"F-TEST",
"expected",
vec![
("name-normalized", "expected".to_string()),
("name-is-shaped-glyphs", "different".to_string()),
],
);
assert!(!grouped.contains_key("name-normalized"));
assert_eq!(
grouped.get("name-is-shaped-glyphs"),
Some(&vec!["different".to_string()])
);
}
}

View File

@ -0,0 +1,24 @@
[package]
name = "round2-candidatekit"
version = "0.1.0"
edition.workspace = true
publish.workspace = true
# Packet 2B-0 (ROUND2_TEXT_RECIPE.md, spec/CONTRACT_EDITOR_T4_SPIKE.md pins
# 8, 9, 10, 13, 14): the ONLY code shared between the two Round 2 text
# candidates (C1 = egui+lyon, C2 = vello). See src/lib.rs's crate doc
# comment for the neutrality boundary this crate exists to hold — it loads
# and validates Packet 2A's fixtures/probes/reference apparatus, and defines
# the shared report shape and scoring rule both candidates are measured
# against. It does NOT render, resolve hit tests, or build accessibility
# trees.
#
# tests/dependency_deny_list.rs enforces the boundary by reading THIS file
# at test time, not by convention: it fails if a rendering, windowing, GPU,
# or platform-accessibility crate is ever added to [dependencies] below.
[dependencies]
serde = { version = "1", features = ["derive"] }
serde_json = "1"
round2-textkit = { path = "../round2-textkit" }
round2-diff = { path = "../round2-diff" }

View File

@ -0,0 +1,332 @@
//! Loads and validates the candidate-neutral apparatus Packet 2A built:
//! fixtures, the hit-test probe table, and the per-fixture reference raster
//! + regions. Every failure here names the specific file and what was wrong
//! with it — see [`load_all`].
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use round2_diff::GlyphRegion;
use round2_textkit::hittest::HitTestProbeFile;
use round2_textkit::output::FixtureFile;
/// Pin 4's offscreen target, restated as a literal (the same discipline
/// every other crate in this workspace uses: a loader checks a file against
/// a stated constant, never trusts the file to agree with itself).
pub const WIDTH: u32 = 1920;
pub const HEIGHT: u32 = 1080;
const EXPECTED_RGBA_LEN: usize = (WIDTH as usize) * (HEIGHT as usize) * 4;
/// The on-disk shape of one entry in `<id>.regions.json`
/// (`round2-reference/output/`), matching the fields `round2-reference`'s
/// own `RegionRecord` writes. Deserialized here rather than depended on
/// directly, because `round2-reference` pulls in `round2-svgref`, which
/// pulls in `resvg`/`usvg`/`tiny-skia` — exactly the rendering dependencies
/// this crate's neutrality boundary forbids. The region *files* are neutral
/// data; the crate that produced them is not.
///
/// **This is an implicit cross-crate schema with no shared type** —
/// `round2-reference`'s own `RegionRecord` and this one are two
/// independent hand-written structs that happen to agree on field names.
/// `deny_unknown_fields` is what turns a future drift between them into a
/// *named parse error at this crate's boundary* rather than a silently
/// ignored field: without it, serde drops unknown fields by default, and a
/// field `round2-reference` starts writing (or renames) would pass through
/// here unnoticed.
#[derive(Clone, Debug, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct RegionRecord {
label: String,
x0: u32,
y0: u32,
x1: u32,
y1: u32,
}
impl From<RegionRecord> for GlyphRegion {
fn from(r: RegionRecord) -> Self {
GlyphRegion {
label: r.label,
x0: r.x0,
y0: r.y0,
x1: r.x1,
y1: r.y1,
}
}
}
/// One fixture's reference apparatus: the rasterized reference image
/// (already length-checked), its D4 regions (already checked non-empty),
/// and the paths they were loaded from (traceability for a `FAIL`).
#[derive(Clone, Debug)]
pub struct ReferenceFixture {
pub fixture_id: String,
pub reference_rgba: Vec<u8>,
pub regions: Vec<GlyphRegion>,
pub rgba_path: PathBuf,
pub regions_path: PathBuf,
}
/// Every candidate-neutral input Packet 2A built, loaded and validated in
/// one call ([`load_all`]).
#[derive(Debug)]
pub struct NeutralInputs {
pub fixtures: FixtureFile,
pub hittest_probes: HitTestProbeFile,
/// Keyed by fixture id (`F-A`..`F-E`).
pub reference: BTreeMap<String, ReferenceFixture>,
}
/// Loads `fixtures.json`, `hittest_probes.json`, and every fixture's
/// reference raster + regions, from the standard Packet 2A layout under
/// `spike_root` (`round2-textkit/fixtures.json`,
/// `round2-textkit/hittest_probes.json`,
/// `round2-reference/output/<id>.rgba`,
/// `round2-reference/output/<id>.regions.json`).
///
/// Every failure names the specific file and what was wrong with it:
///
/// - `fixtures.json` / `hittest_probes.json`: read/parse errors, or a
/// [`round2_textkit::output::FixtureFile::validate`] /
/// [`round2_textkit::hittest::HitTestProbeFile::validate`] failure
/// (digest mismatch, probe-table drift, ...) — propagated verbatim; those
/// loaders already name the path and the specific disagreement.
/// - `<id>.rgba`: refused if its length is not exactly `1920 * 1080 * 4`
/// bytes ([`WIDTH`] x [`HEIGHT`] x 4 RGBA8), naming the file and the
/// actual length.
/// - `<id>.regions.json`: refused if missing, unparsable, or **empty**.
/// This crate refuses an empty region list itself, naming the file,
/// rather than silently handing it to `round2_diff::diff` — which also
/// refuses an empty list (`diff` panics on nothing, it returns an `Err`),
/// but with a message that has no idea which file on disk was empty.
pub fn load_all(spike_root: &Path) -> Result<NeutralInputs, String> {
let fixtures_path = spike_root.join("round2-textkit/fixtures.json");
let fixtures = round2_textkit::output::load_fixtures(&fixtures_path)?;
let hittest_path = spike_root.join("round2-textkit/hittest_probes.json");
let hittest_probes = round2_textkit::hittest::load_hittest_probes(&hittest_path, &fixtures)?;
let mut reference = BTreeMap::new();
for f in &fixtures.fixtures {
let rgba_path = spike_root
.join("round2-reference/output")
.join(format!("{}.rgba", f.id));
let rgba = std::fs::read(&rgba_path).map_err(|e| {
format!(
"{}: failed to read reference raster: {e}",
rgba_path.display()
)
})?;
if rgba.len() != EXPECTED_RGBA_LEN {
return Err(format!(
"{}: reference raster is {} bytes, expected exactly {EXPECTED_RGBA_LEN} \
({WIDTH}x{HEIGHT} RGBA8) a short or padded buffer cannot be sampled safely",
rgba_path.display(),
rgba.len()
));
}
let regions_path = spike_root
.join("round2-reference/output")
.join(format!("{}.regions.json", f.id));
let regions_text = std::fs::read_to_string(&regions_path).map_err(|e| {
format!(
"{}: failed to read region file: {e}",
regions_path.display()
)
})?;
let records: Vec<RegionRecord> = serde_json::from_str(&regions_text).map_err(|e| {
format!(
"{}: failed to parse region file: {e}",
regions_path.display()
)
})?;
if records.is_empty() {
return Err(format!(
"{}: region list is empty — refusing here, before this could reach \
round2_diff::diff (which also refuses an empty region list, but with a message \
that does not name which file on disk was empty)",
regions_path.display()
));
}
let regions: Vec<GlyphRegion> = records.into_iter().map(GlyphRegion::from).collect();
reference.insert(
f.id.clone(),
ReferenceFixture {
fixture_id: f.id.clone(),
reference_rgba: rgba,
regions,
rgba_path,
regions_path,
},
);
}
Ok(NeutralInputs {
fixtures,
hittest_probes,
reference,
})
}
#[cfg(test)]
mod tests {
use super::*;
/// The real spike workspace root: this crate's manifest directory is
/// `spikes/editor-toolkit/round2-candidatekit`, one level below root.
fn real_spike_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("..")
}
fn read_real(rel: &str) -> Vec<u8> {
std::fs::read(real_spike_root().join(rel))
.unwrap_or_else(|e| panic!("failed to read real {rel}: {e}"))
}
/// A fresh, uniquely named directory under the OS temp dir (never under
/// the repo working tree, so these tests cannot leave stray files for
/// `git status` to notice), laid out like a spike root's
/// `round2-textkit/` + `round2-reference/output/` — enough for
/// `load_all` to be pointed at it.
fn scratch_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!(
"round2-candidatekit-test-{name}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("round2-textkit")).unwrap();
std::fs::create_dir_all(dir.join("round2-reference/output")).unwrap();
dir
}
fn write(path: &Path, bytes: &[u8]) {
std::fs::write(path, bytes)
.unwrap_or_else(|e| panic!("failed to write {}: {e}", path.display()));
}
/// Copies the real, committed, valid `fixtures.json` and
/// `hittest_probes.json` into `dir` — the two files every scenario
/// below needs unmutated so the failure under test is isolated to the
/// one file each test actually breaks.
fn seed_valid_fixtures_and_hittest(dir: &Path) {
write(
&dir.join("round2-textkit/fixtures.json"),
&read_real("round2-textkit/fixtures.json"),
);
write(
&dir.join("round2-textkit/hittest_probes.json"),
&read_real("round2-textkit/hittest_probes.json"),
);
}
#[test]
fn load_all_succeeds_against_the_real_committed_apparatus() {
let inputs = load_all(&real_spike_root()).expect("real apparatus must load");
assert_eq!(inputs.fixtures.fixtures.len(), 5);
assert_eq!(inputs.reference.len(), 5);
for id in ["F-A", "F-B", "F-C", "F-D", "F-E"] {
assert!(inputs.reference.contains_key(id), "missing {id}");
let rf = &inputs.reference[id];
assert_eq!(rf.reference_rgba.len(), EXPECTED_RGBA_LEN);
assert!(!rf.regions.is_empty());
}
}
/// Required kill: a `.rgba` of the wrong length is refused, naming the
/// file.
#[test]
fn a_wrong_length_rgba_is_refused_and_the_file_is_named() {
let dir = scratch_dir("wrong-length-rgba");
seed_valid_fixtures_and_hittest(&dir);
write(
&dir.join("round2-reference/output/F-A.rgba"),
&vec![0u8; 100],
);
let err = load_all(&dir).unwrap_err();
assert!(err.contains("F-A.rgba"), "{err}");
assert!(err.contains("100 bytes"), "{err}");
assert!(err.contains("8294400"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
/// Required kill: an empty region list is refused here — with a message
/// naming this file — rather than silently reaching
/// `round2_diff::diff`.
#[test]
fn an_empty_region_list_is_refused_before_it_could_reach_diff() {
let dir = scratch_dir("empty-regions");
seed_valid_fixtures_and_hittest(&dir);
write(
&dir.join("round2-reference/output/F-A.rgba"),
&vec![0u8; EXPECTED_RGBA_LEN],
);
write(&dir.join("round2-reference/output/F-A.regions.json"), b"[]");
let err = load_all(&dir).unwrap_err();
assert!(err.contains("F-A.regions.json"), "{err}");
assert!(err.contains("empty"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
/// A missing region file (never written at all, as opposed to written
/// empty) is refused and named — the other half of "missing region
/// file" in the required API's failure list, distinct from the
/// empty-but-present case above.
#[test]
fn a_missing_region_file_is_refused_and_named() {
let dir = scratch_dir("missing-regions");
seed_valid_fixtures_and_hittest(&dir);
write(
&dir.join("round2-reference/output/F-A.rgba"),
&vec![0u8; EXPECTED_RGBA_LEN],
);
// F-A.regions.json is deliberately never written.
let err = load_all(&dir).unwrap_err();
assert!(err.contains("F-A.regions.json"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
/// Required kill: a tampered `fixtures.json` digest is refused. Uses
/// the same mutation `round2-textkit`'s own
/// `validate_kills_a_changed_glyph_id` test does (change one glyph id
/// deep inside a fixture, leaving every named/counted field valid) —
/// only the whole-artifact digest catches it, which is exactly why this
/// crate's loader must not skip that check.
#[test]
fn a_tampered_fixtures_digest_is_refused() {
let dir = scratch_dir("tampered-digest");
let mut tampered = round2_textkit::output::load_fixtures(
&real_spike_root().join("round2-textkit/fixtures.json"),
)
.expect("real fixtures.json must load");
let g = &mut tampered.fixtures[0].resolved.segments[0].glyphs[3];
g.glyph_id = 9999;
let json = serde_json::to_string_pretty(&tampered).unwrap();
write(&dir.join("round2-textkit/fixtures.json"), json.as_bytes());
let err = load_all(&dir).unwrap_err();
assert!(err.contains("digest"), "{err}");
let _ = std::fs::remove_dir_all(&dir);
}
/// F5: `<id>.regions.json` is an implicit contract between
/// `round2-reference` (which writes it) and this crate (which reads
/// it), with no shared type. An extra field must be refused **by
/// name**, not silently dropped — that is what turns a future schema
/// drift into a named parse error here instead of quiet data loss.
#[test]
fn an_unknown_field_in_a_region_record_is_refused_by_name() {
let json = serde_json::json!([{
"label": "x",
"x0": 0,
"y0": 0,
"x1": 1,
"y1": 1,
"smuggled_field": 1
}]);
let err = serde_json::from_value::<Vec<RegionRecord>>(json)
.unwrap_err()
.to_string();
assert!(err.contains("smuggled_field"), "{err}");
}
}

View File

@ -0,0 +1,72 @@
//! # round2-candidatekit — Packet 2B-0: the candidate-neutral apparatus, and
//! **nothing else**.
//!
//! `spec/CONTRACT_EDITOR_T4_SPIKE.md` Round 2 scores criterion 3 (text) via
//! the five checks `spec/ANALYSIS_TEXT_RUN_PRIMITIVES.md` (W3) §5 names.
//! Packet 2A built every piece of candidate-neutral apparatus those checks
//! are measured against (fixtures, the hit-test probe table, the reference
//! rasters and D4 regions, the accessibility oracle). This crate is Packet
//! 2B-0: it is what the two Round 2 candidates — **C1** (egui + lyon) and
//! **C2** (vello) — both depend on, so that neither one re-derives fixture
//! loading, and neither one gets to define the scoring rule for itself.
//!
//! ## The neutrality boundary — this is the point of the crate
//!
//! The user's ruling, verbatim: **"Share only neutral fixture/oracle
//! loading. Rendering, hit testing, and accessibility integration remain
//! candidate-owned."**
//!
//! This crate **MAY** contain:
//!
//! - Loading and validating fixtures, the probe table, the reference
//! rasters and region files, and the a11y expectations
//! ([`inputs::load_all`]).
//! - The shared *report* data shape both candidates emit, and its
//! serialization ([`report::CandidateReport`] and its constituent types).
//! - The scoring rule that turns per-check outcomes into the criterion cell
//! ([`scoring::criterion_cell`], [`scoring::is_eligible`]).
//!
//! This crate **MUST NOT** contain:
//!
//! - Any rendering, rasterization, path/outline conversion, or
//! tessellation.
//! - Any hit-test *resolution* — i.e. nothing that answers "which byte
//! offset does this device point select". Loading the expected answers
//! ([`round2_textkit::hittest::HitTestProbeFile`]) is neutral; computing
//! them is the candidate's job and the thing check 4 measures. This crate
//! only carries the *shape* of a recorded comparison
//! ([`report::HitTestProbeResult`]) — it never resolves one.
//! - Any accessibility node construction or platform-adapter code. This
//! crate only carries the *shape* of observed evidence
//! ([`report::A11yEvidence`]) against the precommitted oracle
//! ([`round2_textkit::a11y`]) — it never builds a tree.
//!
//! `tests/dependency_deny_list.rs` enforces what code review can miss: it
//! reads this crate's own `Cargo.toml` at test time and fails if `egui`,
//! `eframe`, `egui-wgpu`, `lyon`, `lyon_path`, `lyon_tessellation`, `vello`,
//! `wgpu`, `winit`, `accesskit`, `accesskit_winit`, `tiny-skia`, `resvg`, or
//! `usvg` is ever named in `[dependencies]`.
//!
//! ## What this crate does not decide
//!
//! [`scoring::criterion_cell`] implements the contract's outcome rule; it
//! does not implement W3 §5 itself, and it is not the place check 3's
//! `NOT RUN` ruling was *made* — that ruling is `ROUND2_TEXT_RECIPE.md`
//! §1.2, and this crate only encodes and enforces its consequences.
pub mod inputs;
pub mod outcome;
pub mod report;
pub mod scoring;
pub use inputs::{load_all, NeutralInputs, ReferenceFixture};
pub use outcome::CheckOutcome;
pub use report::{
A11yEvidence, AdapterStatus, BusUnreachableEvidence, CandidateReport, CostRecord,
DependencyDelta, DiffReportRecord, HitTestProbeResult, IntegrationOwnership, LocByPart,
RegionMassRecord, ReportPart,
};
pub use scoring::{
criterion_cell, is_eligible, CellOutcome, CHECK_3_RULING, DISQUALIFYING_CHECKS,
ROUND0_READBACK_EVIDENCE, ROUND_PLATFORM,
};

View File

@ -0,0 +1,270 @@
//! The per-check outcome type both candidates report against.
use serde::{Deserialize, Deserializer, Serialize};
/// One check's outcome. Exactly three states, and **both non-`Pass` states
/// carry a reason**: pin 14 requires an environmental `NotRun` to record
/// *why* it could not run, and a bare `Fail` with no reason would be
/// exactly the unfalsifiable report `round1-oracle`'s discipline exists to
/// forbid. There is deliberately no unit-only `NotRun` or `Fail` variant —
/// a candidate cannot report "did not pass" without saying why.
///
/// **An empty or whitespace-only reason is a bare reason wearing a
/// string.** The checked constructors ([`CheckOutcome::fail`],
/// [`CheckOutcome::not_run`]) and this type's `Deserialize` impl both
/// reject one — those are the two paths a candidate actually uses to
/// produce a `CandidateReport` (build it in Rust, or read one back from
/// JSON). The variants' payloads stay `pub` because a fully private field
/// would need a getter/setter pair that adds ceremony without closing any
/// path a candidate is expected to take; the invalid state is
/// unconstructible through construction *and* deserialization, which is
/// what "a reason is required" needs to mean in practice.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub enum CheckOutcome {
Pass,
Fail(String),
NotRun(String),
}
impl CheckOutcome {
/// Checked constructor: rejects an empty-or-whitespace-only reason.
pub fn fail(reason: impl Into<String>) -> Result<Self, String> {
let reason = reason.into();
if reason.trim().is_empty() {
return Err(
"CheckOutcome::fail: reason must not be empty or whitespace-only — a Fail with \
no reason is exactly the unfalsifiable report this type exists to forbid"
.to_string(),
);
}
Ok(CheckOutcome::Fail(reason))
}
/// Checked constructor: rejects an empty-or-whitespace-only reason.
pub fn not_run(reason: impl Into<String>) -> Result<Self, String> {
let reason = reason.into();
if reason.trim().is_empty() {
return Err(
"CheckOutcome::not_run: reason must not be empty or whitespace-only — pin 14 \
requires the environmental cause to be recorded, not merely gestured at"
.to_string(),
);
}
Ok(CheckOutcome::NotRun(reason))
}
/// Ordering used by [`crate::scoring::criterion_cell`]'s worst-of-five
/// rule: `Pass` < `NotRun` < `Fail`. Higher is worse.
pub(crate) fn severity_rank(&self) -> u8 {
match self {
CheckOutcome::Pass => 0,
CheckOutcome::NotRun(_) => 1,
CheckOutcome::Fail(_) => 2,
}
}
pub fn is_pass(&self) -> bool {
matches!(self, CheckOutcome::Pass)
}
pub fn is_fail(&self) -> bool {
matches!(self, CheckOutcome::Fail(_))
}
pub fn is_not_run(&self) -> bool {
matches!(self, CheckOutcome::NotRun(_))
}
}
/// The wire shape `CheckOutcome` deserializes through — identical variants
/// and payloads, `#[serde(deny_unknown_fields)]` for the same structural-
/// drift reason every deserializable type in this workspace uses it, kept
/// as a **separate, private** type so [`CheckOutcome`]'s own `Deserialize`
/// impl can run [`CheckOutcome::fail`]/[`CheckOutcome::not_run`]'s
/// empty-reason check on the way through, which `#[derive(Deserialize)]`
/// on `CheckOutcome` directly could not do.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
enum CheckOutcomeWire {
Pass,
Fail(String),
NotRun(String),
}
impl TryFrom<CheckOutcomeWire> for CheckOutcome {
type Error = String;
fn try_from(wire: CheckOutcomeWire) -> Result<Self, String> {
match wire {
CheckOutcomeWire::Pass => Ok(CheckOutcome::Pass),
CheckOutcomeWire::Fail(reason) => CheckOutcome::fail(reason),
CheckOutcomeWire::NotRun(reason) => CheckOutcome::not_run(reason),
}
}
}
impl<'de> Deserialize<'de> for CheckOutcome {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: Deserializer<'de>,
{
let wire = CheckOutcomeWire::deserialize(deserializer)?;
CheckOutcome::try_from(wire).map_err(serde::de::Error::custom)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn severity_orders_pass_below_not_run_below_fail() {
assert!(
CheckOutcome::Pass.severity_rank() < CheckOutcome::NotRun("x".into()).severity_rank()
);
assert!(
CheckOutcome::NotRun("x".into()).severity_rank()
< CheckOutcome::Fail("x".into()).severity_rank()
);
}
#[test]
fn predicates_agree_with_the_variant() {
assert!(CheckOutcome::Pass.is_pass());
assert!(!CheckOutcome::Pass.is_fail());
assert!(!CheckOutcome::Pass.is_not_run());
assert!(CheckOutcome::Fail("x".into()).is_fail());
assert!(!CheckOutcome::Fail("x".into()).is_pass());
assert!(CheckOutcome::NotRun("x".into()).is_not_run());
assert!(!CheckOutcome::NotRun("x".into()).is_pass());
}
#[test]
fn round_trips_through_json() {
for outcome in [
CheckOutcome::Pass,
CheckOutcome::Fail("reason".to_string()),
CheckOutcome::NotRun("reason".to_string()),
] {
let json = serde_json::to_string(&outcome).unwrap();
let back: CheckOutcome = serde_json::from_str(&json).unwrap();
assert_eq!(outcome, back);
}
}
// ---- F6: a real distinguishing assertion, not `len() > 0` ----
/// A bare JSON string `"NotRun"` does not match the tuple-variant shape
/// `NotRun(String)` at all (that shape serializes as
/// `{"NotRun": "..."}`), so this is a **structural** deserialize
/// failure — distinct from the empty-reason rejection below, which
/// targets a `NotRun` that *does* carry a payload, just an empty one.
/// Asserts on serde's actual reported type mismatch (a unit-shaped
/// value where a payload-carrying variant was required), which is what
/// actually distinguishes this rejection from every other kind of
/// deserialize failure this file tests — not on "some error happened"
/// (measured: `err.to_string()` is `"invalid type: unit variant,
/// expected newtype variant"`, which names neither `NotRun` nor `Fail`
/// by name, so asserting on the variant name would itself have been
/// wrong).
#[test]
fn a_bare_string_not_run_with_no_payload_fails_to_deserialize() {
let bad = serde_json::json!("NotRun");
let err = serde_json::from_value::<CheckOutcome>(bad)
.unwrap_err()
.to_string();
assert!(err.contains("unit variant"), "{err}");
assert!(err.contains("newtype variant"), "{err}");
}
// ---- F3: an empty or whitespace-only reason is refused ----
#[test]
fn the_fail_constructor_rejects_an_empty_reason() {
let err = CheckOutcome::fail("").unwrap_err();
assert!(err.contains("empty"), "{err}");
}
#[test]
fn the_fail_constructor_rejects_a_whitespace_only_reason() {
let err = CheckOutcome::fail(" \t ").unwrap_err();
assert!(err.contains("empty"), "{err}");
}
#[test]
fn the_fail_constructor_accepts_a_real_reason() {
let outcome = CheckOutcome::fail("host-substituted the Hebrew segment").unwrap();
assert_eq!(
outcome,
CheckOutcome::Fail("host-substituted the Hebrew segment".to_string())
);
}
#[test]
fn the_not_run_constructor_rejects_an_empty_reason() {
let err = CheckOutcome::not_run("").unwrap_err();
assert!(err.contains("empty"), "{err}");
}
#[test]
fn the_not_run_constructor_rejects_a_whitespace_only_reason() {
let err = CheckOutcome::not_run("\n").unwrap_err();
assert!(err.contains("empty"), "{err}");
}
#[test]
fn the_not_run_constructor_accepts_a_real_reason() {
let outcome = CheckOutcome::not_run("no Arabic-capable face installed").unwrap();
assert_eq!(
outcome,
CheckOutcome::NotRun("no Arabic-capable face installed".to_string())
);
}
/// Guards the deserialize path the same way the constructors guard
/// direct construction: a `Fail` with an empty string payload must be
/// refused on the way in from JSON, not merely by a constructor a
/// candidate could route around by deserializing instead.
#[test]
fn deserializing_an_empty_reason_fail_is_refused() {
let bad = serde_json::json!({"Fail": ""});
let err = serde_json::from_value::<CheckOutcome>(bad)
.unwrap_err()
.to_string();
assert!(err.contains("empty"), "{err}");
}
#[test]
fn deserializing_a_whitespace_only_reason_not_run_is_refused() {
let bad = serde_json::json!({"NotRun": " "});
let err = serde_json::from_value::<CheckOutcome>(bad)
.unwrap_err()
.to_string();
assert!(err.contains("empty"), "{err}");
}
#[test]
fn deserializing_a_real_reason_still_works() {
let good = serde_json::json!({"Fail": "a real reason"});
let outcome: CheckOutcome = serde_json::from_value(good).unwrap();
assert_eq!(outcome, CheckOutcome::Fail("a real reason".to_string()));
}
/// An unknown variant name must still be refused — `CheckOutcomeWire`'s
/// own shape carries forward through the custom `Deserialize` impl
/// rather than being silently lost when `CheckOutcome` stopped deriving
/// it directly. Measured: `err.to_string()` is `"unknown variant
/// \`Passed\`, expected one of \`Pass\`, \`Fail\`, \`NotRun\`"`, so the
/// specific bad name is named in the message.
#[test]
fn an_unknown_variant_name_is_refused() {
let bad = serde_json::json!({"Passed": null});
let err = serde_json::from_value::<CheckOutcome>(bad)
.unwrap_err()
.to_string();
assert!(err.contains("unknown variant"), "{err}");
assert!(err.contains("Passed"), "{err}");
}
}

View File

@ -0,0 +1,864 @@
//! The shared report shape both candidates emit ([`CandidateReport`]), plus
//! serializable mirrors of `round2-diff`'s pass/fail types.
//!
//! `round2-diff` is a reviewed, frozen packet — its own `Cargo.toml` doc
//! comment states it is "deliberately zero dependencies", and this crate
//! does not modify it to add a `serde` derive it does not otherwise need.
//! [`DiffReportRecord`] and [`RegionMassRecord`] are lossless mirrors, with
//! an infallible `From` conversion, of `round2_diff::DiffReport` and
//! `round2_diff::RegionMass` — the same pattern `round2-reference`'s
//! `RegionRecord` uses for `round2_diff::GlyphRegion`.
use std::collections::BTreeMap;
use serde::{Deserialize, Serialize};
use round2_diff::{DiffReport, RegionMass};
use round2_textkit::hittest::DevicePoint;
use round2_textkit::types::SpikeCaretAffinity;
use crate::outcome::CheckOutcome;
/// Serializable mirror of `round2_diff::RegionMass`.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct RegionMassRecord {
pub label: String,
pub reference_mass: f64,
pub candidate_mass: f64,
pub relative_delta: f64,
pub pass: bool,
}
impl From<&RegionMass> for RegionMassRecord {
fn from(r: &RegionMass) -> Self {
RegionMassRecord {
label: r.label.clone(),
reference_mass: r.reference_mass,
candidate_mass: r.candidate_mass,
relative_delta: r.relative_delta,
pass: r.pass,
}
}
}
/// Serializable mirror of `round2_diff::DiffReport` — see this module's doc
/// comment for why this crate mirrors rather than modifies `round2-diff`.
/// `pass` is [`DiffReport::pass`]'s own computed verdict, stored rather than
/// re-derived, so a report read back from JSON does not need the four
/// D-rule fields recomputed by hand to know its own outcome.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DiffReportRecord {
pub width: u32,
pub height: u32,
pub band_pixel_count: u64,
pub d1_pixels_outside_band_differing: u64,
pub d1_pass: bool,
pub reference_ink_mass: f64,
pub candidate_ink_mass: f64,
pub d2_relative_delta: f64,
pub d2_pass: bool,
pub reference_centroid: Option<(f64, f64)>,
pub candidate_centroid: Option<(f64, f64)>,
pub d3_delta: Option<(f64, f64)>,
pub d3_pass: Option<bool>,
pub in_band_max_abs_delta_luma: u8,
pub in_band_count_delta_gt_report_threshold: u64,
pub d4_regions: Vec<RegionMassRecord>,
pub d4_pass: bool,
pub d4_worst: Option<RegionMassRecord>,
/// [`DiffReport::pass`]'s overall verdict: D1, D2, D4 must all hold,
/// and D3 must either hold or be inapplicable.
pub pass: bool,
}
impl From<&DiffReport> for DiffReportRecord {
fn from(r: &DiffReport) -> Self {
DiffReportRecord {
width: r.width,
height: r.height,
band_pixel_count: r.band_pixel_count,
d1_pixels_outside_band_differing: r.d1_pixels_outside_band_differing,
d1_pass: r.d1_pass,
reference_ink_mass: r.reference_ink_mass,
candidate_ink_mass: r.candidate_ink_mass,
d2_relative_delta: r.d2_relative_delta,
d2_pass: r.d2_pass,
reference_centroid: r.reference_centroid,
candidate_centroid: r.candidate_centroid,
d3_delta: r.d3_delta,
d3_pass: r.d3_pass,
in_band_max_abs_delta_luma: r.in_band_max_abs_delta_luma,
in_band_count_delta_gt_report_threshold: r.in_band_count_delta_gt_report_threshold,
d4_regions: r.d4_regions.iter().map(RegionMassRecord::from).collect(),
d4_pass: r.d4_pass,
d4_worst: r.d4_worst.as_ref().map(RegionMassRecord::from),
pass: r.pass(),
}
}
}
/// One hit-test probe's recorded comparison. The device point and expected
/// answer come straight from `round2-textkit`'s committed
/// `hittest_probes.json` (`round2_textkit::hittest::HitTestProbe`);
/// resolving *which* byte offset and affinity a candidate's renderer
/// actually returns for that point is the candidate's own job — check 4's
/// entire subject — so this type only carries the recorded outcome of that
/// resolution, never performs it.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct HitTestProbeResult {
pub fixture_id: String,
pub point: DevicePoint,
pub expected_source_offset: u32,
pub expected_affinity: SpikeCaretAffinity,
pub actual_source_offset: u32,
pub actual_affinity: SpikeCaretAffinity,
pub pass: bool,
}
/// One fixture's observed accessibility evidence — what the candidate's own
/// tree (or its absence) actually looked like, compared against
/// `round2-textkit`'s precommitted
/// `round2_textkit::a11y::SpikeAccessibilityExpectation`. Building the tree
/// is the candidate's job (recipe §8.4: "nothing here says *how* a
/// candidate builds the tree, on which thread, or through which crate");
/// this type only carries what was observed.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct A11yEvidence {
pub fixture_id: String,
/// The platform row (recipe §8.2 table key, e.g. `"accesskit-0.24"`)
/// this evidence was collected against — a candidate satisfies check 5
/// by matching one row, the platform it actually exposes a tree on.
pub platform: String,
/// `None` when the run is absent from the tree entirely (the
/// `absent-from-tree` prohibited outcome) — a distinct state from an
/// empty-but-present name (`name-empty`), which is `Some("")`.
pub observed_name: Option<String>,
pub observed_name_bytes_hex: Option<String>,
pub observed_role: Option<String>,
/// One of `round2_textkit::a11y::PROHIBITED_OUTCOMES`, or `None` if no
/// prohibited outcome applies.
pub prohibited_outcome: Option<String>,
pub pass: bool,
pub notes: String,
}
/// Positive evidence that the platform accessibility bus itself was
/// unreachable — the *only* thing that can make
/// [`CandidateReport::check5_accessibility`] `NotRun` admissible on the
/// round's own platform (AT-SPI2, on this machine); see
/// `crate::scoring::ROUND0_READBACK_EVIDENCE` for why "we did not build a
/// bridge" is not, by itself, an environmental cause here.
///
/// A **typed** field rather than folding this into `CheckOutcome::NotRun`'s
/// free-text reason on purpose: a free-text reason is something a candidate
/// can write anything into ("bus unreachable" typed by hand proves
/// nothing), while this type asks for the specific thing that would make
/// the claim checkable — what was attempted, and what was actually
/// observed.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct BusUnreachableEvidence {
/// How the candidate attempted to reach the platform accessibility bus
/// before concluding it was unreachable (e.g. "connected to the AT-SPI2
/// session bus via `atspi::Bus::connect`").
pub probe_description: String,
/// What was actually observed — the failure itself, not a restatement
/// of "unreachable" (e.g. the connection error message).
pub probe_output: String,
}
/// One dependency added to the candidate's own crate(s) over the Round 1
/// baseline. `reason` is a one-line justification a reader can check
/// against what the candidate actually needed to build.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct DependencyDelta {
pub name: String,
pub version: String,
pub reason: String,
}
/// One platform accessibility adapter's status.
///
/// `NotBuilt` is a distinct variant from a failing status **on purpose** —
/// the user's ruling is that an adapter the candidate chose not to build is
/// **scope, not a hidden failure**. A string convention (e.g. a `notes`
/// field reading `"not built"`) could be typo'd, omitted, or silently
/// absorbed into a `PASS`; making it a variant the compiler enforces means
/// a report can never accidentally claim a platform is covered by leaving
/// its status ambiguous.
///
/// **This variant covers *other* platforms only** (Windows UIA, macOS AX,
/// ...) — it must never be used to excuse an unbuilt bridge on the round's
/// own platform (AT-SPI2, on this machine); see
/// [`crate::scoring::ROUND0_READBACK_EVIDENCE`] and
/// [`CandidateReport::check5_bus_unreachable_evidence`] for the field that
/// actually governs whether check 5 is allowed to be `NotRun`.
/// Who *owns* the integration behind an [`AdapterStatus::Implemented`] row.
///
/// Added by the pin-13 schema amendment (2026-07-30), because the first
/// pair of real reports proved that `Implemented`/`NotBuilt` alone cannot
/// carry what Packet 2B was chartered to record. C1 reached AT-SPI through
/// AccessKit **inherited from eframe** and reported that platform as
/// `NotBuilt` ("no separate AccessKit-native readback was built"); C2
/// reached AT-SPI through AccessKit **it wired by hand** and reported
/// `Implemented`. Same underlying fact, opposite rows — and the one that
/// said `NotBuilt` was simply false, since the AccessKit path was present
/// and exercised.
///
/// Relabelling C1's row `Implemented` would have fixed the falsehood and
/// still lost the distinction, because "inherited or candidate-owned?"
/// would have survived only as prose in `notes` — which is exactly how the
/// two candidates diverged in the first place. So it is typed, required on
/// every `Implemented` row, and therefore impossible to omit.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(deny_unknown_fields)]
pub enum IntegrationOwnership {
/// The integration came with a dependency the candidate adopted; the
/// candidate did not write it. `provider` names what supplied it (e.g.
/// `"eframe 0.35 (bundled AccessKit integration)"`), so a reader can
/// check the claim against the dependency graph rather than take it.
///
/// Inheriting an integration is **not** inheriting the semantics drawn
/// on top of it: a candidate that inherits a bridge still writes the
/// accessible nodes for anything it painted itself, and that work shows
/// up in [`ReportPart::AccessibilityTreeConstruction`], not here.
Inherited { provider: Provider },
/// The candidate wrote the integration itself — the bridge wiring, the
/// event plumbing, the adapter lifecycle.
CandidateOwned,
}
/// A non-empty provider name, carrying its own invariant.
///
/// The previous amendment put the check in a constructor and in
/// `Deserialize`, and left `Inherited { provider: String }` public — so the
/// struct-literal path bypassed both, and the very first caller (C1's
/// adapter rows) took it. A checked constructor beside a public field is a
/// suggestion; the field is the API.
///
/// The inner `String` is private and every route in — `new`, `TryFrom`,
/// `Deserialize` — runs the same check, so an empty provider cannot be
/// constructed *or* serialized, on any path, without editing this module.
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
#[serde(transparent)]
pub struct Provider(String);
impl Provider {
/// The only way to build a [`Provider`]. Rejects empty and
/// whitespace-only names.
pub fn new(provider: impl Into<String>) -> Result<Self, String> {
let provider = provider.into();
if provider.trim().is_empty() {
return Err(
"Provider::new: provider must not be empty or whitespace-only — an inherited \
integration whose provider is unnamed cannot be checked against the dependency \
graph, which is the only reason this variant carries a provider at all"
.to_string(),
);
}
Ok(Provider(provider))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for Provider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl<'de> Deserialize<'de> for Provider {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let raw = String::deserialize(deserializer)?;
Provider::new(raw).map_err(serde::de::Error::custom)
}
}
impl IntegrationOwnership {
/// Checked constructor: rejects an empty-or-whitespace-only provider.
///
/// Retained as the ergonomic route, but it is no longer the *only*
/// guard — [`Provider`] carries the invariant now, so the struct-literal
/// path is closed too.
pub fn inherited(provider: impl Into<String>) -> Result<Self, String> {
Ok(IntegrationOwnership::Inherited {
provider: Provider::new(provider)?,
})
}
}
/// Deserialization shadow for [`IntegrationOwnership`], so the JSON path
/// runs the same provider check the constructor does. Without it a
/// hand-edited or differently-generated report could carry
/// `{"Inherited": {"provider": ""}}` straight past a guard that only ever
/// existed in Rust — the same hole, and the same fix, as
/// [`crate::outcome::CheckOutcome`]'s reason strings.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
enum IntegrationOwnershipWire {
Inherited { provider: Provider },
CandidateOwned,
}
impl TryFrom<IntegrationOwnershipWire> for IntegrationOwnership {
type Error = String;
fn try_from(wire: IntegrationOwnershipWire) -> Result<Self, String> {
match wire {
IntegrationOwnershipWire::Inherited { provider } => {
Ok(IntegrationOwnership::Inherited { provider })
}
IntegrationOwnershipWire::CandidateOwned => Ok(IntegrationOwnership::CandidateOwned),
}
}
}
impl<'de> Deserialize<'de> for IntegrationOwnership {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
let wire = IntegrationOwnershipWire::deserialize(deserializer)?;
IntegrationOwnership::try_from(wire).map_err(serde::de::Error::custom)
}
}
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum AdapterStatus {
/// The candidate built and exercised an adapter for this platform.
///
/// `integration_ownership` is **required**: a platform the candidate
/// actually exposed a tree on is `Implemented` whether the integration
/// was inherited or written, and the difference between those two is
/// the measurement, not a footnote.
Implemented {
platform: String,
notes: String,
integration_ownership: IntegrationOwnership,
},
/// The candidate did not build an adapter for this platform.
/// **Scope not covered — not a failure.**
///
/// Reserved **exclusively** for uncovered scope. A platform the
/// candidate reached — by any route, inherited or its own — is
/// `Implemented`, never this.
NotBuilt { platform: String, reason: String },
}
/// A shared part of the candidate's own integration work, common to both C1
/// and C2 so their per-part LOC tables can be read **side by side** — the
/// one thing the user's ruling on cost tables asks of this record.
///
/// Replaces an earlier free-text `part: String` design: free text let each
/// candidate invent its own vocabulary, which produced two tables that
/// could not be compared directly. `Other(String)` is the escape hatch for
/// a genuinely candidate-specific seam that none of the five shared rows
/// describes (e.g. egui's immediate-mode re-layout-per-frame glue, or
/// vello's scene-graph diffing) — the divergence between the two
/// candidates is still expressible, just visibly, instead of silently
/// fragmenting every row.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum ReportPart {
TextRendering,
HitTestResolution,
AccessibilityTreeConstruction,
AccessibilityIntegrationWiring,
FixtureAndReportPlumbing,
/// A seam that is genuinely candidate-specific — not one of the five
/// shared rows above.
Other(String),
}
/// LOC for one part of the candidate's own integration work.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct LocByPart {
pub part: ReportPart,
pub lines: u64,
}
/// Observed cost facts, reported at the same granularity by both
/// candidates — never a subjective score, only what was actually added,
/// built, or left as scope.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CostRecord {
/// The Round 1 baseline commit this delta is measured against.
pub baseline_commit: String,
pub dependencies_added: Vec<DependencyDelta>,
/// One entry per platform row in `round2_textkit::a11y::ACCEPTED_ROLE_TABLE`
/// — every platform gets a status, `Implemented` or `NotBuilt`, never an
/// absent entry (an absent entry is indistinguishable from "forgot to
/// report", which is exactly what `NotBuilt` exists to make explicit).
pub adapters: Vec<AdapterStatus>,
/// Free-text bullets describing integration/wiring the candidate wrote
/// itself — glue code, not vendored or generated.
pub integration_wiring: Vec<String>,
pub loc_by_part: Vec<LocByPart>,
}
/// The shape both Round 2 text candidates emit.
///
/// `check1`..`check5` are the five checks [`crate::scoring::criterion_cell`]
/// reduces to the criterion cell. `supplementary_f_d_bidi` is deliberately
/// **not** one of them — see that function's doc comment for why it is
/// structurally incapable of reaching the cell.
#[derive(Clone, Debug, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CandidateReport {
pub candidate_id: String,
pub check1_faithful_consumption: CheckOutcome,
pub check2_fallback: CheckOutcome,
/// Must be `CheckOutcome::NotRun(_)` by the standing ruling
/// (`ROUND2_TEXT_RECIPE.md` §1.2) — enforced in
/// `crate::scoring::criterion_cell` (by panic, not silent acceptance),
/// not at construction time here, so a report can still be built and
/// inspected before that function ever runs.
pub check3_bidi: CheckOutcome,
pub check4_hit_testing: CheckOutcome,
pub check5_accessibility: CheckOutcome,
/// Present only when `check5_accessibility` is `NotRun` **and** that
/// `NotRun` is claimed to be caused by the platform accessibility bus
/// itself being unreachable — the only cause
/// `crate::scoring::criterion_cell`/`crate::scoring::is_eligible`
/// accept for a check-5 `NotRun` on this round's own platform. `None`
/// whenever `check5_accessibility` is `Pass` or `Fail`.
pub check5_bus_unreachable_evidence: Option<BusUnreachableEvidence>,
/// F-D's supplementary Hebrew/Latin bidi evidence (recipe §1.2) — a
/// separate field, never merged into the five above and never read by
/// `crate::scoring::criterion_cell`.
pub supplementary_f_d_bidi: CheckOutcome,
/// Keyed by fixture id (`F-A`..`F-E`).
pub per_fixture_diffs: BTreeMap<String, DiffReportRecord>,
pub hittest_probe_results: Vec<HitTestProbeResult>,
pub a11y_evidence: Vec<A11yEvidence>,
pub cost: CostRecord,
}
#[cfg(test)]
mod tests {
use super::*;
use round2_diff::GlyphRegion;
fn solid(width: u32, height: u32, rgb: [u8; 3]) -> Vec<u8> {
let mut buf = vec![0u8; (width as usize) * (height as usize) * 4];
for px in buf.chunks_mut(4) {
px[0] = rgb[0];
px[1] = rgb[1];
px[2] = rgb[2];
px[3] = 255;
}
buf
}
#[test]
fn diff_report_record_mirrors_every_field_and_the_computed_verdict() {
let reference = solid(8, 8, [255, 255, 255]);
let candidate = reference.clone();
let region = GlyphRegion {
label: "x".to_string(),
x0: 2,
y0: 2,
x1: 6,
y1: 6,
};
let report = round2_diff::diff(&reference, &candidate, 8, 8, &[region]).unwrap();
let record = DiffReportRecord::from(&report);
assert_eq!(record.width, report.width);
assert_eq!(record.height, report.height);
assert_eq!(record.d1_pass, report.d1_pass);
assert_eq!(record.d2_pass, report.d2_pass);
assert_eq!(record.d3_pass, report.d3_pass);
assert_eq!(record.d4_pass, report.d4_pass);
assert_eq!(record.pass, report.pass());
assert_eq!(record.d4_regions.len(), report.d4_regions.len());
}
fn empty_cost() -> CostRecord {
CostRecord {
baseline_commit: "abc1234".to_string(),
dependencies_added: Vec::new(),
adapters: vec![AdapterStatus::NotBuilt {
platform: "windows-uia".to_string(),
reason: "no Windows CI runner for this spike".to_string(),
}],
integration_wiring: Vec::new(),
loc_by_part: Vec::new(),
}
}
fn base_candidate_report() -> CandidateReport {
CandidateReport {
candidate_id: "C-TEST".to_string(),
check1_faithful_consumption: CheckOutcome::Pass,
check2_fallback: CheckOutcome::Pass,
check3_bidi: CheckOutcome::NotRun("x".to_string()),
check4_hit_testing: CheckOutcome::Pass,
check5_accessibility: CheckOutcome::Pass,
check5_bus_unreachable_evidence: None,
supplementary_f_d_bidi: CheckOutcome::Pass,
per_fixture_diffs: BTreeMap::new(),
hittest_probe_results: Vec::new(),
a11y_evidence: Vec::new(),
cost: empty_cost(),
}
}
#[test]
fn candidate_report_round_trips_through_json() {
let report = base_candidate_report();
let json = serde_json::to_string_pretty(&report).unwrap();
let reloaded: CandidateReport = serde_json::from_str(&json).unwrap();
assert_eq!(reloaded.candidate_id, "C-TEST");
assert!(matches!(
reloaded.cost.adapters[0],
AdapterStatus::NotBuilt { .. }
));
assert!(reloaded.check5_bus_unreachable_evidence.is_none());
}
/// `check5_bus_unreachable_evidence` must round-trip when present, not
/// just when `None` — the field the review named is exactly the one a
/// lossy round trip would silently drop.
#[test]
fn bus_unreachable_evidence_round_trips_through_json() {
let mut report = base_candidate_report();
report.check5_accessibility = CheckOutcome::not_run("bus unreachable").unwrap();
report.check5_bus_unreachable_evidence = Some(BusUnreachableEvidence {
probe_description: "connected to the AT-SPI2 session bus".to_string(),
probe_output: "org.freedesktop.DBus.Error.ServiceUnknown".to_string(),
});
let json = serde_json::to_string_pretty(&report).unwrap();
let reloaded: CandidateReport = serde_json::from_str(&json).unwrap();
let evidence = reloaded
.check5_bus_unreachable_evidence
.expect("evidence must survive the round trip");
assert_eq!(
evidence.probe_output,
"org.freedesktop.DBus.Error.ServiceUnknown"
);
}
/// `NotBuilt` must not be interchangeable with `Implemented` — the
/// compiler-enforced distinction the doc comment claims.
#[test]
fn not_built_adapter_is_a_distinct_variant_from_implemented() {
let a = AdapterStatus::NotBuilt {
platform: "macos-nsaccessibility".to_string(),
reason: "no macOS runner".to_string(),
};
assert!(matches!(a, AdapterStatus::NotBuilt { .. }));
assert!(!matches!(a, AdapterStatus::Implemented { .. }));
}
// ---- pin-13 schema amendment: integration ownership ----
fn implemented(platform: &str, own: IntegrationOwnership) -> AdapterStatus {
AdapterStatus::Implemented {
platform: platform.to_string(),
notes: "n".to_string(),
integration_ownership: own,
}
}
/// The amendment's whole point: two candidates that both reached a
/// platform are both `Implemented`, and the inherited-vs-owned
/// difference survives as *data* rather than as prose in `notes`. This
/// is the comparison the first pair of real reports could not express —
/// C1 reached AT-SPI through AccessKit inherited from eframe and
/// reported `NotBuilt`; C2 reached it through AccessKit it wired itself
/// and reported `Implemented`.
#[test]
fn two_candidates_on_one_platform_differ_only_in_ownership() {
let c1 = implemented(
"accesskit-0.24",
IntegrationOwnership::Inherited {
provider: Provider::new("eframe 0.35").unwrap(),
},
);
let c2 = implemented("accesskit-0.24", IntegrationOwnership::CandidateOwned);
for a in [&c1, &c2] {
assert!(matches!(a, AdapterStatus::Implemented { .. }));
}
let own = |a: &AdapterStatus| match a {
AdapterStatus::Implemented {
integration_ownership,
..
} => integration_ownership.clone(),
_ => unreachable!(),
};
assert_ne!(own(&c1), own(&c2));
}
/// `Inherited` must name its provider, so the claim is checkable
/// against the dependency graph instead of taken on trust.
#[test]
fn inherited_carries_its_provider_and_is_not_conflated_with_owned() {
let inherited = IntegrationOwnership::Inherited {
provider: Provider::new("eframe 0.35 (bundled AccessKit integration)").unwrap(),
};
match &inherited {
IntegrationOwnership::Inherited { provider } => {
assert!(provider.as_str().contains("eframe"), "{provider}")
}
IntegrationOwnership::CandidateOwned => panic!("wrong variant"),
}
assert_ne!(inherited, IntegrationOwnership::CandidateOwned);
}
/// Mutation: an `Implemented` row that omits `integration_ownership`
/// must fail to deserialize. If this ever passes, the field has become
/// optional in practice and the amendment is decorative — a report
/// could once again carry the distinction only in prose.
#[test]
fn an_implemented_row_without_integration_ownership_is_refused() {
let bad = serde_json::json!({
"Implemented": { "platform": "at-spi2", "notes": "n" }
});
let err = serde_json::from_value::<AdapterStatus>(bad)
.unwrap_err()
.to_string();
assert!(err.contains("integration_ownership"), "{err}");
}
/// `NotBuilt` is for uncovered scope only, so it takes no ownership
/// field — a platform reached by *any* route is `Implemented`.
#[test]
fn not_built_takes_no_ownership_field() {
let bad = serde_json::json!({
"NotBuilt": {
"platform": "windows-uia",
"reason": "no runner",
"integration_ownership": "CandidateOwned"
}
});
let err = serde_json::from_value::<AdapterStatus>(bad)
.unwrap_err()
.to_string();
assert!(err.contains("integration_ownership"), "{err}");
}
/// Mutation: the checked constructor refuses an empty provider. If this
/// passes, `Inherited`'s "checkable against the dependency graph" claim
/// is decorative.
#[test]
fn the_inherited_constructor_rejects_an_empty_provider() {
let err = IntegrationOwnership::inherited("").unwrap_err();
assert!(err.contains("provider"), "{err}");
assert!(err.contains("dependency graph"), "{err}");
}
/// Whitespace-only is the same hole wearing a space — a provider of
/// `" "` renders as `Inherited` in a table and names nothing.
#[test]
fn the_inherited_constructor_rejects_a_whitespace_only_provider() {
assert!(IntegrationOwnership::inherited(" \t \n ").is_err());
}
#[test]
fn the_inherited_constructor_accepts_a_real_provider() {
let own = IntegrationOwnership::inherited("eframe 0.35").unwrap();
assert_eq!(
own,
IntegrationOwnership::Inherited {
provider: Provider::new("eframe 0.35").unwrap()
}
);
}
/// The JSON path must run the same check — a hand-edited report is
/// exactly where an unnamed provider would arrive from.
#[test]
fn deserializing_an_empty_provider_is_refused() {
let bad = serde_json::json!({ "Inherited": { "provider": "" } });
let err = serde_json::from_value::<IntegrationOwnership>(bad)
.unwrap_err()
.to_string();
assert!(err.contains("provider"), "{err}");
}
#[test]
fn deserializing_a_whitespace_only_provider_is_refused() {
let bad = serde_json::json!({ "Inherited": { "provider": " " } });
assert!(serde_json::from_value::<IntegrationOwnership>(bad).is_err());
}
/// An empty provider must be refused when it arrives nested inside a
/// whole adapter row, not only when deserialized on its own — that is
/// the shape a real report carries it in.
#[test]
fn an_adapter_row_with_an_empty_provider_is_refused() {
let bad = serde_json::json!({
"Implemented": {
"platform": "at-spi2",
"notes": "n",
"integration_ownership": { "Inherited": { "provider": "" } }
}
});
let err = serde_json::from_value::<AdapterStatus>(bad)
.unwrap_err()
.to_string();
assert!(err.contains("provider"), "{err}");
}
/// Mutation, and the reason this amendment exists: the *struct-literal*
/// path must be closed, not just the constructor. `Provider`'s inner
/// field is private, so outside this module
/// `IntegrationOwnership::Inherited { provider: "".to_string() }` does
/// not compile at all — which is the only kind of guard a caller cannot
/// forget to call. The previous amendment guarded the constructor and
/// `Deserialize` and left the literal open, and the first real caller
/// (C1's adapter rows) took exactly that path.
///
/// This test pins the *serialization* consequence, the half a type
/// error cannot express: every `Provider` that exists has been checked,
/// so no serialized row can carry an empty provider.
#[test]
fn no_constructible_provider_serializes_as_empty() {
for bad in ["", " ", "\t", "\n \t"] {
assert!(
Provider::new(bad).is_err(),
"Provider::new({bad:?}) must not construct"
);
}
let good = Provider::new("eframe 0.35").unwrap();
let json = serde_json::to_string(&good).unwrap();
assert_eq!(
json, "\"eframe 0.35\"",
"provider must serialize transparently"
);
assert!(!json.trim_matches('"').trim().is_empty());
}
/// The newtype must not smuggle its invariant past the wire either: a
/// whole adapter row carrying an empty provider string is refused on
/// the way in, and a row that round-trips keeps its provider intact.
#[test]
fn an_adapter_row_provider_survives_a_round_trip_unchanged() {
let row = AdapterStatus::Implemented {
platform: "at-spi2".to_string(),
notes: "n".to_string(),
integration_ownership: IntegrationOwnership::inherited("eframe 0.35").unwrap(),
};
let json = serde_json::to_string(&row).unwrap();
let back: AdapterStatus = serde_json::from_str(&json).unwrap();
assert_eq!(serde_json::to_string(&back).unwrap(), json);
assert!(json.contains("eframe 0.35"), "{json}");
}
#[test]
fn deserializing_candidate_owned_still_works() {
let ok = serde_json::json!("CandidateOwned");
assert_eq!(
serde_json::from_value::<IntegrationOwnership>(ok).unwrap(),
IntegrationOwnership::CandidateOwned
);
}
#[test]
fn adapter_rows_round_trip_through_json_both_ways() {
for a in [
implemented("at-spi2", IntegrationOwnership::CandidateOwned),
implemented(
"accesskit-0.24",
IntegrationOwnership::Inherited {
provider: Provider::new("eframe 0.35").unwrap(),
},
),
AdapterStatus::NotBuilt {
platform: "windows-uia".to_string(),
reason: "no runner".to_string(),
},
] {
let json = serde_json::to_string(&a).unwrap();
let back: AdapterStatus = serde_json::from_str(&json).unwrap();
assert_eq!(
serde_json::to_string(&back).unwrap(),
json,
"round trip changed the row"
);
}
}
/// An unknown field on the wire must be refused, not ignored — the same
/// discipline every deserializable type in this workspace uses.
#[test]
fn an_unknown_field_on_cost_record_is_refused() {
let mut v = serde_json::to_value(empty_cost()).unwrap();
v.as_object_mut()
.unwrap()
.insert("smuggled_field".into(), serde_json::json!(1));
let err = serde_json::from_value::<CostRecord>(v)
.unwrap_err()
.to_string();
assert!(err.contains("smuggled_field"), "{err}");
}
// ---- F4: the five shared ReportPart rows compare directly ----
/// The whole point of replacing free-text `part: String` with a fixed
/// enum: two candidates' `LocByPart` rows for the same shared part are
/// now directly comparable (`==`), which a free-text label (e.g. "text
/// rendering" vs. "rendering text") could never guarantee.
#[test]
fn the_same_shared_part_from_two_candidates_compares_equal() {
let c1_row = LocByPart {
part: ReportPart::HitTestResolution,
lines: 340,
};
let c2_row = LocByPart {
part: ReportPart::HitTestResolution,
lines: 210,
};
assert_eq!(c1_row.part, c2_row.part);
assert_ne!(
c1_row.lines, c2_row.lines,
"the LOC counts may legitimately differ"
);
}
/// `Other` stays the escape hatch: two candidate-specific seams with
/// different labels remain distinguishable, unlike the five fixed rows.
#[test]
fn other_parts_with_different_labels_are_not_conflated() {
let egui_seam = ReportPart::Other("immediate-mode re-layout per frame".to_string());
let vello_seam = ReportPart::Other("scene-graph diffing".to_string());
assert_ne!(egui_seam, vello_seam);
}
/// All five shared rows round-trip, and `Other` carries its label
/// through — a lossy `Serialize`/`Deserialize` impl on the enum would
/// silently collapse rows that must stay comparable.
#[test]
fn every_report_part_round_trips_through_json() {
let parts = [
ReportPart::TextRendering,
ReportPart::HitTestResolution,
ReportPart::AccessibilityTreeConstruction,
ReportPart::AccessibilityIntegrationWiring,
ReportPart::FixtureAndReportPlumbing,
ReportPart::Other("candidate-specific seam".to_string()),
];
for part in parts {
let json = serde_json::to_string(&part).unwrap();
let back: ReportPart = serde_json::from_str(&json).unwrap();
assert_eq!(part, back);
}
}
}

View File

@ -0,0 +1,368 @@
//! Turns a [`CandidateReport`]'s five check outcomes into the Round 2
//! criterion cell, and reports eligibility separately
//! (`ROUND2_TEXT_RECIPE.md` §1.2).
use crate::outcome::CheckOutcome;
use crate::report::CandidateReport;
/// The round's own platform — `round2_textkit::a11y::ACCEPTED_ROLE_TABLE`'s
/// `"at-spi2"` row — restated here so [`ROUND0_READBACK_EVIDENCE`]'s doc
/// comment and [`require_check5_not_run_is_admissible`]'s panic can name it
/// precisely.
pub const ROUND_PLATFORM: &str = "at-spi2";
/// Round 0's own readback evidence, quoted verbatim in the panic
/// [`criterion_cell`]/[`is_eligible`] raise for a check-5 `NotRun` that
/// carries no [`crate::report::BusUnreachableEvidence`].
///
/// `round0-evidence/c1-egui-readback.txt` and
/// `round0-evidence/c2-vello-readback.txt` both record `READBACK: PASS` — a
/// live, out-of-process AT-SPI2 tree walk that succeeded for **both**
/// candidates on this machine. So on [`ROUND_PLATFORM`], "we did not build
/// an accessibility bridge" is not an environmental cause: the bus is
/// reachable, and check 5 is the round's own platform, not declared
/// out-of-scope adapter coverage. `AdapterStatus::NotBuilt` still covers
/// *other* platforms (Windows UIA, macOS AX, ...) as declared scope; it
/// must not be used to excuse the platform the round actually runs on.
pub const ROUND0_READBACK_EVIDENCE: &str = "round0-evidence/c1-egui-readback.txt and \
round0-evidence/c2-vello-readback.txt both record READBACK: PASS a live, out-of-process \
AT-SPI2 tree walk succeeded for both candidates on this machine, so the platform \
accessibility bus is reachable here and an unbuilt accessibility bridge is not \
environmental NOT RUN on this platform. AdapterStatus::NotBuilt covers OTHER platforms \
(Windows UIA, macOS AX, ...) as declared scope; it does not, by itself, excuse the \
platform the round actually runs on.";
/// Panics if `report.check5_accessibility` is `NotRun` without
/// `report.check5_bus_unreachable_evidence` present — see
/// [`ROUND0_READBACK_EVIDENCE`]. A no-op for `Pass`/`Fail`, and a no-op for
/// a `NotRun` that *does* carry evidence. Deliberately does **not** inspect
/// `report.cost.adapters` — an `AdapterStatus::NotBuilt` entry for
/// [`ROUND_PLATFORM`] must not, by itself, satisfy this check (that is the
/// exact loophole the review named).
fn require_check5_not_run_is_admissible(report: &CandidateReport) {
if report.check5_accessibility.is_not_run() && report.check5_bus_unreachable_evidence.is_none()
{
panic!(
"candidate {:?} reported check5_accessibility = NotRun(_) with no \
check5_bus_unreachable_evidence {ROUND0_READBACK_EVIDENCE}",
report.candidate_id
);
}
}
/// The Round 2 criterion cell for check 3 (bidi / text-run primitives) is
/// structurally identical to [`CheckOutcome`] — a cell is the worst of the
/// five checks, which is itself just a `CheckOutcome` — kept as a distinct
/// name so a reader is never unsure whether a value in hand is *one check's
/// own* outcome or *the criterion cell* five checks reduce to.
pub type CellOutcome = CheckOutcome;
/// The standing ruling [`criterion_cell`] enforces (`ROUND2_TEXT_RECIPE.md`
/// §1.2, 2026-07-29): no Arabic-capable face is installed on the round's
/// declared machine, and pin 9 makes an absent required face environmental
/// `NOT RUN`. Quoted verbatim in the panic [`criterion_cell`] raises for a
/// report that disagrees with it.
pub const CHECK_3_RULING: &str = "ROUND2_TEXT_RECIPE.md §1.2 (2026-07-29 ruling): check 3 is \
NOT RUN for every candidate, on both adapters no Arabic-capable face is installed, and \
pin 9 makes an absent required face environmental NOT RUN. F-D's supplementary Hebrew/Latin \
bidi evidence is recorded separately and must never upgrade check 3 to PASS.";
/// Reduces a [`CandidateReport`]'s five check outcomes to the Round 2
/// criterion cell: the **worst of the five**, ordered `Pass` < `NotRun` <
/// `Fail` ([`CheckOutcome::severity_rank`]).
///
/// The supplementary F-D bidi result
/// ([`CandidateReport::supplementary_f_d_bidi`]) is a separate field on
/// `CandidateReport` and this function never reads it — that is what makes
/// it **structurally** incapable of reaching the cell (recipe §1.2: "it
/// must not upgrade check 3 to PASS"), rather than merely conventionally
/// excluded by a check this function could someday grow to include by
/// accident.
///
/// # Panics
///
/// Panics if `report.check3_bidi` is anything other than `NotRun` — a
/// candidate reporting `Pass` or `Fail` for check 3 has violated the
/// standing ruling ([`CHECK_3_RULING`]), which this function treats as a
/// programming error in how the candidate assembled its report, not a value
/// a scoring rule is allowed to interpret. (Not every environmental
/// deviation deserves a panic; this one does, because pin 9's face-absence
/// fact does not vary between the two candidates or between runs — a
/// non-`NotRun` value here can only mean the report was built wrong.)
pub fn criterion_cell(report: &CandidateReport) -> CellOutcome {
if !report.check3_bidi.is_not_run() {
panic!(
"candidate {:?} reported check 3 as {:?}, not NotRun(_) — {CHECK_3_RULING}",
report.candidate_id, report.check3_bidi
);
}
require_check5_not_run_is_admissible(report);
let checks = [
&report.check1_faithful_consumption,
&report.check2_fallback,
&report.check3_bidi,
&report.check4_hit_testing,
&report.check5_accessibility,
];
checks
.into_iter()
.max_by_key(|c| c.severity_rank())
.cloned()
.expect("`checks` is a fixed non-empty array of five elements")
}
/// The two disqualifying checks (recipe §1.2: "checks 2 and 5 are the
/// disqualifying set"). Named so the disqualifying set is a fact a reader
/// (and a grep) can find, not a claim buried in a comment beside
/// [`is_eligible`].
pub const DISQUALIFYING_CHECKS: &str = "check2_fallback, check5_accessibility";
/// Whether `report` remains a candidate at all — reported **separately**
/// from [`criterion_cell`], because the two questions are different: the
/// cell is what the criterion 3 table shows, eligibility is whether the
/// candidate survives at all.
///
/// Failing check 2 or check 5 disqualifies. Check 3's `NotRun` (the only
/// state it is ever allowed to carry — see [`criterion_cell`]) does **not**
/// disqualify, because check 3 is not in the disqualifying set
/// ([`DISQUALIFYING_CHECKS`]).
///
/// # Panics
///
/// Panics under the same condition [`criterion_cell`] does for check 5 —
/// see [`require_check5_not_run_is_admissible`] / [`ROUND0_READBACK_EVIDENCE`].
/// Without this, a candidate that never wired an accessibility bridge could
/// report `check5_accessibility = NotRun("we did not build it")`, and
/// `is_eligible` would return `true` because `NotRun` is not `Fail` — the
/// exact loophole the ruling this function enforces exists to close. This
/// function does not merely return `false` for that case, because the
/// report itself is malformed (an inadmissible claim), not merely
/// disqualifying: a malformed report should not be silently readable as "at
/// least eligible."
pub fn is_eligible(report: &CandidateReport) -> bool {
require_check5_not_run_is_admissible(report);
!report.check2_fallback.is_fail() && !report.check5_accessibility.is_fail()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::report::{AdapterStatus, BusUnreachableEvidence, CostRecord};
use std::collections::BTreeMap;
fn base_report() -> CandidateReport {
CandidateReport {
candidate_id: "C-TEST".to_string(),
check1_faithful_consumption: CheckOutcome::Pass,
check2_fallback: CheckOutcome::Pass,
check3_bidi: CheckOutcome::NotRun(CHECK_3_RULING.to_string()),
check4_hit_testing: CheckOutcome::Pass,
check5_accessibility: CheckOutcome::Pass,
check5_bus_unreachable_evidence: None,
supplementary_f_d_bidi: CheckOutcome::Pass,
per_fixture_diffs: BTreeMap::new(),
hittest_probe_results: Vec::new(),
a11y_evidence: Vec::new(),
cost: CostRecord {
baseline_commit: "0000000".to_string(),
dependencies_added: Vec::new(),
adapters: Vec::new(),
integration_wiring: Vec::new(),
loc_by_part: Vec::new(),
},
}
}
fn some_evidence() -> BusUnreachableEvidence {
BusUnreachableEvidence {
probe_description: "connected to the AT-SPI2 session bus".to_string(),
probe_output: "org.freedesktop.DBus.Error.ServiceUnknown".to_string(),
}
}
// ---- criterion_cell: the worst-of-five rule ----
#[test]
fn all_pass_except_the_pinned_check_3_yields_a_not_run_cell() {
let cell = criterion_cell(&base_report());
assert!(matches!(cell, CheckOutcome::NotRun(_)), "{cell:?}");
}
/// Required kill: a `CandidateReport` claiming check 3 `Pass` is
/// rejected, naming the §1.2 ruling.
#[test]
#[should_panic(expected = "§1.2")]
fn a_check_3_pass_is_rejected_naming_the_ruling() {
let mut report = base_report();
report.check3_bidi = CheckOutcome::Pass;
let _ = criterion_cell(&report);
}
/// Same requirement, the other disallowed value: a `Fail` for check 3
/// is rejected exactly as a `Pass` is — the ruling pins check 3 to
/// `NotRun` specifically, not merely "not Pass".
#[test]
#[should_panic(expected = "§1.2")]
fn a_check_3_fail_is_also_rejected_naming_the_ruling() {
let mut report = base_report();
report.check3_bidi = CheckOutcome::Fail("pretend Arabic shaping worked".to_string());
let _ = criterion_cell(&report);
}
/// Required kill: a supplementary F-D `Pass` does not move the cell off
/// `NotRun`.
#[test]
fn a_supplementary_f_d_pass_does_not_move_the_cell_off_not_run() {
let mut report = base_report();
report.supplementary_f_d_bidi = CheckOutcome::Pass;
assert!(matches!(criterion_cell(&report), CheckOutcome::NotRun(_)));
}
/// Required kill: a supplementary F-D `Fail` does not move the cell
/// either — in particular it must not turn `NotRun` into `Fail`, which
/// is the direction a naive "worst of six" implementation would break.
#[test]
fn a_supplementary_f_d_fail_does_not_move_the_cell_either() {
let mut report = base_report();
report.supplementary_f_d_bidi =
CheckOutcome::Fail("Hebrew segment drawn in the wrong face".to_string());
let cell = criterion_cell(&report);
assert!(
matches!(cell, CheckOutcome::NotRun(_)),
"a FAIL on the supplementary row must not reach the cell at all: got {cell:?}"
);
}
/// A genuine check-2 FAIL must still win the worst-of-five over the
/// pinned check-3 NotRun — confirms the ordering is real, not just
/// "always NotRun".
#[test]
fn a_check_2_failure_outranks_the_pinned_not_run_in_the_cell() {
let mut report = base_report();
report.check2_fallback =
CheckOutcome::Fail("host-substituted the Hebrew segment".to_string());
let cell = criterion_cell(&report);
assert!(matches!(cell, CheckOutcome::Fail(_)), "{cell:?}");
}
// ---- is_eligible: the disqualifying set is {check2, check5} only ----
/// Required kill: a candidate failing check 2 is ineligible.
#[test]
fn failing_check_2_makes_a_candidate_ineligible() {
let mut report = base_report();
report.check2_fallback = CheckOutcome::Fail("...".to_string());
assert!(!is_eligible(&report));
}
/// Required kill: a candidate failing check 5 is ineligible.
#[test]
fn failing_check_5_makes_a_candidate_ineligible() {
let mut report = base_report();
report.check5_accessibility = CheckOutcome::Fail("...".to_string());
assert!(!is_eligible(&report));
}
/// Required kill: a candidate whose only non-Pass is check 3 `NotRun`
/// is eligible.
#[test]
fn a_candidate_whose_only_non_pass_is_check_3_not_run_is_eligible() {
let report = base_report(); // check3 is NotRun; everything else Pass.
assert!(
is_eligible(&report),
"check 3 is not in the disqualifying set"
);
}
/// Checks 1 and 4 are not disqualifying either — only 2 and 5 are. This
/// distinguishes "affects the cell" from "affects eligibility": a
/// check-1 FAIL sinks the cell to FAIL but must not, by itself, remove
/// the candidate from the round.
#[test]
fn failing_check_1_or_4_sinks_the_cell_but_not_eligibility() {
let mut report = base_report();
report.check1_faithful_consumption = CheckOutcome::Fail("...".to_string());
assert!(
is_eligible(&report),
"checks 1 and 4 are not in the disqualifying set"
);
assert!(matches!(criterion_cell(&report), CheckOutcome::Fail(_)));
}
// ---- F1: a check-5 NotRun is admissible only with bus-unreachable evidence ----
/// Required kill: a check-5 `NotRun` with no unreachable-bus evidence is
/// rejected, naming Round 0's readback evidence.
#[test]
#[should_panic(expected = "READBACK: PASS")]
fn a_check_5_not_run_with_no_evidence_is_rejected_by_is_eligible() {
let mut report = base_report();
report.check5_accessibility = CheckOutcome::not_run("we did not build it").unwrap();
report.check5_bus_unreachable_evidence = None;
let _ = is_eligible(&report);
}
/// Same rejection, reached through `criterion_cell` instead of
/// `is_eligible` — both are "the scoring path" the review named.
#[test]
#[should_panic(expected = "READBACK: PASS")]
fn a_check_5_not_run_with_no_evidence_is_rejected_by_criterion_cell() {
let mut report = base_report();
report.check5_accessibility = CheckOutcome::not_run("we did not build it").unwrap();
report.check5_bus_unreachable_evidence = None;
let _ = criterion_cell(&report);
}
/// Required kill: a check-5 `NotRun` **with** unreachable-bus evidence
/// is accepted, and does not disqualify the candidate (NotRun is not in
/// the disqualifying set — see [`DISQUALIFYING_CHECKS`]).
#[test]
fn a_check_5_not_run_with_evidence_is_accepted_and_does_not_disqualify() {
let mut report = base_report();
report.check5_accessibility = CheckOutcome::not_run("bus unreachable").unwrap();
report.check5_bus_unreachable_evidence = Some(some_evidence());
assert!(
is_eligible(&report),
"a legitimately NotRun check 5 must not disqualify"
);
let cell = criterion_cell(&report);
assert!(matches!(cell, CheckOutcome::NotRun(_)), "{cell:?}");
}
/// Required kill: an `AdapterStatus::NotBuilt` entry for the round's own
/// platform does not, by itself, make a check-5 `NotRun` admissible —
/// `NotBuilt` covers *other* platforms as declared scope, and must not
/// be usable to excuse AT-SPI2, the platform this round actually runs
/// on. The admissibility check must ignore `cost.adapters` entirely.
#[test]
#[should_panic(expected = "READBACK: PASS")]
fn a_not_built_adapter_for_the_rounds_own_platform_does_not_grant_admissibility() {
let mut report = base_report();
report.check5_accessibility = CheckOutcome::not_run("we did not build it").unwrap();
report.check5_bus_unreachable_evidence = None;
report.cost.adapters.push(AdapterStatus::NotBuilt {
platform: ROUND_PLATFORM.to_string(),
reason: "ran out of time".to_string(),
});
let _ = is_eligible(&report);
}
/// A check-5 `Pass` or `Fail` never triggers the admissibility check at
/// all — it exists only to gate `NotRun`, and must not fire on a report
/// that never claimed environmental absence.
#[test]
fn a_check_5_pass_or_fail_never_needs_bus_unreachable_evidence() {
let mut report = base_report();
report.check5_accessibility = CheckOutcome::Pass;
report.check5_bus_unreachable_evidence = None;
assert!(is_eligible(&report));
let _ = criterion_cell(&report);
let mut report = base_report();
report.check5_accessibility = CheckOutcome::fail("absent-from-tree").unwrap();
report.check5_bus_unreachable_evidence = None;
assert!(!is_eligible(&report));
let _ = criterion_cell(&report);
}
}

View File

@ -0,0 +1,223 @@
//! Enforces the neutrality boundary `src/lib.rs`'s crate doc comment states:
//! `round2-candidatekit` must not depend on any rendering, windowing, GPU,
//! or platform-accessibility crate — those stay candidate-owned (C1 =
//! egui + lyon, C2 = vello). This test reads this crate's own `Cargo.toml`
//! **at test time** rather than hard-coding "the current dependency list is
//! X" — the point is to catch a *future* dependency add, not merely to
//! assert today's file is fine.
/// Rendering, windowing, GPU, and platform-accessibility crates that must
/// never appear in `round2-candidatekit`'s own `[dependencies]`. This list
/// is the thing under test — it is deliberately hard-coded, unlike the
/// dependency names it is checked against, which are always read fresh from
/// the manifest.
const DENY_LIST: &[&str] = &[
"egui",
"eframe",
"egui-wgpu",
"lyon",
"lyon_path",
"lyon_tessellation",
"vello",
"wgpu",
"winit",
"accesskit",
"accesskit_winit",
"tiny-skia",
"resvg",
"usvg",
];
/// If `header` (the contents of a `[...]` line, already trimmed) names a
/// dependency **sub-table** — TOML's `[dependencies.name]` form, or the
/// same thing nested under a target, `[target.'cfg(...)'.dependencies.name]`
/// — returns `name`. `Cargo.toml` lets a single dependency spread across
/// its own `[...]` header when it needs more than a version string (e.g.
/// `[dependencies.wgpu]\nversion = "0.19"`), and that header names the
/// dependency directly rather than introducing a block of `key = value`
/// lines the way `[dependencies]` does — a scanner that only recognizes the
/// block form misses this shape entirely (confirmed empirically: it
/// returned `[]` for a manifest whose only dependency used this form).
fn dependency_subtable_name(header: &str) -> Option<String> {
let rest = if let Some(r) = header.strip_prefix("dependencies.") {
r
} else if let Some(idx) = header.find(".dependencies.") {
&header[idx + ".dependencies.".len()..]
} else {
return None;
};
// A dependency's own sub-table (e.g. hand-spread build metadata) would
// add a further dot, as in `dependencies.foo.metadata`; only the first
// segment is the crate name.
let name = rest.split('.').next().unwrap_or(rest);
Some(name.trim_matches('"').trim_matches('\'').to_string())
}
/// True if `header` opens a **block** of `key = value` dependency lines —
/// `[dependencies]` itself, or the same thing nested under a target
/// (`[target.'cfg(unix)'.dependencies]`). Deliberately does not match
/// `dev-dependencies` or `build-dependencies`: both end in "dependencies"
/// but with a hyphen, not a dot, immediately before it, so
/// `.ends_with(".dependencies")` is false for them — those tables are out
/// of scope for this guard on purpose (see
/// `the_line_scanner_finds_dependencies_and_ignores_other_sections`).
fn opens_dependency_block(header: &str) -> bool {
header == "dependencies" || header.ends_with(".dependencies")
}
/// Extracts dependency names from a `Cargo.toml`, covering both shapes
/// Cargo accepts: the block form (`[dependencies]` followed by `key =
/// value` lines) and the sub-table form (`[dependencies.name]`), each
/// optionally nested under `[target.'cfg(...)'. ...]`. Deliberately not a
/// TOML parser — pulling one in as a dependency of a crate whose whole
/// point is a short, auditable dependency list would be self-defeating —
/// but a plain line scan that recognizes both header shapes, not just the
/// block one.
fn dependency_names(manifest: &str) -> Vec<String> {
let mut names = Vec::new();
let mut in_dependency_block = false;
for raw_line in manifest.lines() {
let line = raw_line.trim();
if let Some(header) = line.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
let header = header.trim();
if let Some(name) = dependency_subtable_name(header) {
names.push(name);
in_dependency_block = false;
continue;
}
in_dependency_block = opens_dependency_block(header);
continue;
}
if !in_dependency_block || line.is_empty() || line.starts_with('#') {
continue;
}
if let Some((key, _)) = line.split_once('=') {
names.push(key.trim().trim_matches('"').to_string());
}
}
names
}
fn manifest_path() -> std::path::PathBuf {
std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("Cargo.toml")
}
#[test]
fn dependencies_do_not_include_a_denied_rendering_windowing_or_a11y_crate() {
let manifest = std::fs::read_to_string(manifest_path())
.unwrap_or_else(|e| panic!("failed to read {}: {e}", manifest_path().display()));
let names = dependency_names(&manifest);
assert!(
!names.is_empty(),
"the line scanner found zero dependencies in {} — that means this test is vacuous, not \
that the crate has no dependencies (it depends on round2-textkit, round2-diff, serde, \
serde_json); check the scanner, not the crate",
manifest_path().display()
);
let violations: Vec<&String> = names
.iter()
.filter(|n| DENY_LIST.contains(&n.as_str()))
.collect();
assert!(
violations.is_empty(),
"round2-candidatekit/Cargo.toml [dependencies] names denied crate(s) {violations:?} — \
this crate is candidate-neutral apparatus only (rendering, hit-test resolution, and \
accessibility integration are candidate-owned; see src/lib.rs's crate doc comment for \
the ruling this enforces). Denied list: {DENY_LIST:?}"
);
}
/// Sanity check on the scanner itself, against a synthetic manifest
/// fragment: if this fails, the test above could be silently vacuous no
/// matter what `[dependencies]` actually contains. Also confirms
/// `[dev-dependencies]` is not scanned — a denied crate under dev-only use
/// (impossible here, since this crate declares none, but stated as a
/// property of the scanner) must not trip the production-dependency check.
#[test]
fn the_line_scanner_finds_dependencies_and_ignores_other_sections() {
let synthetic = "[package]\nname = \"x\"\nversion = \"0.1.0\"\n\n[dependencies]\nserde = \
\"1\"\nwgpu = \"0.19\"\n\n[dev-dependencies]\nwgpu = \"0.19\"\n";
let names = dependency_names(synthetic);
assert_eq!(names, vec!["serde".to_string(), "wgpu".to_string()]);
}
/// Confirms the scanner (and by extension the test above) actually flags a
/// denied name when one is present — otherwise `violations.is_empty()`
/// could be vacuously true because the scanner finds nothing, not because
/// the manifest is clean.
#[test]
fn a_synthetic_manifest_with_a_denied_dependency_is_flagged() {
let synthetic = "[dependencies]\nserde = \"1\"\ntiny-skia = \"0.11\"\n";
let names = dependency_names(synthetic);
let violations: Vec<&String> = names
.iter()
.filter(|n| DENY_LIST.contains(&n.as_str()))
.collect();
assert_eq!(violations, vec![&"tiny-skia".to_string()]);
}
// ---- F2: the dotted sub-table form, confirmed empirically to be missed ----
//
// Feeding the original scanner
// `"[dependencies]\nserde = \"1\"\n\n[dependencies.wgpu]\nversion = \"0.19\"\n"`
// returned `["serde"]` — `wgpu` never appeared, because the scanner only
// recognized `[dependencies]` as a block header and had no notion of a
// dependency named directly by its own `[...]` header. Each test below
// would fail if `dependency_subtable_name`'s handling were removed (i.e.
// if `dependency_names` fell back to the old block-only logic).
/// The bare sub-table form: `[dependencies.wgpu]`.
#[test]
fn the_scanner_detects_a_dependency_named_via_a_dotted_subtable_header() {
let synthetic = "[package]\nname = \"x\"\n\n[dependencies]\nserde = \"1\"\n\n\
[dependencies.wgpu]\nversion = \"0.19\"\n";
let names = dependency_names(synthetic);
assert!(
names.contains(&"wgpu".to_string()),
"sub-table form missed: {names:?}"
);
let violations: Vec<&String> = names
.iter()
.filter(|n| DENY_LIST.contains(&n.as_str()))
.collect();
assert_eq!(violations, vec![&"wgpu".to_string()]);
}
/// The block form nested under a target: `[target.'cfg(unix)'.dependencies]`.
#[test]
fn the_scanner_detects_a_dependency_block_under_a_target_cfg_table() {
let synthetic =
"[dependencies]\nserde = \"1\"\n\n[target.'cfg(unix)'.dependencies]\nwgpu = \"0.19\"\n";
let names = dependency_names(synthetic);
assert!(
names.contains(&"wgpu".to_string()),
"target-cfg block form missed: {names:?}"
);
let violations: Vec<&String> = names
.iter()
.filter(|n| DENY_LIST.contains(&n.as_str()))
.collect();
assert_eq!(violations, vec![&"wgpu".to_string()]);
}
/// Both forms combined: the sub-table form nested under a target,
/// `[target.'cfg(windows)'.dependencies.tiny-skia]`.
#[test]
fn the_scanner_detects_a_dotted_subtable_header_under_a_target_cfg_table() {
let synthetic = "[target.'cfg(windows)'.dependencies.tiny-skia]\nversion = \"0.11\"\n";
let names = dependency_names(synthetic);
assert!(
names.contains(&"tiny-skia".to_string()),
"target-cfg sub-table form missed: {names:?}"
);
}
/// A dependency's own further sub-table (e.g. a spread-out `package`
/// rename) must still resolve to the crate name, the first dotted segment
/// after `dependencies.`, not the whole trailing path.
#[test]
fn a_deeper_dotted_path_still_resolves_to_the_leading_crate_name() {
let synthetic = "[dependencies.serde.metadata]\nfoo = 1\n";
let names = dependency_names(synthetic);
assert_eq!(names, vec!["serde".to_string()]);
}