epiphany/spikes/editor-toolkit/DECISIONS.md

24 KiB

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 eframeegui-winitaccesskit_winitaccesskit_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....