Editor T4 opens: the toolkit spike gets a contract that cannot be argued with
Ruling A grants the architecture -- direct vector canvas over ResolvedLayoutIR, viewport-culled, per-system damage -- and deliberately leaves the toolkit open for this spike to decide under six recorded criteria. All three prerequisites are discharged (W1dd33b34, W224f8c80, W3f639919), so the tranche opens. The contract's problem is not what to build but how to keep the answer honest. Six criteria across three candidate stacks is unbounded work with a dozen places for the verdict to be decided by whoever writes the report. So the structure is an elimination ladder, cheapest disqualifier first, with every deciding number pinned before it is measured: the fixture ladder F1-F4 and its musical-content recipe, the deciding rung, the equivalence band, the tie-break and its three operationally defined axes, and the oracles -- committed, with their commit recorded, before any candidate renders against them. Five review rounds shaped it, and most of what they caught was mine. Measurement. Timed work must be completed GPU work, not submission, or a candidate that returns after enqueueing looks fast. One common configuration across all candidates: offscreen, 1920x1080, identical MSAA and format, --release --locked, both adapters with the integrated one deciding. Freehand latency runs from scheduled event injection to completion of the first frame containing that event, backlog included; timing from renderer pickup would let a candidate buffer the trace, render late, drop nothing, and report fast update work while lagging visibly. Damage. The first draft compared raw PrimitiveIndices, which are positions in flat arrays -- so a pitch change that preserves primitive count moves geometry while every vector stays identical, and an inserted accidental renumbers indices so untouched systems look changed. It is now fingerprints over dereferenced primitives, unowned included. And byte equality alone lets a full redraw masquerade as per-system damage, so the oracle is paired with a reuse assertion over the scene cache, at every engraving-valid rung, before the ranking round rather than inside it. Outcomes. A check that ran and got the wrong answer is FAIL, not NOT RUN; conflating them let a broken implementation buy an escalation instead of recording negative evidence. Capability and timing are separate cells. Eligibility is a disqualifying set tracked apart from criterion cells, so a user waiver actually reaches the tie-break instead of leaving a decision with no effect. Both no-winner paths are total: disqualification, and ranking-loss wipeout when nobody renders the deciding rung. Maintenance inputs are reproducible rather than selectable: upstreams pinned per candidate before any figure is fetched, composites taking the worse value per axis, maintainer identity machine-observable, the issue snapshot archived, and unanswered issues contributing infinity rather than their current age -- which would otherwise score twenty ignored issues opened yesterday as excellent responsiveness. What the spike is not: the app. No command registry, no editing, no document layer, no goldens. Ruling D creates epiphany-editor-app fresh after this verdict. And it does not reopen Ruling A -- a finding that no candidate can implement the granted architecture escalates as an amendment request and does not silently promote the SVG path back. Docs only; no code, no crate, no gate movement. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01RSX4zSLgKvtiXaPjnMqLGz
This commit is contained in:
parent
7df5ca17ce
commit
61890a046f
|
|
@ -0,0 +1,653 @@
|
|||
# Contract: Editor T4 — the toolkit spike
|
||||
|
||||
**Revision 5** (2026-07-28), after four reviews. Revision 1 left discretion in
|
||||
the measurement and elimination mechanics; revision 2 reordered the ladder and
|
||||
pinned the deciding numbers, but its new structure carried its own defects —
|
||||
a damage oracle that compared the wrong thing, correctness failures dressed as
|
||||
environmental absences, and an escalation branch with no continuation.
|
||||
Revisions 3 and 4 closed those and introduced smaller ones of their own — a
|
||||
damage gate covering one rung, a censored median that flattered silence, and a
|
||||
latency threshold with no timer origin. This revision closes those.
|
||||
|
||||
Repo root `/home/jeans/Repos/active/epiphany`. Governed by
|
||||
`spec/PLAN_EDITOR_APP.md` Ruling A ("What this ruling does **not** pin: the
|
||||
toolkit/tessellation stack. The T4 spike decides it, bounded by these recorded
|
||||
criteria" 1–6) and Ruling D (the app crate is created **at T4**, and the spike
|
||||
opens the tranche). All three §3.7 prerequisites are discharged: W1 `dd33b34`,
|
||||
W2 `24f8c80`, W3 `f639919` (`spec/ANALYSIS_TEXT_RUN_PRIMITIVES.md`).
|
||||
|
||||
Execution model as T1a/T2/T3/W1/W2: Sonnet subagents implement per round,
|
||||
coordinator line-level review with independent re-runs, **user deep-dives at
|
||||
this contract's sign-off, at every precommitted oracle before candidates run
|
||||
against it, at every measured table before it enters the ruling, and at the
|
||||
final verdict**. Mutation discipline where there are assertions to mutate:
|
||||
anchor-assert before substituting, restore by reversing, never `git checkout`,
|
||||
never `git stash`.
|
||||
|
||||
**Parallel safety.** The genesis-operation tranche owns `epiphany-core`,
|
||||
`epiphany-ops`, `epiphany-bundle` (at G2), all `.tex`, and testkit's
|
||||
requirement-label constants, vectors, and generators
|
||||
(`spec/PLAN_GENESIS_OPS.md`). **This packet touches none of them**, and by
|
||||
pin 2 it modifies no existing crate at all. Requirement and conformance counts
|
||||
are reported as observed, never asserted from memory; this packet moves
|
||||
neither. Pin 12 additionally freezes the *sources* the spike measures against,
|
||||
which file-level non-overlap does not by itself achieve.
|
||||
|
||||
---
|
||||
|
||||
## The verified starting point
|
||||
|
||||
Confirmed in the tree on 2026-07-28. Several of these make the spike smaller;
|
||||
two make it possible at all.
|
||||
|
||||
* **The tessellator's input already exists.** W2's `epiphany-glyphs` exposes
|
||||
`BravuraGlyphCatalog` implementing `GlyphCatalog` with a real `render_data`,
|
||||
returning `GlyphRenderData { outline: Vec<PathCommand>, .. }` over
|
||||
`PathCommand::{MoveTo, LineTo, CurveTo, Close}` in staff-space units
|
||||
(`glyphs/src/catalog.rs:21,39`; `layout-ir/src/glyph.rs:283`). No candidate
|
||||
needs to parse SVG `d` strings, and no glyph work belongs in this packet.
|
||||
* **Glyphs with holes are in the bundled set, and can be named rather than
|
||||
assumed.** 19 of the 37 bundled outlines have more than one subpath;
|
||||
`gClef` (4), `fClef` (3), `timeSig8` (3), `accidentalFlat` (2) are the
|
||||
useful ones. Criterion 1 has real material.
|
||||
* **Per-system ownership is populated only by the real solver.** `Engraver`'s
|
||||
casting module builds one `PrimitiveIndices` per system
|
||||
(`engrave/src/casting.rs:1185`); the **stub solver publishes everything
|
||||
`unowned`** by deliberate honesty (`layout-ir/src/resolved.rs:82-87`).
|
||||
Damage-based repaint is therefore untestable under the stub — the spike
|
||||
uses `Engraver`, or it is not measuring Ruling A's mechanism.
|
||||
* **`ResolvedLayoutIR::systems()` exists and is documented as adopted "at T4,
|
||||
not before"** (`resolved.rs`). This packet is its first consumer.
|
||||
* **There is no large fixture.** The testkit's largest is **two staves ×
|
||||
twelve measures** (`two_staff_wrapping_pressure`, `fixtures.rs:346`); the
|
||||
three-staff fixture is **one measure** (`three_staff_close_content`,
|
||||
`fixtures.rs:225`), and the `ten_measure_*` family is single-staff. No
|
||||
fixture is both wide and long. The spike builds its own ladder — pin 7.
|
||||
* **There is no `ResolvedText`.** W3 recommended disposition E and the `.tex`
|
||||
amendment is the core/layout track's work, unlanded. See pins 8–10.
|
||||
* **Today's SVG exporter cannot serve as round 2's reference renderer.**
|
||||
`ShapedSegment` carries font-internal glyph ids and "an ordinary SVG
|
||||
`<text>` element cannot request one"
|
||||
(`ANALYSIS_TEXT_RUN_PRIMITIVES.md:496`). W3 says the real exporter will need
|
||||
explicit-glyph path output. Pin 10 supplies that for the spike rather than
|
||||
letting the check be unrunnable.
|
||||
* **The historical baseline is context, not a comparand.**
|
||||
`benches/editor_pipeline.rs` (`986c9cc`, `4a4988c`) measured today's
|
||||
SVG-string path at 123–165 µs scene-build + 1.36–2.86 ms paint per edit —
|
||||
but **dev profile**, offscreen `resvg`, medians across score states whose
|
||||
ink differs by depth, and the bench documents these limits itself
|
||||
(`editor_pipeline.rs:154`). Pin 6 re-measures it inside the spike instead.
|
||||
The demo pins `eframe = "0.29"` and rasterizes through `resvg` into an egui
|
||||
texture (`editor-gui/src/main.rs:361`).
|
||||
* **Two GPU adapters are present on the development machine** — a discrete
|
||||
Navi 31 and an integrated Granite Ridge — with a live display. Vulkan 1.4
|
||||
enumerated from an unsandboxed shell; **re-confirm adapter enumeration from
|
||||
the spike's own process before relying on it**, since a sandboxed run could
|
||||
not see it.
|
||||
* **MSRV is 1.85**, and the MSRV CI job excludes exactly one crate,
|
||||
`epiphany-editor-gui` (`ci.yml:99`). Pin 1 keeps that list unchanged.
|
||||
|
||||
---
|
||||
|
||||
## What the spike is, and what it is not
|
||||
|
||||
**It is** an elimination experiment that produces one document: a ruling
|
||||
naming the toolkit/tessellation stack for `epiphany-editor-app`, with the
|
||||
measurements and disqualifications that chose it.
|
||||
|
||||
**It is not the app.** No command registry, no editing intents, no document
|
||||
layer, no undo, no goldens, no persistence. Ruling D creates
|
||||
`epiphany-editor-app` as a fresh crate **after** this verdict, built around
|
||||
the §3.5 command registry from its first commit; spike code is not grown into
|
||||
it, for the same reason the demo is not.
|
||||
|
||||
**It does not reopen Ruling A.** The architecture — direct vector canvas over
|
||||
`ResolvedLayoutIR`, viewport-culled, per-system damage, SVG demoted to export
|
||||
— is granted. The spike chooses the stack. A finding that *no* candidate can
|
||||
implement it escalates to the user as a ruling-amendment request; it does not
|
||||
silently promote the SVG path back.
|
||||
|
||||
---
|
||||
|
||||
## Design pins
|
||||
|
||||
### Placement and isolation
|
||||
|
||||
1. **The spike lives outside the workspace**, at `spikes/editor-toolkit/`, as
|
||||
its own Cargo workspace with its own committed `Cargo.lock`, path-depending
|
||||
on the epiphany crates it reads. The root `Cargo.toml` gains one additive
|
||||
line — `exclude = ["spikes"]` — and nothing else. Rationale: candidates
|
||||
bring `wgpu`, `vello`, modern `egui`, `lyon`, `rustybuzz`; as workspace
|
||||
members they would enter the shared lockfile and the MSRV closure for code
|
||||
designed to be thrown away. (Cargo's `exclude` places the directory outside
|
||||
root `--workspace` and lets it keep its own lockfile.) **Consequence,
|
||||
stated because it is a real cost:** root `cargo test --workspace` does not
|
||||
build the spike, so the spike's own gate is run explicitly and reported.
|
||||
2. **No existing crate is modified.** The spike consumes public API only. If a
|
||||
candidate needs something `layout-ir`, `glyphs`, `engrave`, or
|
||||
`editor-core` does not expose, the spike **works around it locally and
|
||||
reports the gap** as a named finding for a later packet. An API that has to
|
||||
change to make a candidate work is itself evidence about that candidate.
|
||||
|
||||
### Measurement
|
||||
|
||||
3. **Builds are `--release --locked`.** Every timed figure, without exception.
|
||||
A dev-profile number decides nothing here.
|
||||
4. **One common deciding configuration, identical across candidates:**
|
||||
offscreen render target (not a surface), fixed target size **1920 × 1080**,
|
||||
identical MSAA sample count (4×, or the highest all survivors support,
|
||||
stated), identical sRGB target format, `wgpu` Vulkan backend on both
|
||||
adapters. Candidates are compared only within this configuration.
|
||||
**Surface-present timing is measured separately as a capability figure and
|
||||
decides nothing** — it mixes in the compositor.
|
||||
5. **Timed work must be completed GPU work, not submission.** Each timed
|
||||
iteration ends with an explicit device wait (`Device::poll` with
|
||||
`Maintain::Wait`, or the candidate stack's equivalent barrier) and, where
|
||||
the backend supports timestamp queries, a GPU-side timestamp span reported
|
||||
**alongside** the wall figure. A candidate that cannot be made to report
|
||||
completed work is reported as `NOT RUN` for that figure, never as fast.
|
||||
Protocol per figure: **≥50 warm-up iterations discarded, then ≥200 timed
|
||||
iterations**; report median, p95, min, max, and iteration count. **The
|
||||
deciding statistic is the median**, with a **predeclared practical
|
||||
equivalence band of 10%** — a band chosen in advance to stop small
|
||||
differences deciding architecture, *not* a claim of statistical
|
||||
indistinguishability, which this protocol does not test for. Vsync is off,
|
||||
or the figure is a full distribution — a 16.7 ms mean under vsync measures
|
||||
the display.
|
||||
6. **The SVG comparison is re-measured inside the spike, or it is not made.**
|
||||
Under pins 3–5, on the exact same fixture, target, size, statistic, and
|
||||
machine: render the score to an SVG string, parse with `usvg`, rasterize
|
||||
with `resvg` to the same offscreen dimensions, and compare
|
||||
**scene-build + paint totals**, because the premise under test concerns the
|
||||
whole renderer-owned path, not paint alone. The historical 123–165 µs /
|
||||
1.36–2.86 ms figures appear in the report **labeled as dev-profile
|
||||
historical context only** and are never compared against a candidate.
|
||||
**What falsification means, defined in advance:** the SVG path has no
|
||||
damage mechanism — it re-serializes and re-rasterizes everything per edit —
|
||||
so the honest comparison is *candidate damage repaint* against *SVG full
|
||||
repaint*, at the deciding rung. A candidate losing on **full** paint at the
|
||||
smallest rung does **not** falsify Ruling A's demotion, since damage and
|
||||
culling are exactly what the demotion buys; a candidate losing on the
|
||||
damage-vs-full comparison at the deciding rung does, and escalates to the
|
||||
user as a finding rather than resolving itself.
|
||||
7. **The fixture ladder is pinned now, before any candidate renders**, so the
|
||||
deciding workload cannot move. Rungs are **F1–F4** (named apart from the
|
||||
elimination rounds, which are 0–5): **F1** 1 staff × 10 measures (bench
|
||||
parity), **F2** 4 staves × 32 measures, **F3** 12 staves × 100 measures,
|
||||
**F4** 24 staves × 200 measures. Generated in the spike workspace (not
|
||||
testkit, whose generators the parallel track owns) and engraved up front
|
||||
with `Engraver::default()`.
|
||||
**Dimensions do not pin the workload, and the workload is what gets
|
||||
tessellated.** The generator's musical-content recipe — notes per measure,
|
||||
accidental cadence, slur and tie density, voice count, articulations, and
|
||||
the **fixed seed** — is committed to the spike repo and **user-reviewed
|
||||
before any candidate-specific implementation begins**, under the same rule
|
||||
as the oracles (pin 13). One recipe, scaled across rungs by dimension only.
|
||||
**Reporting:** per rung, actual page and system counts, **per-type
|
||||
primitive counts** (glyphs / strokes / curves) and **total path-command
|
||||
count** — not an aggregate primitive number. Path commands are what a
|
||||
tessellator actually consumes; two layouts with equal primitive counts can
|
||||
differ severalfold in curve work.
|
||||
**Rung validity and the deciding rung are decided independently of any
|
||||
candidate.** A rung is **dropped** only by the shared engraving preflight —
|
||||
engrave exceeding 10 minutes wall, or memory exhaustion — reported with its
|
||||
reason. **The deciding rung is the largest engraving-valid rung**, full
|
||||
stop. A candidate that cannot render there records its own `NOT RUN` and
|
||||
the eligibility consequence in pin 16; **one candidate's inability does not
|
||||
shrink the contest for the others**, which revision 2's "largest completed
|
||||
by every survivor" wording allowed.
|
||||
|
||||
### Text
|
||||
|
||||
8. **Text uses a local, explicitly non-canonical stand-in.**
|
||||
`SpikeResolvedText` in the spike workspace, mirroring W3 §3E **completely**:
|
||||
`provenance`; source string; shaping identity (pin 9);
|
||||
`Vec<ShapedSegment>` of positioned glyphs — each with face index, source
|
||||
range, direction, script, language, size, **and per-glyph transform**;
|
||||
cluster map; measured `bounds`; `reserved_box`; `origin`; `align`; `style`;
|
||||
`layer`. Mirroring a subset and calling it §3E would test a shape the
|
||||
amendment is not going to have. It is **not** the `.tex` amendment and does
|
||||
not pre-empt it — the
|
||||
reverse is the point. The spike is that shape's first consumer, so **every
|
||||
place it proves awkward to consume is a finding routed back to the
|
||||
amendment**, which is the cheapest available review of a design otherwise
|
||||
reviewed only on paper.
|
||||
9. **The stand-in identity records every field of W3's `TextShapingIdentity`**
|
||||
(`ANALYSIS_TEXT_RUN_PRIMITIVES.md:407`), not a file hash: the **ordered
|
||||
fallback chain**, and per face its **family** and **version** (W3 carries
|
||||
both as diagnostics even though the content hash is the identity that
|
||||
binds), its file content hash, face index, variations, and synthesis flags;
|
||||
**the shaper implementation id and
|
||||
version** ("it moves glyphs, so it is an input on exactly the footing of
|
||||
the font version"); **the OpenType feature set applied, in canonical
|
||||
order**; and the **Unicode version** governing both the bidi algorithm and
|
||||
grapheme segmentation. The exact fixture strings are committed verbatim in
|
||||
the spike repo. A partial identity would let two runs agree on pixels and
|
||||
disagree on clusters — the divergence W3 §5 check 4 exists to catch.
|
||||
**Shaping is fixture generation, not a recommendation:** `rustybuzz` shapes
|
||||
and `unicode-bidi` itemizes. **`unicode-bidi` does not do grapheme
|
||||
segmentation** — the segmentation implementation is separate
|
||||
(`unicode-segmentation` or equivalent) and **it, and its Unicode-data
|
||||
version, are named in the identity and the report**, because caret stops
|
||||
come from it and not from the shaper. Whether the *engraver* adopts any of
|
||||
these is the core/layout track's call; the spike reports on fitness only.
|
||||
**Faces** resolve **once at startup from an explicit path list, with their
|
||||
bytes hashed** — W3's narrowed rule exactly ("a host face may participate
|
||||
only once resolved to an exact content-hashed asset"), committing no font
|
||||
binary. A required face absent on the machine ⇒ `NOT RUN` (pin 14).
|
||||
10. **The spike builds its own SVG reference emitter.** Today's exporter
|
||||
cannot draw a `SpikeResolvedText` — `<text>` cannot request font-internal
|
||||
glyph ids (`ANALYSIS_TEXT_RUN_PRIMITIVES.md:496`) — so without this,
|
||||
round 2 check 1 would be `NOT RUN` for every candidate and the round would
|
||||
decide nothing. The spike emits **explicit glyph outlines as `<path>`**
|
||||
from the same hashed face and the same glyph ids (`ttf-parser`, already in
|
||||
`rustybuzz`'s tree), rasterized with `resvg` under pin 4's configuration.
|
||||
This is a prototype of the explicit-glyph output W3 says the real exporter
|
||||
needs, and its findings are reported as such. **Rejected alternative:**
|
||||
sequencing round 2 after the real exporter lands — that would block T4 on
|
||||
the core track's amendment, which is precisely what pin 8 exists to avoid.
|
||||
|
||||
### Damage
|
||||
|
||||
11. **Damage repaint is defined as a state transition, with an oracle.** For
|
||||
the fixture at each rung, build a deterministic **A/B pair**: score `S`
|
||||
and `S′`, where `S′` is `S` with **one** operation applied to a single
|
||||
note chosen to lie inside one system. Engrave both up front.
|
||||
**The damage set is computed from rendered content, never from raw index
|
||||
vectors.** `PrimitiveIndices` are positions in flat arrays
|
||||
(`resolved.rs:77`), so comparing the vectors is doubly wrong: a pitch
|
||||
change that preserves primitive count alters geometry while every index
|
||||
vector stays identical, and an inserted accidental renumbers global
|
||||
indices so that later systems' vectors differ although nothing they draw
|
||||
moved. Instead, each system is fingerprinted over its **dereferenced**
|
||||
primitives — glyph reference, quantized position, transform, bounding box,
|
||||
style, layer for glyphs, and the analogous fields for strokes and curves —
|
||||
together with the system's own geometry, under a fingerprint function
|
||||
committed in the spike repo. **Assert before timing** that exactly one
|
||||
system's fingerprint differs between IR_A and IR_B. **If the count is
|
||||
anything other than one — zero included** — advance to the next note in a
|
||||
**finite, committed candidate order** (notes in canonical layout order,
|
||||
first 20 tried) and record how many were tried. Zero matters as much as
|
||||
many: an operation whose layout effect the solver absorbs entirely would
|
||||
otherwise yield a "damage repaint" that repaints nothing and times as
|
||||
instant. **Exhausting the candidate order is a harness/preflight failure**
|
||||
— reported against the spike, not against any candidate, and not recorded
|
||||
as candidate evidence, since every candidate shares the same A/B pair. **`unowned` is fingerprinted the same way, not merely counted** —
|
||||
size alone misses changed geometry at unchanged indices. If its
|
||||
fingerprint differs, its primitives join the damage set and the report
|
||||
says so; its size is reported at every rung regardless, because a large
|
||||
unowned bucket caps what damage repaint can ever save, and hiding that
|
||||
would flatter every candidate equally but the architecture not at all.
|
||||
**Timed:** retessellation of the damaged system + cache replacement +
|
||||
completed paint (pin 5), starting from a warm scene built from IR_A.
|
||||
**Oracle:** the damage-updated target must be **byte-identical** to a
|
||||
from-scratch full paint of IR_B in the same process and configuration.
|
||||
(Byte equality is legitimate here — same renderer, same settings — unlike
|
||||
the cross-renderer comparison in pin 6, which is a bounded visual
|
||||
differential.)
|
||||
**Failing the oracle is a FAIL, not a `NOT RUN`.** Under pin 14's split:
|
||||
the damage **capability** cell is `FAIL` — the check ran and the output
|
||||
was wrong — while the damage **timing** is `NOT RUN`, because no valid
|
||||
timing exists for an incorrect result. Revision 2 recorded both as
|
||||
`NOT RUN`, which filed a broken implementation as an environmental
|
||||
absence. A damage-capability `FAIL` **escalates with a required root-cause
|
||||
attribution**: candidate limitation, or spike implementation defect. That
|
||||
distinction decides whether it eliminates — Ruling A's granted
|
||||
architecture *is* per-system damage, so a genuine candidate limitation is
|
||||
disqualifying, while a spike defect is a bug to fix and re-run — and it is
|
||||
not a judgement the report author makes silently.
|
||||
|
||||
### Inputs and outcomes
|
||||
|
||||
12. **The measured source tree is frozen and named.** All rounds run inside a
|
||||
dedicated `git worktree` checked out at a **pinned baseline commit**,
|
||||
recorded by SHA in the report, so the spike's path dependencies resolve to
|
||||
frozen sources rather than the working tree — which currently carries the
|
||||
parallel track's in-flight edits. The spike's own tree is placed into that
|
||||
worktree (its `../../crates/...` path deps then resolve inside it), so the
|
||||
only unfrozen inputs are the spike's own files. Builds are `--locked`. If the baseline
|
||||
moves, **the affected round is re-run in full**; partially re-run rounds
|
||||
are not reported. Blast radius's "only modification to pre-existing
|
||||
workspace configuration or production files" is scoped against that
|
||||
baseline, not against the globally dirty tree.
|
||||
13. **Every check has a precommitted oracle.** Oracles are committed to the
|
||||
spike repo, with their commit recorded, **before** any candidate renders
|
||||
against them. This is a hard sequencing rule, not a preference: "points
|
||||
that must be ink" chosen after seeing output is not a test.
|
||||
14. **The outcome model, in three parts.**
|
||||
**(a) Capability results are `PASS` / `FAIL` / `NOT RUN`.** `NOT RUN`
|
||||
means *could not execute* — missing font, missing backend, unavailable
|
||||
API, environment absent. **A check that ran and produced the wrong answer
|
||||
is `FAIL`**, never `NOT RUN`; conflating them lets a broken implementation
|
||||
buy an escalation instead of recording negative evidence.
|
||||
**(b) Timings are `measured` or `NOT RUN`, separately from (a).** A
|
||||
timing is `NOT RUN` whenever no valid figure exists — including when the
|
||||
corresponding capability `FAIL`ed, since timing a wrong result measures
|
||||
nothing.
|
||||
**(c) Eligibility is tracked as "disqualifying checks passed", separately
|
||||
from criterion cells.** The **disqualifying set** is fixed here: round 0's
|
||||
accessibility route; round 1's fill correctness; round 2 checks 2 and 5;
|
||||
round 3's semantics; and a damage-capability `FAIL` attributed to
|
||||
candidate limitation (pin 11). A criterion cell is the **worst** of its
|
||||
checks and is reported for the record — but **eligibility for the
|
||||
tie-break is the disqualifying set alone**, so a `FAIL` on a
|
||||
non-disqualifying check does not silently strand a candidate the user
|
||||
chose to keep, which revision 2's "passing every hard criterion" wording
|
||||
did. **Criterion 2 has no PASS/FAIL cell at all** — it is a ranking
|
||||
criterion, represented in the matrix by its measured table plus the
|
||||
damage-capability cell, and a fabricated pass for it would be a fake row.
|
||||
The spike is not complete while a disqualifying check is `NOT RUN` for a
|
||||
surviving candidate, and a `NOT RUN` that would decide the verdict
|
||||
escalates (pin 16) — **with one exemption, named here so this pin and
|
||||
pin 16 cannot drift apart: a `NOT RUN` timing at the deciding rung is the
|
||||
predeclared ranking loss of pin 16 step 1 and eliminates without pausing.**
|
||||
15. **Current releases, not Ruling A's snapshot.** Ruling A criterion 6
|
||||
records "egui 0.29 / 0.35" as a 2026-07-23 observation. The spike
|
||||
evaluates each candidate's actual release at spike time and records
|
||||
version + release date as a matrix row. **Criterion 6 is a question the
|
||||
ruling answers, not a test a candidate passes** — Ruling A defers it "to
|
||||
the spike's call under criteria 1–5" — so it gets no PASS/FAIL cell and
|
||||
is answered in prose, with the version row as its evidence.
|
||||
16. **The tie-break, fixed before measurement, with escalation as a real
|
||||
outcome.** Among candidates whose **disqualifying set** (pin 14c) is fully
|
||||
passed, in order:
|
||||
1. **Damage-repaint median at the deciding rung** (pin 7), integrated
|
||||
adapter, pin 4's configuration. **Anchored to the best median, not
|
||||
pairwise:** retain every candidate within the 10% band *of the best*,
|
||||
discard the rest. Pairwise comparison is non-transitive with three
|
||||
candidates and could order them inconsistently. One survivor ⇒ it wins.
|
||||
A candidate with `NOT RUN` at the deciding rung is **not** retained
|
||||
here and its inability is reported as such (pin 7). **This is a
|
||||
predeclared ranking loss and is explicitly exempt from the escalation
|
||||
rule below** — the deciding rung is the largest engraving-valid rung,
|
||||
so failing to render it is failing on the merits at the scale the
|
||||
product targets, decided by a rule fixed before any measurement.
|
||||
**If step 1 retains nobody** — every eligible candidate `NOT RUN` at
|
||||
the deciding rung, so there is no best median to anchor to — that is
|
||||
the **no-winner branch**, not an empty selection: the spike stops and
|
||||
the ruling records a ranking-loss wipeout with the recommended
|
||||
widening.
|
||||
2. **Foreclosure coverage** — count of round-5 probes passed (0–3), each
|
||||
binary. **Retain every candidate at the maximum count and discard the
|
||||
rest**; one survivor ⇒ it wins, multiple survivors ⇒ go to 3. (Stated
|
||||
because "equal counts ⇒ go to 3" left 3/3/2 undefined.)
|
||||
3. **Maintenance surface, by Pareto rule over three operationally defined
|
||||
axes.** **The measured upstreams are pinned here, before any figure is
|
||||
fetched**, because every candidate is a composite and "the primary
|
||||
crate" would let the favourable upstream be chosen after seeing the
|
||||
data: **C1 = `egui` (emilk/egui) + `lyon` (nical/lyon); C2 = `vello`
|
||||
(linebender/vello) + `winit` (rust-windowing/winit); C3 = `iced`
|
||||
(iced-rs/iced)**. Where a candidate names more than one upstream, **it
|
||||
takes the worse value on each axis** — conservative, and it removes the
|
||||
selection entirely. Each figure is recorded with its measurement date:
|
||||
- **Transitive crate count** — unique packages from `cargo tree
|
||||
--edges normal --target <host triple>` for the candidate's spike
|
||||
crate under exactly the features the spike enables, excluding the
|
||||
epiphany path crates. Lower is better.
|
||||
- **Release recency** — days since the most recent crates.io release
|
||||
that is neither **yanked** nor a **prerelease**. Lower is better.
|
||||
- **Issue responsiveness** — measured on the pinned repositories above,
|
||||
at a **recorded snapshot date**, from an **archived issue/comment
|
||||
snapshot committed to the spike repo** so the figure is recomputable
|
||||
after the tracker moves on. A *qualifying issue* is one opened in the
|
||||
180 days before the snapshot, in the issue tracker (**pull requests
|
||||
excluded**), **not authored by a maintainer or a bot**. *Maintainer*
|
||||
is **machine-observable, not inferred**: a comment whose recorded
|
||||
GitHub `author_association` is `OWNER`, `MEMBER`, or `COLLABORATOR`,
|
||||
or whose author is listed in a named owners file **at a pinned
|
||||
commit**. Triage/write permission is not reliably public and is not
|
||||
used. **Bot comments never count as a response.**
|
||||
Take the twenty most recent qualifying issues and the time from open
|
||||
to first maintainer response. **An issue with no maintainer response
|
||||
contributes `+∞`, not its current age.** Revision 4 used
|
||||
opening-to-snapshot age, which is only a *lower bound* on the
|
||||
response time and would have given twenty issues opened yesterday and
|
||||
ignored an excellent median. With `+∞`, the median stays computable
|
||||
while fewer than half are unanswered and becomes `+∞` — the worst
|
||||
possible value, which is the right answer — once half or more are.
|
||||
Lower median is better; fewer than five qualifying issues ⇒ this axis
|
||||
is `NOT RUN` and step 3 escalates.
|
||||
A candidate wins step 3 only if it **Pareto-dominates every other
|
||||
remaining candidate** — no worse on all three axes and better on at
|
||||
least one, against each of them individually. No aggregate score, no
|
||||
weighting, no author discretion.
|
||||
A decisive `NOT RUN`, a tie surviving all three steps, or any
|
||||
Pareto-incomparable pair **escalates rather than selects** — **except**
|
||||
the deciding-rung `NOT RUN` at step 1, which is the predeclared ranking
|
||||
loss above and eliminates without pausing.
|
||||
|
||||
---
|
||||
|
||||
## The candidate set
|
||||
|
||||
Three entrants — **all three named by Ruling A criterion 6**
|
||||
(`PLAN_EDITOR_APP.md:596`: "modern egui, iced, or a Vello surface"). The spike
|
||||
may add a fourth **with written justification in the report**; it may not drop
|
||||
one silently. Exact crate names, versions, features, and backends are recorded
|
||||
in the report and frozen by the committed `Cargo.lock`.
|
||||
|
||||
* **C1 — modern `egui` + `lyon`-tessellated meshes.** Closest to the demo, so
|
||||
the migration story is cheapest; the open questions are whether pushing
|
||||
tessellated meshes through `epaint` holds up at F3/F4, and whether egui's
|
||||
text stack can be bypassed cleanly for pin 8's pre-shaped runs.
|
||||
* **C2 — `vello` behind a `winit` shell.** GPU-compute path rendering, so
|
||||
criterion 1 is likely free; the open questions are windowing and UI chrome
|
||||
(there is no widget toolkit), accessibility wiring, and maturity.
|
||||
* **C3 — `iced`.** Retained-mode where C1 is immediate-mode, which bears
|
||||
directly on per-system damage, and it ships its own wgpu renderer.
|
||||
**Accessibility is its live risk, and round 0 must prove the route rather
|
||||
than assume it:** AccessKit lists egui among integrated projects and does
|
||||
not list iced, and iced's upstream accessibility issue (#552) remains open.
|
||||
A manual `accesskit_winit` route may exist; round 0 demonstrates it or C3
|
||||
fails there.
|
||||
|
||||
**Named exclusions, so their absence is a decision.** `skia-safe`: a C++ build
|
||||
chain against a project whose MSRV and reproducibility posture rests on a
|
||||
pure-Rust closure, plus a vendoring and licensing surface out of proportion to
|
||||
the gain. GTK/Qt drawing surfaces: excluded on **deployment, ABI stability,
|
||||
dependency weight, and integration cost** — *not* on any claim that they
|
||||
foreclose overlays, freehand input, or touch, which they do not. Either may be
|
||||
reconsidered if all three entrants fail.
|
||||
|
||||
---
|
||||
|
||||
## The elimination ladder
|
||||
|
||||
Cheapest hard disqualifier first, genuinely: every disqualifying check
|
||||
(pin 14c) is settled before the expensive ranking round runs. All candidates complete round *n*
|
||||
before any enters round *n+1*.
|
||||
|
||||
**Round 0 — accessibility route + desk survey (HARD).** For each candidate
|
||||
record: current release and date, transitive dependency count, MSRV, and
|
||||
whether the fill tessellator documents nonzero/even-odd support. Then the hard
|
||||
part: **demonstrate an accessibility route** — a minimal window exposing one
|
||||
accessible node whose role and name are **read back through the platform
|
||||
adapter**, for **every** candidate without exception. Naming an integration
|
||||
crate does **not** satisfy this round; it is at most a hint about how hard the
|
||||
demonstration will be, and revision 2's wording that let a crate name
|
||||
substitute for a readback was a false-pass path. A first-party integration and
|
||||
a manual `accesskit_winit` wiring are equally acceptable *routes*; only the
|
||||
readback is the evidence. **Timebox: two working days per candidate**, fixed
|
||||
here rather than left open — exceeding it is a `FAIL` with the attempt and its
|
||||
blocking point documented, not an indefinite extension. Rationale: criterion 4
|
||||
is a hard
|
||||
criterion, and discovering its absence in round 3 would waste three rounds —
|
||||
which is the whole claim of "cheapest disqualifier first".
|
||||
|
||||
**Round 1 — criterion 1, fill correctness (HARD).** Each candidate draws
|
||||
`gClef`, `fClef`, `timeSig8`, `accidentalFlat` from `BravuraGlyphCatalog`'s
|
||||
typed outlines under pin 4's configuration. **Precommitted oracle (pin 13):**
|
||||
for each glyph, ≥3 must-be-ink and ≥3 must-be-background sample points in
|
||||
staff-space coordinates, **derived programmatically** by an even-odd
|
||||
point-in-path test over the `PathCommand` outline — not chosen by eye — each
|
||||
required to lie **≥8 device pixels from any outline edge** at the pinned
|
||||
render transform, so antialiasing cannot explain any result.
|
||||
**Every background point must lie inside a bounded hole** — enclosed by the
|
||||
glyph's outer silhouette yet unfilled under even-odd — asserted by the
|
||||
derivation, not assumed. Background points merely outside the silhouette test
|
||||
nothing: a renderer that fills holes solid passes them trivially, which was
|
||||
revision 2's false-pass path. The oracle file, the render transform, and the
|
||||
expected class per point are committed before any candidate renders.
|
||||
*Fails hard:* holes that are not holes — the disqualification Ruling A
|
||||
anticipated.
|
||||
|
||||
**Round 2 — criterion 3, text (HARD).** W3 §5's five checks against pin 8's
|
||||
stand-in and pin 10's reference emitter: (1) **faithful consumption** — draws
|
||||
positioned segments without re-shaping, matching the reference emitter's
|
||||
rendering of the same run under Ruling A's bounded visual differential;
|
||||
(2) **fallback, forced** — resolves through the declared chain only, and
|
||||
reports rather than substitutes an uncovered codepoint; (3) **bidi** — a mixed
|
||||
Arabic/Latin run itemizes into multiple directional segments, each drawn in
|
||||
its resolved face at its resolved position; (4) **hit testing at character
|
||||
granularity** — UTF-8 byte offsets as base index, caret stops at
|
||||
grapheme-cluster boundaries with bidi affinity; (5) **accessibility** — the run
|
||||
appears in the tree as its **source string**, not a graphic.
|
||||
*Outcome rule (pin 14):* the criterion cell is the worst of the five.
|
||||
**Checks 2 and 5 are in the disqualifying set** — W3 names those two as
|
||||
disqualifying "regardless of tessellation throughput" — so failing either
|
||||
eliminates. Failing 1, 3, or 4 marks the cell `FAIL` and is **not**
|
||||
disqualifying by default, since W3 did not make them so and this contract does
|
||||
not silently promote them. **What that escalation is, concretely:** the spike
|
||||
*pauses* and the user issues a recorded waiver or ruling amendment that states
|
||||
explicitly whether the failed check joins the disqualifying set for this
|
||||
candidate. Eligibility then follows pin 14(c) from that record. Revision 2
|
||||
left the user a decision with no effect — the candidate stayed `FAIL` on the
|
||||
criterion and pin 16 admitted only candidates passing every hard criterion, so
|
||||
a "keep it" ruling could not reach the tie-break.
|
||||
|
||||
**Round 3 — criterion 4, accessibility semantics (HARD).** One score fragment
|
||||
exposed with **meaningful** semantics. **Precommitted oracle (pin 13):** a
|
||||
fixed numbered interaction script — key and pointer actions in order — and for
|
||||
each step the required evidence: accessibility-tree node **role, name, state,
|
||||
and supported actions**, plus **before/after focus and selection state**, plus
|
||||
the transcribed spoken output. Orca version, AT-SPI version, and desktop
|
||||
session are recorded. Transcription alone does not satisfy this round;
|
||||
navigation, focus movement, selection updates, and command activation must
|
||||
each be evidenced by tree state, because a stack can narrate while remaining
|
||||
unusable. *Fails hard:* a tree that exists but conveys nothing — Ruling A
|
||||
names that outcome as failure, so carrying AccessKit earns no credit.
|
||||
|
||||
**Round 3b — damage correctness (HARD, untimed).** Pin 14(c) makes a
|
||||
candidate-attributed damage failure disqualifying, so it must be settled
|
||||
*before* the ranking round, not discovered inside it — otherwise the ladder's
|
||||
"every disqualifier before round 4" claim is false. Each survivor runs pin
|
||||
11's A/B transition **at every engraving-valid rung** with the byte-identity
|
||||
oracle, and **nothing is timed**: this round asks only whether correct
|
||||
per-system damage is implementable in the stack. Every rung, not F1 alone —
|
||||
revision 4 checked F1 and left an F2–F4 oracle failure to surface *inside* the
|
||||
ranking round, which is exactly the late hard disqualification this round
|
||||
exists to prevent, and larger rungs are where multi-system and unowned
|
||||
handling actually breaks.
|
||||
**Byte equality is necessary but not sufficient**, so it is paired with a
|
||||
**reuse assertion**: the candidate's scene cache is instrumented to record
|
||||
which system representations were rebuilt during the transition, and the
|
||||
rebuilt set must equal the damage set. Without it a candidate that silently
|
||||
redraws everything produces a byte-identical target and passes as if it
|
||||
implemented damage — it would then be caught only by round 4's timings, i.e.
|
||||
by being slow, which is evidence about performance rather than about
|
||||
architecture.
|
||||
Outcome is the damage-**capability** cell (pin 14a), with root-cause
|
||||
attribution on failure. Still cheap: no measurement protocol applies, so each
|
||||
rung is one transition rather than 250 timed iterations. Round 4 then times
|
||||
damage for candidates that already proved it correct.
|
||||
|
||||
**Round 4 — criterion 2, staged latency (RANKING, no hard pass).** Survivors
|
||||
only. Per rung (pin 7), per adapter, under pins 3–5: (a) scene build — IR to
|
||||
the candidate's draw representation; (b) full paint; (c) **damage repaint**
|
||||
per pin 11, with its oracle; (d) viewport-culled paint at 1920 × 1080 on the
|
||||
deciding rung — **with the viewport transform and location committed in
|
||||
advance** alongside the oracles (pin 13) and the **visible primitive count
|
||||
reported**, since a size without a position leaves the culled workload
|
||||
unpinned. Plus the SVG re-measurement of pin 6. **The integrated adapter's
|
||||
figures decide; the discrete adapter's are reported as headroom** — a budget
|
||||
met on a Navi 31 says nothing about the machine most users have.
|
||||
**Every candidate's figures within a round come from the same spike commit**,
|
||||
recorded in the report, in addition to pin 12's frozen root baseline and the
|
||||
oracle commits — otherwise the harness itself is an uncontrolled variable
|
||||
between candidates.
|
||||
|
||||
**Round 5 — criterion 5, foreclosure probes (RANKING).** Prototyped, not
|
||||
assumed, each with a binary pass condition fixed here: **overlay** — a moving
|
||||
presence cursor drawn in a separate layer while the score's damage set stays
|
||||
empty (asserted, not observed); **freehand** — a live stroke rendered from a
|
||||
**committed synthetic pointer trace** (≥1,000 events, replayed at a stated
|
||||
fixed rate, committed with the oracles under pin 13), on the **integrated
|
||||
adapter at the deciding rung** under pin 4's configuration.
|
||||
**Latency is measured from the trace's scheduled injection timestamp to
|
||||
completion of the first frame containing that event — backlog included.**
|
||||
Starting the timer when the renderer picks the event up would let a candidate
|
||||
buffer the whole trace, render it late, drop nothing, and report fast update
|
||||
work while lagging visibly; that is the failure mode this probe exists to
|
||||
catch, so the timer origin is the schedule, not the pickup.
|
||||
**Coalescing is permitted and must be declared:** where a frame covers several
|
||||
injected events, each of those events is charged that frame's completion time.
|
||||
**Every sample in the trace must appear in the resulting stroke geometry** —
|
||||
asserted against the committed trace, not assumed.
|
||||
Passes if the **p95 of that latency is ≤ 16.7 ms** — the spec's own frame
|
||||
figure, not an invented one — **and no injected event is dropped or missing
|
||||
from the stroke**; a single one fails the probe. (Revision 3 pointed at "round
|
||||
4(c)'s budget", but 4(c) defines a measurement, not a threshold, and this
|
||||
probe feeds the deciding tie-break.) **touch** — multi-touch events received with
|
||||
distinguishable pointer ids, and a two-finger pan/zoom driven by them.
|
||||
Coverage is the count passed (0–3) and feeds tie-break step 2 only.
|
||||
|
||||
---
|
||||
|
||||
## Blast radius
|
||||
|
||||
New `spikes/editor-toolkit/**` (its own workspace, committed `Cargo.lock`,
|
||||
candidate sub-crates, fixture generator, SVG reference emitter, precommitted
|
||||
oracle files, and a `DECISIONS.md`); one additive `exclude = ["spikes"]` line
|
||||
in the root `Cargo.toml`; `spec/RULING_EDITOR_TOOLKIT.md` and this contract.
|
||||
Nothing else — no `epiphany-core`, no `epiphany-ops`, no `epiphany-bundle`, no
|
||||
`epiphany-layout-ir`, no `epiphany-glyphs`, no `epiphany-engrave`, no
|
||||
`epiphany-editor-core`, no `epiphany-editor-gui`, no testkit, no `.tex`, no CI
|
||||
change, no new workspace member, no golden touched or blessed. Scoped against
|
||||
pin 12's baseline commit.
|
||||
|
||||
## Gate (actual output)
|
||||
|
||||
The root workspace must be **unchanged in behavior**: `cargo fmt --check`,
|
||||
`cargo clippy --workspace --all-targets -- -D warnings`, `cargo test
|
||||
--workspace`, conformance suite and requirement counts **reported as observed**
|
||||
and unmoved, all GUI goldens byte-identical, layout canonical bytes
|
||||
byte-identical. The one-line `exclude` is the **only modification to
|
||||
pre-existing workspace configuration or production files** against the
|
||||
baseline — the contract, the ruling, and the spike tree are additions at the
|
||||
repository root too, so "the only root diff" would have been wrong — and the
|
||||
gate proves it changed nothing.
|
||||
|
||||
The spike workspace is gated **separately and explicitly**, since root
|
||||
`--workspace` does not reach it: `cargo fmt --check` and `cargo test --locked`
|
||||
inside `spikes/editor-toolkit/`, reported with its own output. Clippy is
|
||||
advisory there, not `-D warnings`: throwaway code held to the production bar
|
||||
wastes the reviewer's attention, and pin 1 guarantees none of it ships.
|
||||
|
||||
## Deliverable
|
||||
|
||||
`spec/RULING_EDITOR_TOOLKIT.md` — the verdict. A candidate × check matrix with
|
||||
every capability result carrying `PASS` / `FAIL` / `NOT RUN` and its evidence,
|
||||
its timing carrying `measured` / `NOT RUN` **separately** (pin 14a–b),
|
||||
criterion cells derived from it, an explicit **disqualifying-set column**
|
||||
recording eligibility (pin 14c), **no PASS/FAIL cell for criterion 2**, and a
|
||||
version row answering Ruling A criterion 6 in prose (pin 15); round 4's tables
|
||||
in full with adapter, configuration, rung, per-type primitive and path-command
|
||||
counts, statistic, and iteration counts on every figure; the pin-12 baseline
|
||||
SHA, the spike commit per round, and the oracle commits; the tie-break applied
|
||||
on the record per pin 16, including any waiver or amendment that changed
|
||||
eligibility; the named foreclosures the choice accepts; and round 2's findings
|
||||
routed back to the `ResolvedText` amendment. Plus the **no-winner branch** if
|
||||
it fires, in either of its two forms: **disqualification** — which
|
||||
disqualifying check killed which candidate — or **ranking-loss wipeout**,
|
||||
where no candidate rendered the deciding rung so step 1 retained nobody
|
||||
(pin 16).
|
||||
Both end in a recommended widening; the second is not a lesser outcome and is
|
||||
not reported as an inconclusive run.
|
||||
|
||||
## Report
|
||||
|
||||
Per round, as every tranche: files + summary, exact measured values with their
|
||||
full labels, every `NOT RUN` with the reason it could not run, gate output for
|
||||
both workspaces, deviations flagged explicitly. **The user reviews each
|
||||
precommitted oracle before candidates run against it, and each measured table
|
||||
before it enters the ruling.**
|
||||
Loading…
Reference in New Issue