epiphany/spikes/editor-toolkit/ROUND2_TEXT_RECIPE.md

737 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Round 2 text recipe — the precommitted stand-in, fixtures, and differential
Governed by `spec/CONTRACT_EDITOR_T4_SPIKE.md` pins 8, 9, 10, 13, 14 and
`spec/ANALYSIS_TEXT_RUN_PRIMITIVES.md` (W3) §3E and §5. This is **Packet 2A**:
everything candidate-neutral, committed and user-reviewed **before either
candidate consumes it**, under the same rule as the Round 1 oracle.
Nothing here is a recommendation to the core track. W3 §4 already took
disposition E; the spike is that shape's **first consumer**, and pin 8 makes
every place the shape proves awkward to consume a finding routed back to the
`.tex` amendment. Those findings are §12.
---
## 1. Why the faces are what they are
Pin 9: faces resolve **once at startup from an explicit path list, with their
bytes hashed**, committing no font binary. Absent face ⇒ `NOT RUN` (pin 14).
The declared chain, in order:
| # | Path | family (name id 1) | version (name id 5) | upem | sha256 | bytes |
|---|---|---|---|---|---|---|
| 0 | `/usr/share/fonts/tex-gyre/texgyrepagella-regular.otf` | TeX Gyre Pagella | `Version 2.501;PS 2.501;ffdkm 0.1` | 1000 | `44e64260716d8f2bbe412baa1ee99b7c995190ac4573177c24def0b9200438c7` | 218100 |
| 1 | `/usr/share/fonts/liberation-fonts/LiberationSerif-Regular.ttf` | Liberation Serif | `Version 2.1.5` | 2048 | `058ea80864aef09a23f45cbec2bb5400bc3dfbdea01c3f10538a21fcb497fb74` | 393576 |
The hashes above are recorded **as observed on this machine on 2026-07-29**.
The generator recomputes them and **fails loudly** on any mismatch rather than
regenerating: under pin 9 the content hash *is* the identity, so a changed file
is a changed fixture set, not a detail to absorb.
**This pair was chosen to make check 2 real rather than nominal.** Face 0
covers Latin and the combining acute but **not** Hebrew (measured, §4); face 1
covers both. So:
* a Latin-only run resolves entirely within face 0 — no fallback exercised;
* a Hebrew run **must** traverse to face 1, and doing so is observable in
`ShapedSegment::face`;
* U+0627 ARABIC LETTER ALEF is covered by **neither** — verified `None` in both
faces — so it is the uncovered codepoint check 2 requires, and it is
uncovered *by fact*, not by a chain artificially truncated to manufacture the
test.
**The two faces disagree on units-per-em (1000 vs 2048).** This is deliberate,
not incidental: a fallback chain whose faces share a upem would let a
consumer that forgets to normalize per face pass anyway. Every position this
recipe records is in staff-space, already divided by the *originating face's*
upem, so a consumer that reads `size` and multiplies without consulting the
face will land visibly wrong on the Hebrew segment and nowhere else.
### 1.1 Deviation, named: there is no Arabic-capable face on this machine
W3 §5 check 3 says "a mixed **Arabic**/Latin run". `fc-list :charset=0627`
returns nothing, and the 168 installed faces contain no Arabic coverage at all
(nor CJK). Under pin 9 that is an environment absence ⇒ `NOT RUN`.
Recording check 3 as `NOT RUN` would lose the only bidi evidence the round can
produce, so this recipe supplies **F-D, a Hebrew/Latin bidi fixture**, as an
explicitly named substitution with its coverage gap stated:
* **What it still tests:** the property check 3 actually names — that a mixed
run *itemizes into multiple directional segments, each drawn in its resolved
face at its resolved position*. Hebrew is strong RTL; the run itemizes into
three visual runs at levels 0/1/0 (measured, §4), the RTL segment resolves to
a different face than the LTR segments, and its glyph clusters run in
descending source order.
* **What it does not test:** Arabic is *cursive-joining*. Its positional forms
(initial/medial/final/isolated) come from contextual GSUB, so an Arabic
fixture would additionally prove that a consumer draws the **resolved** glyph
ids rather than re-deriving them. Hebrew has no joining behaviour, so F-D
cannot catch a consumer that re-shapes and happens to agree.
**That gap is covered elsewhere, but only partly:** F-A carries two real
ligature clusters (`ff` and `fi` → one glyph each, measured), which a
re-shaping consumer with different feature settings would get wrong. It is
weaker than positional forms — a re-shaper using the same font and default
features reproduces `liga` — but it is not nothing.
### 1.2 RULED (2026-07-29): check 3 is `NOT RUN`
The user's ruling, recorded verbatim in substance:
> Check 3 is **NOT RUN**: the contract explicitly requires Arabic/Latin, and
> pin 9 defines an absent required face as environmental `NOT RUN`. Record F-D
> separately as:
>
> > **Supplementary PASS** — Hebrew/Latin bidi itemization, fallback-face
> > selection, visual ordering, and resolved positioning.
>
> It must not upgrade check 3 to PASS; it cannot exercise contextual Arabic
> joining. If checks 1, 2, 4 and 5 pass, the Round 2 criterion cell is
> therefore `NOT RUN`, but **eligibility is unaffected** because check 3 is not
> disqualifying.
Consequences, so no later packet has to re-derive them:
* The Round 2 criterion cell for check 3 reads `NOT RUN` for **every**
candidate, on both adapters. It is an environment fact, not a candidate
outcome, and it is identical for C1 and C2 — so it separates nothing and
decides nothing.
* F-D is still built, still rendered, still diffed, and still reported — as
**Supplementary PASS/FAIL**, on its own row, never merged into check 3's.
* A candidate that fails F-D has failed the supplementary row. That is
reportable evidence for the ruling, and it is not a check-3 FAIL.
* Check 3 is **not** in the disqualifying set (checks 2 and 5 are), so a
`NOT RUN` cell cannot end a candidacy.
The pre-ruling text above is kept as written because it is the reasoning the
ruling answered, not because it is still open.
---
## 2. The fixture set
Committed **verbatim**, as Rust string literals with every non-ASCII codepoint
escaped, so the file is unambiguous under any editor or normalization:
| id | scored as | literal |
|---|---|---|
| **F-A** | check 1 faithful consumption, check 5 accessibility | `"Allegro affettuoso \u{2014} al fine"` |
| **F-B** | check 2 fallback, forced | `"Coro \u{05D0}\u{05D1}\u{05D2}"` |
| **F-C** | check 2 uncovered codepoint | `"Coro \u{0627}"` |
| **F-D** | **Supplementary** bidi evidence (Hebrew/Latin) — **check 3 remains `NOT RUN`** (§1.2) | `"Allegro \u{05D0}\u{05D1}\u{05D2} con brio"` |
| **F-E** | check 4 hit testing / caret | `"Cafe\u{301} \u{2014} resume\u{301}"` |
Plain-text rendering, for reading: `Allegro affettuoso — al fine` ·
`Coro אבג` · `Coro ا` · `Allegro אבג con brio` · `Café — resumé`.
**The F-D row is a scoring classification, not a description, and it is
enforced.** The column header used to read "purpose (W3 §5 check)" and F-D's
cell "3 bidi", which contradicts the 2026-07-29 ruling wherever that label
is printed. The label lives in exactly one place in code
(`fixtures::FIXTURES[3].purpose`), flows from there into `fixtures.json`,
`FIXTURES_SUMMARY.md` and every generator's console output, and is restated as
a literal in `FixtureFile::validate` (`EXPECTED_PURPOSES`) so that restoring
`"check 3 (bidi)"` is a named validation failure rather than a stale string
someone reads in good faith. A ruling recorded only in prose, while the
machine-readable artifact still says "check 3", is a ruling that loses to
whichever record the reader happens to open.
**F-E is deliberately NFD, not NFC.** W6 withdrew the claim that score text is
NFC (`ANALYSIS_TEXT_RUN_PRIMITIVES.md` F6), so a fixture that quietly assumed
composed input would test a guarantee the model does not make. `e` + U+0301
also produces the case that matters: the shaper *composes* it to one glyph
(measured: gid 198), while segmentation reports **one grapheme spanning three
UTF-8 bytes**. One caret stop, two codepoints, one glyph — the exact place a
codepoint-indexed caret and a grapheme-indexed caret diverge.
**F-A carries two ligatures**, which is the same divergence from the other
direction: `ff` and `fi` each shape to a single glyph covering two codepoints,
so a caret between `f` and `f` has a grapheme boundary but **no glyph
boundary**, and its position must be interpolated within the ligature rather
than read off a glyph origin.
---
## 3. Render geometry, fixed for every fixture
Identical to Round 1's rule so the two rounds share one convention:
```
device = (staff.x * scale + tx, ty - staff.y * scale)
scale = 100 device px per staff space
target = 1920 x 1080 (pin 4)
```
* Em size **1.28 staff spaces = 128 device px**, and the size is **derived
from the differential's blind spot, not chosen for legibility** — see §10's
D1 blind-spot rule. Measured on TeX Gyre Pagella (mid-height scanline,
upem 1000): lowercase verticals `l`/`i`/`n` are 84 font units, round strokes
`o`/`e` are 9394. That is **10.8 and 12.0 device px** at this em size,
comfortably above D1's 5 px floor.
**Revision 1 pinned 64 px em, which was wrong**, and wrong in a way that
would have quietly voided the round's strongest rule: at 64 px the same
stems measure **5.4 device px**, and D1's 2 px band swallows any stroke
narrower than 5 px whole. D1 would have been blind to every stem in the
fixture set while still reporting `pass`. The em size and the band radius
had been chosen independently, and were incompatible.
All five fixtures fit the 1920 px target at this size. Measured from the
generated `fixtures.json` (`bounds` is absolute staff space, so the device
right edge is `bounds.right * 100`), longest first:
| fixture | ink width (device px) | right edge (device px) |
|---|---|---|
| F-A | 1553.2 | 1715.1 |
| F-D | 1128.8 | 1290.7 |
| F-E | 860.3 | 1023.0 |
| F-B | 486.3 | 809.1 |
| F-C | 274.2 | 597.0 |
F-A is the longest and clears the frame by ~205 px.
* Baseline origin at staff-space **`1638/1024 = 1.599609375`**, `y = 0.0`
i.e. device **`(159.9609375, 540.0)`** — left-aligned, vertically centred,
with room for the longest fixture.
**This is the normative value, and it is not `1.6`.** Invariant 5 puts every
position on the `1/1024` staff-space grid, and `1.6 × 1024 = 1638.4` is not
an integer, so `1.6` is not a representable origin. Revision 1 stated `1.6` /
device `(160, 540)` here and the code quantized it silently on the way past;
§12 recorded the discrepancy as a note, which does not repair a normative
section that still states an invalid constant. `RUN_ORIGIN_STAFF` is now
`1638.0/1024.0` exactly, so the constant and the grid agree at the source
rather than at the first rounding.
* Ink is opaque black on opaque white, as in Round 1, so the same luminance
classification applies.
* `align` is `Start`; per W3, `PositionedGlyph::offset` has alignment **already
applied**, so a consumer places the run by `origin` alone. The field is
retained as a record of the decision, and the fixtures assert that
re-deriving from `align` is never necessary.
---
## 4. Measured shaping facts, recorded before anything consumes them
Produced by `rustybuzz` 0.20.1 / `unicode-bidi` 0.3.18 /
`unicode-segmentation` 1.13.3 / `ttf-parser` 0.25.1 on the faces in §1. These
are **precommitted expectations**: the generator asserts them, and a mismatch
is a reported failure, never a silent re-record.
Total glyph counts are stated for **every** fixture below. Revision 1 stated
them only for F-A, F-B and F-E, which left F-C and F-D uncovered by the one
validator check that catches shaped-output drift; the fix is to state the
measured numbers, not to let a validator invent them.
**F-A** — 28 codepoints, 30 bytes, **26 glyphs**, all face 0. Two ligature
clusters: cluster at byte 9 spans `ff` → gid 234; cluster at byte 26 spans
`fi` → gid 97. Em dash → gid 119.
**F-B** — Latin head `"Coro "` (5 bytes) → 5 glyphs on **face 0**; Hebrew tail
(6 bytes) → 3 glyphs on **face 1**, RTL, clusters descending 4/2/0. Two
segments, two faces, one string.
**F-C** — 6 codepoints, 7 bytes, **5 glyphs** in 2 segments. U+0627 resolves
in **neither** face (`glyph_index` → `None` in both).
Recorded as an **explicit unresolved cluster**, never dropped and never
substituted from the host: W3's invariant is that a cluster shaping could not
resolve is represented diagnostically, because a dropped cluster is a silent
divergence between the string and the ink.
**F-D** — 20 codepoints, 23 bytes, **20 glyphs** in 3 segments. Base level 0;
visual runs `0..8` level 0 (`"Allegro "`), `8..14` level 1 (Hebrew), `14..23`
level 0 (`" con brio"`). The middle segment is on face 1, the outer two on
face 0.
**F-E** — 15 codepoints, 19 bytes, **13 glyphs**. `e`+U+0301 composes to gid
198 at byte 3 and again at byte 16. Graphemes: `["C","a","f","e\u{301}"," ",
"—"," ","r","e","s","u","m","e\u{301}"]` — 13 graphemes, 15 codepoints.
---
## 5. `SpikeResolvedText` — the complete §3E mirror
Pin 8: mirroring a subset and calling it §3E would test a shape the amendment
is not going to have. Every field of W3 §3E is present, with W3's own names:
`provenance` · `text` · `shaping: SpikeTextShapingIdentity` ·
`segments: Vec<ShapedSegment>` · `clusters: ClusterMap` · `bounds` ·
`reserved_box` · `origin` · `align` · `style` · `layer`.
`ShapedSegment`: `face` (index into the chain) · `glyphs: Vec<PositionedGlyph>`
· `source: Range<u32>` (UTF-8 byte offsets) · `direction` · `script` ·
`language` · `size: StaffSpace`.
`PositionedGlyph`: `glyph_id` · `offset: Point` · `transform: Option<Transform2D>`.
**Every W3 invariant is asserted by the generator, not merely honoured:**
1. every `ClusterMap` offset and every `source` bound is a **valid UTF-8
boundary** in `text`;
2. segment source ranges **partition the whole string** — total, non-
overlapping in logical order, whatever the visual order;
3. every cluster carries its source range, its glyph indices, and its caret
stops, each stop with a **geometric position and a bidi affinity**;
4. an unresolved cluster is present with an explicit marker (F-C), never
dropped;
5. positions are staff-space, y-up, quantized on the **1/1024 grid**, the same
convention as glyph positions — text quantization is not a second
convention.
The type is `SpikeResolvedText`, in the spike workspace, marked
non-canonical in its own doc comment. It is **not** the `.tex` amendment and
does not pre-empt it.
---
## 6. `SpikeTextShapingIdentity` — every pin-9 field, and where its value comes from
| field | value on these fixtures | source |
|---|---|---|
| `faces[i].family` | `TeX Gyre Pagella` / `Liberation Serif` | name id 1, **diagnostic only** |
| `faces[i].version` | `Version 2.501;PS 2.501;ffdkm 0.1` / `Version 2.1.5` | name id 5, **diagnostic only** |
| `faces[i].file_hash` | §1 table | SHA-256 over the exact file bytes — **the identity** |
| `faces[i].face_index` | 0 / 0 | neither file is a collection |
| `faces[i].variations` | empty | both faces measured non-variable |
| `faces[i].synthesis` | none | no synthetic weight or slant is applied |
| `shaper` / `shaper_version` | `rustybuzz` / `0.20.1` | it moves glyphs, so it is an input on the footing of the font version |
| `features` | the empty set, canonically ordered | see below |
| `unicode_version` | the version backing `unicode-bidi` **and** `unicode-segmentation`, both recorded | see below |
**Features.** The fixtures apply **no explicit feature settings**; rustybuzz's
default horizontal feature set governs, which is what produced the measured
ligatures in §4. The identity records the empty explicit set **plus the shaper
id and version that define the defaults** — an identity recording "empty" with
no shaper version would be exactly the partial identity pin 9 forbids, since
the defaults are the shaper's, not the document's.
**Unicode version, recorded twice on purpose.** Pin 9 is explicit that
`unicode-bidi` does **not** do grapheme segmentation, that the segmentation
implementation is separate, and that 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. The identity therefore carries **both** `(bidi_impl, bidi_version,
bidi_unicode_version)` and `(segmentation_impl, segmentation_version,
segmentation_unicode_version)`, each read from the crate rather than asserted
here, and the report prints both. If the two disagree on Unicode version, that
disagreement is **reported as a finding**, not reconciled: two components
defining one `unicode_version` field is a shape problem, and it belongs in §12.
---
## 7. Cluster map, caret stops, and the hit-test contract
Pinned by W3 §5 check 4 and restated here as the thing the generator builds:
* **Base index is UTF-8 byte offsets** into `text`, addressing the stored
`String` directly.
* **Caret stops are grapheme-cluster boundaries**, from
`unicode-segmentation`, **not** codepoint boundaries and **not** glyph
boundaries. F-E has 13 stops for 15 codepoints; F-A has a stop between the
two `f`s of `affettuoso` although the ligature is one glyph.
* **Each stop carries a bidi affinity**, so a caret at a direction boundary is
unambiguous. F-D's byte 8 and byte 14 each carry two stops — one per
affinity — at **different geometric positions**, which is the whole reason
affinity exists.
* **A stop inside a ligature is interpolated across the ligature's advance**
in proportion to the cluster's grapheme count, and the recipe records that
rule explicitly so a candidate cannot pass by rounding to the glyph origin.
The expected hit-test answers are committed per fixture as a table of
`(device point) -> (byte offset, affinity)` probes: for every caret stop, one
probe at the midpoint of each adjacent grapheme, plus probes before the first
and after the last stop. Points are placed at least **4 device px** from any
stop position, so a correct implementation cannot fail on a rounding tie and an
incorrect one cannot pass on one. A probe that cannot meet the 4 px separation
is **dropped and recorded as dropped** — never placed closer. Measured: 80
probes across the five fixtures (F-A 29, F-B 9, F-C 7, F-D 21, F-E 14), **none
dropped**, smallest interior gap 31.9 device px.
**What this table cannot test, stated rather than left implicit.** The probes
carry an affinity, but they do not *test* affinity, and no point-based table
could: a device point selects one answer, while affinity is precisely the
distinction between two answers **at the same point**. F-D's two `Upstream`
stops both sit at staff-space x = 4.609375 — the same position as a
`Downstream` stop belonging to another grapheme — so a probe placed there would
be ambiguous by construction, which is what the 4 px rule exists to forbid.
Affinity is therefore validated structurally, by the direction-boundary
distinctness check (§5 invariant 3 and its F-D specialization), and hit testing
is validated by point → byte offset. Both halves of check 4 are covered; they
are covered by different instruments, and the reason is geometric, not a
convenience.
---
## 8. Accessibility oracle (check 5) — pinned, not described
Check 5 is **disqualifying**. Revision 2 of this section said "committed per
fixture: the expected node role, the expected name as exact bytes…" and then
named no role and encoded nothing. That is not an oracle; it is a place where a
judgement would have been made *after* seeing a candidate's tree, which is the
one thing pin 13 exists to prevent. Revision 3 pins it, in this section and in
`fixtures.json`'s own `accessibility` record per fixture (`round2-textkit`
`src/a11y.rs`), validated against literals by `FixtureFile::validate`.
### 8.1 The name
The run reaches assistive technology **as its source string** — the exact bytes
of `text`, not the shaped glyphs, not a graphic, and not a normalization of it.
Each fixture's record carries the name three ways — the string, its **lowercase
hex**, and its byte length — because a normalization can look identical in a
string field and never does in hex.
**Composition.** F-B and F-D are multi-segment runs, and a tree that exposes one
text node per direction run is not wrong; requiring exactly one node would
manufacture a failure for a legitimate implementation. So the requirement is on
the concatenation:
> the run's own accessible name, **or** the names of its text descendants
> concatenated in **logical** (not visual) order, must equal the source string
> byte for byte.
F-D is the fixture that makes that distinction bite: a tree assembled by walking
the visual runs left to right produces a different string, and only there.
### 8.2 The role
Stated **per platform**, not in one toolkit's vocabulary. Naming only
AccessKit's `Role` enum would have quietly favoured C1 (egui ships AccessKit)
over C2 (vello is a rendering crate with no accessibility layer at all), and a
criterion that encodes one candidate's stack is not a criterion. A candidate
satisfies this half by matching **one row** — the platform it actually exposes a
tree on — and it is not required to expose trees on platforms it does not
target.
| platform | accepted | prohibited |
|---|---|---|
| accesskit-0.24 | `Label`, `TextRun`, `Paragraph` | `Image`, `GraphicsObject`, `GraphicsSymbol`, `GenericContainer`, `Unknown`, `Pane` |
| at-spi2 | `label`, `static`, `text`, `paragraph` | `image`, `canvas`, `filler`, `panel`, `unknown` |
| aria | *(no role)*, `text`, `paragraph` | `img`, `presentation`, `none`, `graphics-object`, `graphics-symbol` |
| macos-nsaccessibility | `AXStaticText` | `AXImage`, `AXUnknown`, `AXGroup` |
| windows-uia | `Text` | `Image`, `Pane`, `Custom` |
The accesskit row was read from the `accesskit` 0.24.1 `Role` enum in this
workspace's own lockfile, not from memory; every name in that row exists there.
The prohibited column is named rather than left as "anything not accepted", so a
candidate's result reads as *this specific* divergence.
### 8.3 Outcomes that fail whatever the role says
* `absent-from-tree`**the one this check will most likely actually catch.**
It is the default outcome for a toolkit that draws to a canvas and stops.
* `name-empty` — absence wearing a role.
* `name-normalized` — F-E's case. A tree exposing `Café` (NFC) for a fixture
whose `text` is `Cafe\u{301}` has silently normalized, a divergence between
the string and the record exactly as damaging as a dropped cluster.
* `name-is-shaped-glyphs` — the tree exposes what was drawn rather than what was
said: glyph names, glyph ids, or the ligated text. F-A is the fixture.
* `name-drops-unresolved-codepoints` — F-C's case. Its U+0627 is covered by
**neither** declared face and draws no ink at all, and it must appear in the
name regardless: the accessibility tree carries the text, not the ink.
### 8.4 What is deliberately not pinned
Nothing here says *how* a candidate builds the tree, on which thread, or through
which crate. A candidate that has to write its own accessibility layer to pass
is free to; what it may not do is expose the run as a picture, or not expose it
at all.
---
## 9. The SVG reference emitter (pin 10)
Today's exporter cannot draw a `SpikeResolvedText``<text>` carries
characters, and the viewer's shaper picks the glyphs, so anything contextual
(the measured `ff`/`fi` ligatures, the composed `é`) would silently draw
different glyphs than the layout resolved. Without this emitter, check 1 is
`NOT RUN` for every candidate and the round decides nothing.
The spike emits **explicit glyph outlines as `<path>`**, from the same hashed
face and the same glyph ids, via `ttf-parser` (already in `rustybuzz`'s tree),
then rasterizes with `resvg` 0.45 under pin 4's configuration — 1920×1080,
opaque white ground, opaque black ink.
**It never emits `<text>`, and the generator asserts that** — a `<text>`
element anywhere in the output is a hard failure of the emitter, because it
would reintroduce exactly the re-shaping this round exists to forbid.
This is a prototype of the explicit-glyph output W3 says the real exporter
needs, and its findings are reported as such.
---
## 10. The bounded visual differential — defined before anything is compared
Ruling A demoted SVG to export and permitted "geometry/scene equivalence plus
a **bounded visual differential** under a controlled backend, NOT pixel
equality", because a GPU tessellator legitimately differs from `resvg` in
antialiasing and curve flattening while being geometrically correct. That
phrase has never been given a number. It is given one here, in advance,
because a tolerance chosen after seeing a candidate's output is not a
tolerance.
Both rasters are 1920×1080, opaque, black-on-white. Both are reduced to 8-bit
luminance with the same Rec. 601 weights Round 1 used.
**Edge band.** A reference pixel is an *edge pixel* if its 3×3 neighbourhood
contains both a pixel with luma < 128 and one with luma 128. The **band** is
every pixel within Chebyshev distance `EDGE_BAND_PX = 2` of an edge pixel.
This is the same device Round 1 used for its 8 px clearance floor: confine the
comparison to where the answer is geometric and not a coin flip about
antialiasing.
**Four rules decide. All four are hard.**
| | rule | rationale |
|---|---|---|
| **D1** | **Outside the band, zero pixels may differ in class** (ink = luma < 128). Not "few". Zero. | Away from an edge, both renderers are painting solid ink or solid ground. Any disagreement there is geometry, not antialiasing. |
| **D2** | **Whole-image ink mass** Σ(255 luma)/255 agrees within **2%** relative. | Catches "drew nothing" and "drew everything" outright. |
| **D3** | **Whole-image ink centroid** agrees within **0.5 device px** per axis. | Catches gross misplacement of the run as a whole. Its floor is declared below; it is not a sub-pixel registration test. |
| **D4** | **Per-glyph ink mass**, over each shaped glyph's device bounding box dilated by 3 px, agrees within **2%** relative, **for every glyph**. | This is the rule that actually catches a wrong, dropped, or re-shaped glyph. D1 cannot (blind spot below) and whole-image D2 cannot (one glyph is a small fraction of the total). |
**D1's blind spot, measured and declared.** D1 can only see an error that
reaches a pixel outside the band, so it is **structurally blind to any error
confined to a stroke narrower than `2 * EDGE_BAND_PX + 1 = 5 device px`**
such a stroke is entirely within 2 px of its own edges, so deleting it outright
changes no unbanded pixel. This is not a defect to be fixed by tuning; it is
what confining the comparison to non-edge pixels *means*. It is handled by
choosing the em size so the fixtures have no stroke that thin 3: thinnest
measured stroke 10.8 px) **and** by D4, which does not depend on band geometry
at all. Verified empirically: deleting a 4 px stem from a synthetic reference
produced `d1 = 0` differing pixels.
**D3's detection floor, declared rather than discovered.** A whole-image
centroid is one number over two million pixels. Measured on the synthetic
reference: a legitimate antialiasing-only variant moved it **0.346 px**, while
a true 0.5 px translation moved it **0.486 px**. Those are not separable, so
**D3 does not detect uniform drift below roughly 1 device px, and this recipe
does not claim it does.** D3 is retained for gross misplacement, where it is
decisive (deleting one stem moved it 40.7 px; a 1% scale moved it 2.7 px).
Sub-pixel registration is **out of scope for this round**, stated here in
advance rather than inferred later from a candidate's numbers.
**Reported, never deciding:** inside the band, the max |Δluma| and the count of
pixels differing by more than 16. Those numbers are antialiasing, which is
precisely what the differential is bounded *against* measuring.
**These thresholds are claims, and §11 is how they are tested.** If a mutation
in §11 fails to kill, the threshold is wrong and is reported as wrong it is
never loosened to make the comparison pass, under the same rule as goldens.
Revision 1's rules D1D3 were tested exactly that way and **two of its
mutations did not kill**; the finding produced D4, the declared D1 blind spot,
and the declared D3 floor above, rather than a relaxed threshold.
---
## 11. The mutation set the differential must kill
**Every row below is executed, and the executable that runs it exits non-zero
when a required kill does not happen.** Two harnesses, split by what the
mutation needs:
* **M1, M2, M3, M3B, M7, M8, M10** geometric, no fonts required:
`cargo run --release -p round2-diff --bin selftest`, against synthetic
geometry 10).
* **M4, M5, M6** *text* mutations, meaningless without shaped glyphs from the
declared faces: `cargo run --release -p round2-reference --bin
text_mutations`, against the real frozen fixtures. Every substituted glyph id
and advance is measured from the faces through `round2_textkit::shape` and
anchor-asserted before use, so a mutation that silently became a no-op fails
loudly rather than passing as "did not kill".
* **M9** is structural 9's `<text>` assertion) and fires before any raster.
Recipe revision 2 stated M4/M5/M6 and executed none of them. **Executing them
corrected the recipe on the first run** see M4.
| # | mutation | must fail | measured |
|---|---|---|---|
| M1 | translate the whole run by **1 device px** in x | D3 (and D1 where strokes exceed the band) | kills |
| M2 | translate by **0.5 device px** | **nothing required — boundary probe.** §10 declares D3's floor at ~1 px; a mutation set at exactly the tolerance tests arithmetic, not the rule. | recorded |
| M3 | drop a stem **below** D1's 5 px floor | **D4** and D2; D1 expected silent | kills D4; D1 silent, as declared |
| M3B | drop a stem **above** D1's 5 px floor | D1 **and** D4 | kills both |
| M4 | replace the `ff` ligature with the two unligated glyphs (a re-shaping consumer's output) | **D1** *not* D4; see below | D1 = 221 px outside band; D4 worst region 1.40%, ligature's own region 1.20% (tolerance 2%) |
| M5 | draw the composed `é` as `e` with the acute **omitted** | **D4** | D4 13.28% and 13.19% on the two `é` regions; D1, D2 (2.55%) and D3 (2.41 px) also fire |
| M6 | render the Hebrew segment with face **0** substituted (host substitution, the thing check 2 forbids) | emitter refuses; if forced, D4 | refusal fires on U+05D0 before any raster; forced, D4 = 95.37% / 71.26% / 34.69% on the three Hebrew regions |
| M7 | scale the run by **1%** about its origin | D1, D3, D4 | kills |
| M8 | blank the target entirely | D2 | kills |
| M9 | render the source string as `<text>` instead of explicit glyphs | emitter assertion 9), before any raster | kills |
### M4 is assigned to D1, and the measurement is why
Revision 2 assigned M4 to D4 by analogy with M3, a dropped glyph. Executing it
showed the analogy is false. An `ff` ligature and two `f` glyphs carry very
nearly the **same ink**: 0.07% of whole-image mass, and 1.20% inside the
ligature's own region. D4 is a *mass* rule, and this is a *shape* substitution
the wrong instrument. D1, which asks where the ink is rather than how much,
sees it immediately at 221 differing pixels outside the edge band.
D4 is not *structurally* blind here the way D1 is blind below 5 px it came
within 1.7× of firing. But tightening D4 to catch 1.20% would leave barely
1.3× of margin over M10's measured 0.785%, the legitimate antialiasing-only
variant that **must** pass. A threshold that close to a known-good variant is
not a tolerance. So D4 keeps its 2%, D1 owns M4, and the margin is written down
here instead of being discovered by whoever tightens it later.
The general shape, worth carrying into the ruling: **D1 and D4 are
complementary, and neither is sufficient.** D1 is blind to error confined
inside a stroke narrower than 5 device px; D4 is blind to error that rearranges
ink without changing its mass. M3 is caught only by D4; M4 is caught only by
D1.
**M10 — the mutation that is not a mutation.** A legitimate
antialiasing-only variant of the reference identical geometry, different
edge coverage **must PASS all four rules.** A differential that rejects
everything is exactly as useless as one that accepts everything, and this is
the only test that shows the tolerance is a tolerance. Measured on the
synthetic reference: D1 0 differing px, D2 0.25%, D3 0.35 px, in-band
max |Δluma| 3.
M9 is not a differential test; it is listed here because it is the mutation
that would make the differential *meaningless*, and the emitter must refuse it
structurally rather than be caught by a threshold.
**Why M3 and M5 name D4 and not D1.** Revision 1 required D1 to catch them. It
cannot: each is an error confined to strokes inside the band, and deleting a
4 px stem from the synthetic reference measured `d1 = 0`. Revision 1 would have
shipped a rule that reported `pass` on a dropped glyph. (M4 went the other way
on measurement see above.)
A mutation that does **not** kill is reported as a finding against this recipe,
and the recipe changes as it has now done twice: revision 2 gained D4, the
declared D1 blind spot and the declared D3 floor; revision 3 moved M4 from D4
to D1 and recorded D4's mass-preserving blind spot alongside D1's stroke-width
one.
---
## 12. Findings routed back to the W3 `.tex` amendment (pin 8)
The spike is §3E's first consumer, and pin 8 makes each awkwardness a finding.
Recorded as they are discovered; these are already known before implementation:
**W3-F1 — `TextFaceIdentity::version: Option<SemVer>` is the wrong type.**
Real font versions are not semver. The two faces here report
`Version 2.501;PS 2.501;ffdkm 0.1` and `Version 2.1.5`; only the second parses
as semver, and only after stripping a prefix. Since the field is explicitly
diagnostic `file_hash` is the identity the honest type is the **raw name-
table string**, `Option<String>`, or the field should be dropped. Typing it as
`SemVer` forces either a lossy parse or an empty field on a face that plainly
has a version.
**W3-F2 — one `unicode_version` field, two components define it.** Pin 9
requires that the segmentation implementation and its Unicode-data version be
named, and that they are separate from the bidi implementation. §3E carries a
single `unicode_version: UnicodeVersion`. Either the field means "the bidi
algorithm's" and segmentation's is unrecorded the exact gap pin 9 says must
not exist or it means both and the type is silently asserting the two agree.
The spike records both, and the amendment should carry both.
**W3-F3 `ShapedSegment::face: u32` has no value for a wholly-uncovered
span.** F-C's Arabic letter resolves in no declared face, so its segment has no
face index to carry but W3's invariant that segment source ranges *partition
the whole string* means the span cannot simply be omitted. The two requirements
are in direct conflict as §3E is written. The spike's stand-in uses
`face: Option<u32>`; the amendment needs that, or an explicit unresolved
segment variant.
**W3-F4 at a boundary into an unresolved span, affinity carries no
geometry.** W3 requires each caret stop to have "a geometric position and a
bidi affinity, so a caret at a direction boundary is unambiguous". F-C's byte 5
is a direction boundary (Latin LTR Arabic RTL) whose downstream side is an
unresolved, zero-advance cluster, so both affinities land on the **identical**
position measured, staff-space x = 3.130859375 for both. The distinctness
that makes affinity useful is unavailable by construction there. The spike
therefore enforces boundary distinctness on F-D (where both sides have ink) and
**deliberately exempts** an unresolved-side boundary; the exemption is recorded
here because an unstated exemption is indistinguishable from an oversight. The
amendment should say which of the two it wants: a stop pair that is allowed to
coincide, or a single stop where no distinction exists.
**W3-F5 a `u128` identity does not survive a JSON round-trip, and this is
not only a spike problem.** `Provenance`'s stable id renders as up to 39
decimal digits. Round-tripping the fixture file through `serde_json::Value`
or Python's `json`, or any JavaScript consumer silently converts it to an
`f64`: measured, `82875741697311382809239399464544864365` came back as
`8.287574169731139e+37`. A provenance id that changes when a tool merely reads
and rewrites a file is not an identity. The canonical wire format is binary and
is unaffected, so this is a constraint on **JSON artifacts** this file, and
any debug or fixture dump carrying an id of that width. The spike serializes it
as a decimal string. Any project tooling that dumps IR to JSON needs the same
treatment.
**W3-F6 §3E defines no serialized form, and the first consumer to need one
wrote a lossy mirror.** `epiphany-layout-ir` carries no `serde` dependency at
all, so nothing in §3E can be serialized as written. Every consumer that has to
persist, cache, dump, or send a `ResolvedText` must hand-write a mirror and
the very first one (this spike's `fixtures.json`) was quietly lossy for two
`Provenance` fields until review caught it: a `Debug` rendering in place of
`source`, and a length in place of `dependencies`. `Debug` output has no
stability contract and cannot be parsed back, and a dependency *count* discards
the invalidation set that is the field's whole purpose. It lost nothing
measurable here only because these fixtures' dependency lists are empty, which
is an accident of the fixtures.
The mirror is fixed (`source` and `dependencies` now carry
`TypedObjectId::canonical_bytes()`, under W3's field names). What routes back
is the shape of the mistake: an incremental-layout cache and an out-of-process
renderer are both plainly in W3's future, each needs this same conversion, and
each will write it independently. The amendment should specify `ResolvedText`'s
serialized form once a derive, or a canonical byte form as Chapter 5 fixes
for `TypedObjectId` rather than leave one per consumer.
**Not a W3 finding, but recorded the origin was not on the quantization
grid.** Invariant 5 requires positions on the 1/1024 grid, and this recipe's
own stated origin of `1.6` staff spaces is not representable there
(1.6 × 1024 = 1638.4). The invariant caught it during implementation rather
than after, which is the whole reason it is asserted instead of assumed.
Revision 2 recorded that here and left §3 saying `1.6` / device `(160, 540)`,
on the reasoning that the note explained the discrepancy. It does not: a
findings section does not repair a normative section, and a reader taking §3 at
its word would have taken an unrepresentable origin. **Revision 3 states the
quantized value in §3 itself** `1638/1024 = 1.599609375`, device x
`159.9609375` and `RUN_ORIGIN_STAFF` is now that exact ratio, so nothing is
silently rounded on the way past. Worth keeping on the record because the
number came from this recipe, not from the code: a stated constant can violate
a stated invariant, only one of the two was executable, and the executable one
was right.
**Also not a W3 finding, and worse than the one above the spike had its own
quantizer.** W3 §5 says positions are "quantized on the same 1/1024 grid as
glyph positions **so text quantization is not a second convention**". The
spike's `quantize_component` implemented the grid arithmetic locally as
`(v * 1024.0).round() / 1024.0`, which is round-half-**away-from-zero**, while
`epiphany_determinism::QuantizedCoord::from_staff_spaces` the project's own
quantizer, Appendix D is round-half-to-**even**. The divergence was *named*
in a doc comment, with the reasoning that this spike's values never land on a
tie. That is not a defence: nothing checked it, a font metric or a padding
constant could land on a tie at any time, and W3's requirement is about the
convention rather than about whether two conventions agree on today's inputs.
Naming a divergence is not the same as being allowed to take it.
`quantize_component` now routes through `QuantizedCoord`, and `is_on_grid`
which claimed exactness while accepting anything within `1e-6` is now an
exact round-trip through the same type. Four tests pin ties-to-even at
`±0.5` and `±2.5` grid units, each chosen because the two conventions
**disagree** there; a fifth records a tie where they agree, so the four are
understood as testing the disagreement and not merely "ties round somewhere".
Regenerating changed nothing: `fixtures.json` is byte-identical and the
artifact digest is unchanged at `c808d6eb…`, so no fixture value did in fact
land on a tie which is what the old comment claimed, and is still not what
made it acceptable.
Further findings are appended as implementation reaches them.
---
## 13. Rulings
1. **Check 3 scoring — RULED 2026-07-29: `NOT RUN`.** Full text and its
consequences in §1.2. F-D is recorded separately as *Supplementary PASS
Hebrew/Latin bidi itemization, fallback-face selection, visual ordering, and
resolved positioning*, and must not upgrade check 3 to PASS. Eligibility is
unaffected: check 3 is not disqualifying.
2. **Nothing else is open.** Every other choice here is either measured, taken
from a pin verbatim, or recorded as a finding against the amendment rather
than decided by the spike.