epiphany/spikes/editor-toolkit/DECISIONS.md

484 lines
28 KiB
Markdown

# T4 toolkit spike — decisions and findings log
Governed by `spec/CONTRACT_EDITOR_T4_SPIKE.md`. This file records
implementation decisions and named deviations, per round.
## Round 0 — accessibility route + desk survey
**Environment prerequisite, not obvious from the contract:** on this
machine (sway, AT-SPI2 via `at-spi-bus-launcher` + `at-spi2-registryd`),
AT-SPI application registration is gated behind two settings that are
*off* by default even though the bus itself is always up:
```
gsettings set org.gnome.desktop.interface toolkit-accessibility true
gdbus call --session --dest org.a11y.Bus --object-path /org/a11y/bus \
--method org.freedesktop.DBus.Properties.Set org.a11y.Status \
ScreenReaderEnabled "<true>"
```
Without both, `Atspi.get_desktop(0)` enumerates **zero** applications even
while a probe process is alive, rendering, and actually connected to the
AT-SPI D-Bus (confirmed separately via `busctl --address
unix:path=$XDG_RUNTIME_DIR/at-spi/bus list`). This is a real environment
absence, not a candidate defect, and any later round run in a fresh
sandbox/session must redo both steps before trusting a `NOT RUN` verdict on
accessibility.
**Verifier substitution.** The contract's Round 0 evidence rule allows "a
small verifier binary using the `atspi` crate, **or an equivalent AT-SPI
client**". `a11y-verifier/verify.py` uses `gi.repository.Atspi` (the
official AT-SPI2 GObject-introspection binding — the same library behind
Orca and Accerciser) instead of the Rust `atspi` crate. This was a
deliberate substitution: the Rust crate's async zbus proxy API would have
had to be learned from source rather than from any working example, and
`pyatspi` was already confirmed reachable on this machine. It is a
standalone process, external to every probe, and performs a real tree walk
from the AT-SPI registry — it satisfies "a real client query of the tree",
not "printing your own struct".
**C1 (egui).** First-party route, the full chain being
`eframe``egui-winit``accesskit_winit``accesskit_unix`: `egui-winit`'s
`accesskit` feature is literally `dep:accesskit_winit`
(`egui-winit-0.35.0/Cargo.toml:55`), on by default through `eframe` in 0.35, and
`accesskit_winit` delegates to the platform crate. So C1 gets the
window-lifecycle handling that C3's bypass would have forfeited. No manual
wiring was needed. `probe-egui` draws one button; readback: **PASS**. See
`round0-evidence/c1-egui-readback.txt`.
**Carry forward — C1's frame node is unnamed.** C1's readback path is
`application:'probe-egui' / frame:'' / button:'EpiphanyProbeButton'`, where C2
and C3 both name their frame. The window title does not reach the AT-SPI frame
node under `eframe` 0.35's default wiring. **Non-disqualifying** — round 0
requires one node with a role *and* a name, and the button carries both — but
it is a real gap: a screen-reader user hears an unnamed window. Round 3
(accessibility semantics) must check it, since window identity is part of
navigation, and it should not be rediscovered there as a surprise.
**C2 (vello + winit).** Manual `accesskit_winit` route, exactly as named by
the contract: `probe-vello` builds the accessibility tree by hand
(`accesskit::TreeUpdate`) and drives it through
`accesskit_winit::Adapter::with_event_loop_proxy`, wired into the same
`winit::application::ApplicationHandler` that owns the vello
`RenderContext`/`Renderer`/`Scene` (the vello render pass is real, not a
stub — it draws a filled rounded rect every frame, following vello's own
`examples/simple` pattern at `linebender/vello@main`). Readback: **PASS**.
See `round0-evidence/c2-vello-readback.txt`.
**C3 (iced) — ROUND-0 RESULT: FAIL. Eliminated at round 0, adjudicated
2026-07-28 by coordinator review; no waiver sought or granted.** The initial
report recorded this as "PASS with a flagged deviation". That adjudication was
wrong and is corrected here. Under pin 14(c) C3's disqualifying set is not
passed; keeping it would require an explicit recorded ruling amendment, which
was declined.
**Dual attribution, because the two failures are different in kind.**
*Candidate limitation — this alone fails the round.* iced 0.14 ships **no
accessibility integration at all**: `accesskit` appears in no iced crate
manifest (verified across every `iced*` crate in the 0.14 tree). And its
**stock runner** exposes to application code neither a
`winit::event_loop::ActiveEventLoop` nor a pre-visibility
`winit::window::Window`; both appear only inside `iced_winit`'s own private
`ApplicationHandler` impl, with `create_window` at `iced_winit-0.14.0/src/lib.rs:350`
inside iced's runner. Every `accesskit_winit::Adapter` constructor requires
both and panics if the window is already visible.
**Scope this to the stock runner, deliberately:** `iced_winit`'s own docs offer
a `conversion` module "for users that decide to implement a custom event loop",
so a hand-built shell carrying a real route remains **conceivable but
unproven** — and it would mean owning the shell. Upstream iced #552 remains
open. "Provably closed" applies to the stock runner, not to iced in principle.
*Probe-design defect — why the first report read PASS.*
`accesskit_unix::Adapter::new()` takes **no window handle**, only handlers, and
registers with AT-SPI from process identity
(`accesskit_unix-0.22.1/src/context.rs`; `app_name()` reads
`std::env::current_exe()`). `probe-iced` therefore registered a **hand-built
static tree**, decoupled from iced's window, focus, and event lifecycle, with
every action discarded — while `view()` happened to label its button
identically, which is what made the transcript read as though iced produced it.
**Deleting iced from the probe would produce the identical readback.** That is
the disqualifying fact: round 0 asks whether the *candidate* exposes a route,
and a process-level side channel answers a different question. That
`accesskit_unix` sits one layer beneath `accesskit_winit` does not make it a
route *for the candidate* — that was the reasoning error, and it is recorded as
a probe defect rather than folded into the candidate's result.
Evidence is preserved rather than rewritten: `round0-evidence/c3-iced-readback.txt`
keeps the verifier's factual `READBACK: PASS` under a `ROUND-0 RESULT: FAIL`
annotation, so the false positive stays visible alongside its adjudication.
One consequence survives the corrected verdict and is worth carrying, because
it would apply to any future hand-built route: bypassing `accesskit_winit`
forfeits that crate's window-lifecycle handling — deactivation on window close,
multi-window disambiguation, focus-driven activation. Any real iced integration
would have to build and maintain that wiring itself rather than inheriting it,
which is a maintenance-surface fact, not merely a round-0 curiosity.
## Round 0 — desk survey
All version/date/MSRV figures were fetched live (crates.io API + GitHub
`Cargo.toml` at the released tag), not from memory or the contract's
2026-07-23 snapshot. See the Round 0 report for the full table.
Fill-rule documentation, quoted verbatim from source (not inferred from
behavior — that is round 1's job):
- **C1 (`lyon_tessellation` 1.0.20, via `lyon_path` 1.0.19):**
`pub enum FillRule { EvenOdd, NonZero }`, with
`DEFAULT_FILL_RULE: FillRule = FillRule::EvenOdd` and an explicit
`is_in(winding_number)` implementation for both.
- **C2 (`peniko` 0.6.1, vello's fill-style type):** `pub enum Fill { NonZero, EvenOdd }`,
each with a full doc comment ("All regions where the winding number of
the path is not zero will be filled" / "... is odd will be filled").
- **C3 (`iced_graphics` 0.14.0, `geometry::fill`):**
`pub enum Rule { NonZero, EvenOdd }`, doc pointing at the SVG
`fill-rule` spec, default `NonZero`.
All three document both rules explicitly. No candidate is eliminated on
this desk-survey item; round 1 is where it is actually tested.
## Round 1 — the precommitted oracle: SUPERSEDED FIRST PASS
> **SUPERSEDED DISCOVERY — NEVER COMMITTED.** This section records the
> four-glyph oracle built against the pre-Revision-6 contract, kept because its
> two findings are what *caused* the amendment. It was never committed and is
> not the oracle any candidate renders against; **the authoritative account is
> the Revision 6 amendment section below** (five glyphs, two check classes,
> explicit status model). In particular, `fClef`'s `background_satisfied =
> false` below is the superseded encoding — under Revision 6 `fClef` is a
> *satisfied* disjoint-component result — and every "committed" in this section
> describes an intent that this pass never reached.
**This is the oracle only — no candidate has rendered against it yet.**
`round1-oracle/` (new spike-workspace member) derives, for `gClef`, `fClef`,
`timeSig8`, `accidentalFlat` from `BravuraGlyphCatalog::render_data`: a
pinned staff-space -> device-pixel transform, Bezier-flattened outlines
(recursive de Casteljau, 0.0005 staff-space flatness tolerance), an even-odd
/ nonzero point-in-path classifier, and a grid search (0.01 staff-space
step) that derives >=3 ink and >=3 bounded-hole background sample points per
glyph, each with >=8 device-px clearance from any outline edge, entirely
programmatically — no coordinate here was chosen by eye. Output:
`round1-oracle/oracle.json` (committed, machine-readable) and
`round1-oracle/ORACLE_SUMMARY.md` (human-readable). **No rendering,
tessellation, or windowing crate is a dependency of this crate** — see its
Cargo.toml and module doc comment.
**Transform (pinned):** `device = (staff.x * scale + tx, ty - staff.y *
scale)`, `scale = 100` device px per staff space, `tx`/`ty` center each
glyph's own flattened bounding box in the pin-4 1920x1080 target — one
uniform rule applied identically to all four glyphs, not a per-glyph fudge.
**Subpath counts.** All four match the contract's recorded starting point
exactly: `gClef` 4, `fClef` 3, `timeSig8` 3, `accidentalFlat` 2 (asserted in
`subpath_counts_match_the_recorded_starting_point`).
**Finding — `fClef` has no bounded hole at all.** Its three subpaths (the
solid clef bowl, area 2.534, plus two disjoint dot subpaths, areas 0.153 and
0.148) are not nested — the search finds **zero** grid hits inside the outer
contour and outside the even-odd fill, at both the main search's resolution
and an independent finer corroboration (0.005 staff-space step, no
clearance floor at all;
`finding_corroboration::fclef_has_no_bounded_hole_at_any_resolution_or_clearance`).
Per Round 1's hole-check clause ("asserted by the derivation, not assumed"), no fallback/relaxed point was substituted:
`fClef`'s oracle carries 3 ink points and **zero** background points, and
`background_satisfied = false` is recorded in `oracle.json` alongside the
diagnostic. The other three glyphs each got 3 ink + 3 background points with
no relaxation needed (thousands of qualifying grid candidates for each).
**Finding — even-odd and nonzero agree on every hole point found.** Mutation
(b) (flip even-odd to nonzero on the selected hole points) was run for
`gClef`, `timeSig8`, `accidentalFlat`'s hole points and **none reclassify**:
Bravura's outer contour and its hole wind in *opposite* directions (the
well-formed-font convention), so nonzero winding cancels to 0 at exactly the
points where the even-odd crossing count is 2 — both rules correctly exclude
the hole. This is real information about the font data (recorded, not
smoothed over), and it means the literal "flip the rule" mutation does not,
for this data, demonstrate that fill-*rule* choice is load-bearing. A
supplementary mutation
(`mutation_b_supplement_hole_points_reclassify_under_naive_outer_only_fill`)
demonstrates the property the check actually needed — that respecting inner
subpaths at all is load-bearing — by showing every selected hole point *is*
inside a naive outer-contour-only fill (what a renderer ignoring holes
entirely would wrongly paint as ink) while being outside the real
whole-outline fill.
Full mutation evidence (a/b/b-supplement/c) is in
`round1-oracle/src/lib.rs`'s `tests` and `finding_corroboration` modules, run
via `cargo test -p round1-oracle -- --nocapture`.
## Round 1 — Revision 6 amendment (five glyphs, two check classes, status model)
**Both findings above changed the contract, not just this oracle.**
`fClef` having no bounded hole and criterion 1's fill-*rule* framing being
moot on Bravura's correctly (oppositely) wound contours were reported as
findings against the pre-Revision-6 contract; the coordinator's response was
to amend `spec/CONTRACT_EDITOR_T4_SPIKE.md` to Revision 6 rather than treat
`fClef` as a shortfall. Round 1 is now **five glyphs in two check classes**,
and this oracle is amended to match (uncommitted diff on top of the original
oracle; the mechanics below are unchanged — same flattening, same
point-in-path classifier, same transform, same clearance floor — only the
requirement model and the glyph set changed):
- **Bounded-hole check** (unchanged mechanics): `gClef`, `timeSig8`,
`accidentalFlat`, and now **`noteheadHalf`** (new: two subpaths, measured
`ring_signed_areas = [0.903, -0.368]` — the authoritative figure, now also
what the contract records; an earlier `[0.902, -0.367]` came from a coarser
fixed-step flattening). Still >=3 ink + >=3 background points per glyph, every
background point inside a bounded hole.
- **Disjoint-component check** (new): `fClef` alone, no longer folded into
the bounded-hole class it never actually belonged to. It carries **no
background requirement** — that is its design (bowl plus two solid,
disjoint dots) — and instead requires >=1 ink point inside **each** of its
three filled subpaths, each point tagged with `subpath_index` so the
oracle proves coverage of every component rather than three ink points
that could all land in the bowl. Verified two ways: a finer (0.005
staff-space step) grid corroboration (renamed
`fclef_has_zero_bounded_hole_grid_hits_at_a_finer_0_005_grid`, since the
old name overclaimed "any resolution" for what was actually one grid) and
a genuinely resolution-independent topological check,
`subpaths_are_mutually_non_nested` /
`fclef_subpaths_are_topologically_non_nested`, which asserts no subpath's
vertices lie inside another subpath — a property of the flattened polygon
itself, not of any sampling step, and what actually justifies the
stronger claim.
**Status model.** `GlyphOracle` now carries an explicit `Requirement` enum
(`BoundedHole` / `DisjointComponents`), per-requirement booleans
(`background_required`/`background_satisfied`,
`subpath_coverage_required`/`subpath_coverage_satisfied`), and one overall
`satisfied: bool` — the field to read. `fClef` is `satisfied = true` with
`background_required = false` and zero background points; a bounded-hole
glyph that failed to find enough background points would be
`satisfied = false`. The two are no longer distinguishable only by an absent
field, which was the defect the amendment closes.
**Fill-rule equivalence, now asserted, not merely reported.** The original
oracle's `mutation_b_...` test printed `reclassifies_under_nonzero=false` for
every hole point without asserting on it. `derive_bounded_hole_oracle` now
asserts `!ev.nonzero_filled` for every selected background point at
derivation time (fails loudly if the opposite-winding assumption is ever
false for a glyph's real data), and
`mutation_b_nonzero_rule_equivalence_on_hole_points_is_asserted` asserts the
same independently at the test level. `ring_signed_areas` — the measured
signed area of every subpath, in ring order — is now recorded on every
glyph's oracle output (`oracle.json` and `ORACLE_SUMMARY.md`), not just
observed in this log: `gClef [8.702, -0.691, -1.803, -0.509]`, `timeSig8
[2.674, -0.435, -0.515]`, `accidentalFlat [1.040, -0.257]`, `noteheadHalf
[0.903, -0.368]`, `fClef [2.534, 0.153, 0.148]` — all within measurement
tolerance of the contract's recorded values.
**New kill test.** `mutation_d_largest_contour_only_fill_misses_the_dot_points`
is the disjoint-component analogue of the outer-contour-only mutation kept
from the original oracle: it asserts that filling only `fClef`'s largest
subpath (the bowl) fails to contain either dot's ink point — the literal
"tessellator keeps only the largest contour" scenario Round 1 names as the
reason this check exists. The original outer-contour-only mutation
(`mutation_b_supplement_...`) is unchanged and still runs, now over all four
bounded-hole glyphs including `noteheadHalf`.
**Citations.** Every `pin 13 item N` citation in `round1-oracle/src/lib.rs`,
`src/main.rs`, `ORACLE_SUMMARY.md`, and this file has been replaced with a
direct citation to the Round 1 clause's own wording — pin 13 has no numbered
items in Revision 6 (or any prior revision), so those citations pointed at
nothing.
`oracle.json` and `ORACLE_SUMMARY.md` are regenerated from the amended code
(`cargo run -p round1-oracle` from `spikes/editor-toolkit/round1-oracle/`).
## Round 1 — compound-path fill correctness (candidates)
**ROUND-1 RESULT: C1 PASS, C2 PASS.** Both candidates reproduce all 27
precommitted sample points on both required adapters — 54 samples each, 108
in total, every one landing exactly on `(0,0,0,255)` or `(255,255,255,255)`
with no antialiased near-miss anywhere. Neither candidate is eliminated at
this rung. Evidence: `round1-evidence/c1-egui-lyon-frozen-run.txt`,
`round1-evidence/c2-vello-frozen-run.txt`.
**The three SHAs this result is anchored to** (pin 12):
| | |
|---|---|
| Root baseline | `0a35697d8e48e65d62cd96c19eec2431e414359c` |
| Oracle commit | `0a35697d8e48e65d62cd96c19eec2431e414359c` |
| Candidate-harness commit | `c20bc93` |
The authoritative run was made from a detached `git worktree` at the root
baseline, with the candidate harness extracted into it from `c20bc93` via
`git archive`. So the `epiphany-glyphs` / `epiphany-layout-ir` code that
supplied every outline is the frozen baseline's, not the working tree's — the
parallel genesis-ops track has been editing `epiphany-core` and
`epiphany-ops` throughout, and a run against the live tree could not have
claimed a fixed subject. `oracle.json` hashes to
`b3fc017bdccb7e19feb44a1fde9f15d6e0e8d403cafcb9296eaadf21f3b5bb96` in the
worktree and in the working tree, unchanged since it was frozen.
**Both candidates draw the whole outline as one compound path** — C1 one
`lyon` `tessellate_path` over every contour, C2 one `BezPath` and one
`Scene::fill`. Filling subpath-by-subpath would paint every counter solid and
pass a test built to catch exactly that, so the single call is load-bearing,
not stylistic.
**C1 goes through egui's own paint pipeline, not a bare wgpu one.** Ruling A
names the candidate as "lyon-tessellated meshes inside egui"; rendering the
lyon mesh through a hand-rolled pipeline would test lyon and answer a
different question — the same failure shape as Round 0's iced side channel,
which read back cleanly while proving nothing about its subject. C1 builds a
real `epaint::ClippedPrimitive` and calls `Renderer::update_buffers` +
`Renderer::render`.
**Carry forward — a blank target is a silent pass shape, and it happened.**
C1's first run failed all 15 ink points while passing all 12 background
points. That pattern is not a fill defect; it is the signature of nothing
being drawn. The cause: the mesh named `TextureId::default()` (the font
atlas) without one being uploaded, and `egui-wgpu` skips primitives whose
texture id is unregistered — `if let Some(..) = self.textures.get(&mesh.texture_id)`
(`egui-wgpu-0.35.0/src/renderer.rs:542`), no error, no warning. C1 now
registers its own 1x1 opaque-white texture. **The general lesson matters more
than the fix:** had Round 1 tested only background points, or only "does it
render without erroring", a blank target would have passed. Every later round
must keep at least one assertion that can only succeed if ink was actually
deposited.
**Carry forward — nominal 8x AA is not the same mechanism on both sides.**
Pin 4 requires an identical MSAA sample count, and both candidates now declare
8x (an earlier C1 revision ran 4x against C2's 8x, which is not a common
configuration at all; vello's `AaConfig` offers only Area / Msaa8 / Msaa16, so
8x is the ceiling both can name). **The integers match; the mechanisms do
not.** C1's 8x is hardware multisampling — a `sample_count: 8` colour
attachment resolved by the GPU, which is why C1 must request
`TEXTURE_ADAPTER_SPECIFIC_FORMAT_FEATURES`: 8x on `Rgba8Unorm` is outside the
WebGPU baseline, which guarantees only `[1, 4]` for that format (both adapters
report `[1, 2, 4, 8]` with the feature enabled). C2's `Msaa8` is vello's own
compute-shader antialiasing writing into a `sample_count: 1` storage texture;
no multisample attachment exists on that side at all. Pin 4 is satisfied **as
stated** — same declared sample count — but equal integers do not imply equal
work, equal memory traffic, or equal cost. Round 1 is a capability rung and is
indifferent to the difference. **Round 4 is not**, because AA lands directly
in the deciding latency numbers, and "8 == 8" must not be allowed to stand in
for parity there. The run report prints the mechanism beside the number
(`msaa=8 (hardware MSAA render-target attachment, GPU-resolved)` vs
`msaa=8 (vello compute AA into a sample_count:1 storage texture)`) so the
record carries it without anyone having to remember.
**Format deviation, named.** Both candidates render to `Rgba8Unorm`, not the
sRGB target pin 4 names, because vello's `render_to_texture` requires
`Rgba8Unorm` + `STORAGE_BINDING`. Both are pinned to the same format for
fairness. Immaterial to fill correctness — only pure black and white are
drawn, and 0 and 255 map to themselves under any transfer function.
**Both adapter classes are enforced, not assumed.** Each binary checks for one
`DiscreteGpu` and one `IntegratedGpu` Vulkan adapter before it will report
overall PASS; a missing class returns `NOT RUN` naming the adapters actually
found, as an environment absence rather than a candidate failure. Reporting
PASS after testing whichever adapter happened to enumerate would silently
narrow the claim — and the integrated figure is the one that decides later
rungs.
**The harness errors rather than substituting.** Three readback failure modes
were all capable of turning a broken run green, and all three are now hard
errors: a short buffer previously yielded `(0,0,0,0)`, whose luma is 0, so it
classifies as *ink* and every ink point would have passed; `device_index`
clamped out-of-range coordinates to an edge pixel, which for a centred glyph
is always background, so a mis-transformed sample reported on a pixel the
oracle never named; and a non-opaque sample means the clear or the blend is
wrong, not that the candidate drew the wrong colour. Buffer length, coordinate
range, and sample opacity are all checked before any classification.
**The oracle is no longer editable into agreement.** `deny_unknown_fields` on
every deserialized type catches *structural* drift (serde ignores unknown
fields by default, so without it the "drift is a deserialization error" claim
this crate rests on was simply false). But semantic drift is the dangerous
kind, and the first `OracleFile::validate` checked the oracle against **its
own other fields** — each glyph's target against the *first glyph's* target,
each subpath count against the oracle's own `expected_subpath_count`. That
accepts any self-consistent file: deleting a glyph, renaming one, or resizing
every target together all validated cleanly. The validator now checks against
literals restated in the harness itself — the exact five-glyph roster with its
requirement mapping, subpath counts and point counts, the 27-point census,
1920x1080, an 8 px clearance floor and every individual sample's clearance,
`ink_satisfied`, the relaxed-spacing flags, and the requirement-specific
status flags. Bounded-hole evidence must be unfilled under **both** rules, not
just even-odd, since that agreement is the measured fact criterion 1 rests on.
Thirteen mutations were run against it and all thirteen are rejected: deleting
`noteheadHalf`; renaming `gClef`; duplicating a glyph over another; dropping
one sample point; retargeting every glyph to 3840x2160 together; lowering the
declared clearance floor to 2; setting one sample's clearance to 3 px;
clearing `ink_satisfied`; setting `ink_spacing_relaxed`; swapping `fClef`'s
requirement class; changing `gClef`'s subpath count in both fields at once;
marking hole evidence `nonzero_filled`; and declaring `round2`. The oracle was
restored by reversal after each and re-hashes to `b3fc017b...`.
---
## Round 2 Packet 2A — the precommitted apparatus, and one carry-forward
Packet 2A landed at `565b0f8` (recipe) + `ffe313c` (four crates), accepted
2026-07-29. What it is, and the rulings recorded alongside it, are in
`ROUND2_TEXT_RECIPE.md`. Recorded here because it outlives Round 2: the
**check-5 asymmetry**, ruled 2026-07-29.
### The asymmetry
Check 5 (accessibility) is in the disqualifying set. The recipe pins it
per-platform rather than in one toolkit's vocabulary, precisely so it does not
encode one candidate's stack — but neutrality of the *criterion* does not make
the *cost* symmetric. Two candidates can both read PASS while having paid
different prices, and a PASS/FAIL cell has nowhere to say so.
**What each candidate actually starts from, stated without inflating either
side.** An earlier draft of this entry had it as "C1 inherits an accessibility
tree; C2 must build one", which overstates both:
* **C1 (egui)** inherits AccessKit and its **platform bridge** — the tree
plumbing and the OS-side adapters are already in the dependency graph. It
does **not** inherit correct semantic nodes for **custom-rendered text**: a
score canvas draws glyphs egui knows nothing about, so the nodes that carry
the source string, its role, and its bounds are C1's to create either way.
Inheriting the bridge is not inheriting the semantics.
* **C2 (vello)** inherits **no** accessibility integration — vello is a
rendering crate. But it is free to integrate AccessKit itself, or another
bridge, so it does **not** face building an accessibility stack from
scratch. What it owns is the **additional integration and wiring**: bringing
a bridge in, driving it, and keeping it attached to a window/event loop that
egui already manages.
So the delta is real but narrower than "has it / doesn't have it", and it is
concentrated in integration and wiring rather than in the semantic node
construction both candidates must do.
**Packet 2B measures that delta. It does not presume its magnitude.** Writing
down a guess at the size of the gap and then confirming it is the same failure
as choosing a tolerance after seeing a candidate's output — the number has to
come from the work, not from the expectation.
### The ruling
1. **Check 5 stays candidate-neutral PASS/FAIL.** The scoring does not change,
and is not adjusted for who had further to walk. A criterion that penalises
a candidate for the shape of its dependency graph is not a criterion.
2. **Packet 2B measures the cost separately**, per candidate, as observed
facts rather than as a judgement about who had it easier:
* which parts of the accessibility path are **inherited** and which are
**candidate-owned** — reported at that granularity, since the honest
answer for both candidates is "some of each";
* the **dependencies added** to reach PASS, over the candidate's Round 1
baseline;
* which **platform adapters** are actually implemented, and which are not
(an adapter not built is not a failure — it is scope not covered, and it
must be reported as such rather than absorbed into a PASS);
* the **integration and wiring** each candidate writes itself, which is
where the real delta sits;
* the resulting **maintenance surface** — the code the project would own
forever if that candidate wins.
3. **The final ruling weighs that cost**, and does so **without retroactively
changing check 5's scoring.** The verdict may prefer a candidate on
implementation cost; it may not relabel a PASS.
The general rule this is an instance of, worth applying to later rungs: **an
eligibility gate answers "may this candidate proceed", not "what will this
candidate cost".** The two questions have different answers often enough that
merging them into one cell loses the second one entirely — and the second is
the one the project lives with after the ruling.