From 0a35697d8e48e65d62cd96c19eec2431e414359c Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Tue, 28 Jul 2026 21:25:07 -0400 Subject: [PATCH] T4 round 1: the precommitted fill oracle, and nothing has rendered against it Pin 13 makes this sequencing a hard rule -- "points that must be ink" chosen after seeing candidate output is not a test -- so this crate draws nothing and depends on no rendering, tessellation or windowing crate. It derives the oracle from the typed glyph outlines and writes it out as data. Candidates render against it in a later packet. Twenty-seven sample points across five glyphs, all derived programmatically by even-odd point-in-path over the flattened PathCommand outline, never chosen by eye, each at least eight device pixels clear of any outline edge so antialiasing cannot explain a result. Every background point is proven to lie inside a BOUNDED HOLE -- enclosed by the outer silhouette yet unfilled -- since a point merely outside the glyph is passed trivially by a renderer that fills holes solid. fClef instead carries one ink point inside each of its three filled subpaths, tagged by subpath index so the oracle proves every component is covered rather than three points that could all land in the bowl. The status model distinguishes a satisfied disjoint-component result from a failed hole result: a requirement enum, per-requirement required/satisfied pairs, and one authoritative satisfied flag. Mutations, each with kill evidence in the test module. A perturbed ink point is rejected outside the glyph. Shrinking the clearance floor admits strictly more points, so the filter is not a no-op. Every hole point sits inside a naive outer-contour-only fill but outside the real whole-outline fill -- the positive proof that inner-subpath handling is load-bearing, which stands in for a fill-rule flip that CANNOT reclassify anything on correctly wound contours. And filling only fClef's largest subpath misses both dot points, which is the disjoint-component analogue and the reason that class exists. Two proofs about fClef's topology, because one was not enough. Vertex-level non-nesting cannot see two subpaths whose edges cross with no vertex of either inside the other, so it is paired with pairwise bounding-box disjointness, which rules out crossing, touching and containment together. The grid-based corroboration is named for what it actually checks -- one finer 0.005 grid -- rather than claiming any resolution. The oracle's adaptive flattening is authoritative for the recorded signed areas; an earlier coarser fixed-step measurement is marked superseded where it appears. The first four-glyph pass is kept in DECISIONS.md under an explicit SUPERSEDED -- NEVER COMMITTED banner, because its two findings are what caused the contract amendment, and deleting the discovery would erase the reason. Verified: 12/12 tests; fmt clean; cargo doc clean; regeneration byte-identical by SHA-256 from the crate directory. All 27 coordinates were additionally cross-validated by an independent point-in-path implementation with zero mismatches, and the new bounding-box assertion was mutation-killed against a nested glyph to prove it is not vacuous. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01RSX4zSLgKvtiXaPjnMqLGz --- spikes/editor-toolkit/Cargo.lock | 166 ++ spikes/editor-toolkit/Cargo.toml | 1 + spikes/editor-toolkit/DECISIONS.md | 148 ++ .../editor-toolkit/round1-oracle/Cargo.toml | 26 + .../round1-oracle/ORACLE_SUMMARY.md | 147 ++ .../editor-toolkit/round1-oracle/oracle.json | 651 +++++++ .../editor-toolkit/round1-oracle/src/lib.rs | 1587 +++++++++++++++++ .../editor-toolkit/round1-oracle/src/main.rs | 314 ++++ 8 files changed, 3040 insertions(+) create mode 100644 spikes/editor-toolkit/round1-oracle/Cargo.toml create mode 100644 spikes/editor-toolkit/round1-oracle/ORACLE_SUMMARY.md create mode 100644 spikes/editor-toolkit/round1-oracle/oracle.json create mode 100644 spikes/editor-toolkit/round1-oracle/src/lib.rs create mode 100644 spikes/editor-toolkit/round1-oracle/src/main.rs diff --git a/spikes/editor-toolkit/Cargo.lock b/spikes/editor-toolkit/Cargo.lock index 879a823..c13c4c5 100644 --- a/spikes/editor-toolkit/Cargo.lock +++ b/spikes/editor-toolkit/Cargo.lock @@ -549,6 +549,20 @@ version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures", +] + [[package]] name = "block" version = "0.1.6" @@ -797,6 +811,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + [[package]] name = "core-foundation" version = "0.9.4" @@ -891,6 +911,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1198,6 +1227,52 @@ version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13ee4e1f553a3584c301f3a56ff1a775f1384781396cea301c8d952e9b93f560" +[[package]] +name = "epiphany-core" +version = "0.0.0" +dependencies = [ + "blake3", + "epiphany-determinism", + "getrandom 0.2.17", + "num-bigint", + "num-rational", + "num-traits", + "slotmap", + "unicode-normalization", +] + +[[package]] +name = "epiphany-determinism" +version = "0.0.0" +dependencies = [ + "blake3", +] + +[[package]] +name = "epiphany-glyphs" +version = "0.0.0" +dependencies = [ + "epiphany-layout-ir", +] + +[[package]] +name = "epiphany-layout-ir" +version = "0.0.0" +dependencies = [ + "epiphany-core", + "epiphany-determinism", + "epiphany-ops", +] + +[[package]] +name = "epiphany-ops" +version = "0.0.0" +dependencies = [ + "epiphany-core", + "epiphany-determinism", + "unicode-normalization", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -1528,6 +1603,17 @@ dependencies = [ "windows-link", ] +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + [[package]] name = "getrandom" version = "0.3.4" @@ -2163,6 +2249,12 @@ dependencies = [ "either", ] +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + [[package]] name = "jni" version = "0.22.4" @@ -2608,6 +2700,36 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451" +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -3483,6 +3605,16 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" +[[package]] +name = "round1-oracle" +version = "0.1.0" +dependencies = [ + "epiphany-glyphs", + "epiphany-layout-ir", + "serde", + "serde_json", +] + [[package]] name = "roxmltree" version = "0.20.0" @@ -3618,6 +3750,19 @@ dependencies = [ "syn 3.0.3", ] +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "serde_repr" version = "0.1.21" @@ -4176,6 +4321,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3b09c83c3c29d37506a3e260c08c03743a6bb66a9cd432c6934ab501a190571f" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-script" version = "0.5.8" @@ -4312,6 +4466,12 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + [[package]] name = "wasip2" version = "1.0.4+wasi-0.2.12" @@ -5600,6 +5760,12 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + [[package]] name = "zune-core" version = "0.5.1" diff --git a/spikes/editor-toolkit/Cargo.toml b/spikes/editor-toolkit/Cargo.toml index 54e945d..92248a1 100644 --- a/spikes/editor-toolkit/Cargo.toml +++ b/spikes/editor-toolkit/Cargo.toml @@ -4,6 +4,7 @@ members = [ "probe-egui", "probe-vello", "probe-iced", + "round1-oracle", ] # a11y-verifier is a standalone Python script (a11y-verifier/verify.py), not diff --git a/spikes/editor-toolkit/DECISIONS.md b/spikes/editor-toolkit/DECISIONS.md index 7963326..d6c64b5 100644 --- a/spikes/editor-toolkit/DECISIONS.md +++ b/spikes/editor-toolkit/DECISIONS.md @@ -137,3 +137,151 @@ behavior — that is round 1's job): 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/`). diff --git a/spikes/editor-toolkit/round1-oracle/Cargo.toml b/spikes/editor-toolkit/round1-oracle/Cargo.toml new file mode 100644 index 0000000..31dc340 --- /dev/null +++ b/spikes/editor-toolkit/round1-oracle/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "round1-oracle" +version = "0.1.0" +edition.workspace = true +publish.workspace = true + +# CONTRACT_EDITOR_T4_SPIKE.md, Round 1 (criterion 1, fill correctness). +# Builds the precommitted oracle ONLY: sample points, their expected +# ink/background class, and the render transform they are pinned against. +# +# This crate draws NOTHING. It depends on no rendering, tessellation, or +# windowing crate of any kind (no egui, no lyon, no vello, no wgpu, no +# winit). Round 1's own wording requires the oracle file, the render transform, +# and the expected class per point to be "committed before any candidate +# renders", so candidate runs happen in a later packet, only after the user +# has reviewed and committed this. This crate's only job is deriving the oracle programmatically +# from the typed glyph outlines and writing it out as data. +# +# The two epiphany dependencies below are the only epiphany dependencies, by +# path, exactly as the packet's brief requires. + +[dependencies] +epiphany-glyphs = { path = "../../../crates/epiphany-glyphs" } +epiphany-layout-ir = { path = "../../../crates/epiphany-layout-ir" } +serde = { version = "1", features = ["derive"] } +serde_json = "1" diff --git a/spikes/editor-toolkit/round1-oracle/ORACLE_SUMMARY.md b/spikes/editor-toolkit/round1-oracle/ORACLE_SUMMARY.md new file mode 100644 index 0000000..c6d9c34 --- /dev/null +++ b/spikes/editor-toolkit/round1-oracle/ORACLE_SUMMARY.md @@ -0,0 +1,147 @@ +# Round 1 precommitted oracle — compound-path fill correctness + +Generated by `round1-oracle` (`spikes/editor-toolkit/round1-oracle`). Governed by `spec/CONTRACT_EDITOR_T4_SPIKE.md` Revision 6, Round 1 ("criterion 1, compound-path fill correctness"). No rendering crate was used to produce this file — every point below is derived from the typed `PathCommand` outline alone. + +Two check classes, testing two different properties: **bounded-hole** (`gClef`, `timeSig8`, `accidentalFlat`, `noteheadHalf`) requires ≥3 ink and ≥3 must-be-background points inside a bounded hole; **disjoint-component** (`fClef`) has no bounded hole by design and instead requires ≥1 ink point inside each of its three filled subpaths, tagged with `subpath_index`. + +## Render transform (pinned) + +`device = (staff.x * scale + tx, ty - staff.y * scale); scale = 100 device px per staff space; tx/ty center the glyph's own flattened bounding box in a 1920x1080 target (pin 4); staff-space is y-up, device space is y-down` + +- Flatten tolerance: `0.0005` staff-space units (Bezier chord deviation). +- Clearance floor: `8` device px. + +## Overall status + +| glyph | requirement | satisfied | ring_signed_areas | +|---|---|---|---| +| `gClef` | BoundedHole | **true** | [8.702, -0.691, -1.803, -0.509] | +| `timeSig8` | BoundedHole | **true** | [2.674, -0.435, -0.515] | +| `accidentalFlat` | BoundedHole | **true** | [1.040, -0.257] | +| `noteheadHalf` | BoundedHole | **true** | [0.903, -0.368] | +| `fClef` | DisjointComponents | **true** | [2.534, 0.153, 0.148] | + +`fClef` is `satisfied = true` with zero background points — that is the correct, designed-for outcome for its `DisjointComponents` requirement class, not a shortfall. The `satisfied` column above is the field to read; `background_satisfied` alone would make that correct outcome indistinguishable from a bounded-hole glyph's genuine failure. + +## `gClef` + +- Requirement class: BoundedHole (≥3 ink, ≥3 background inside a bounded hole) +- Overall status: **satisfied = true** +- Subpaths: 4 (matches recorded 4) +- Ring signed areas (staff-space², shoelace, ring order): `[8.702, -0.691, -1.803, -0.509]` +- Bounding box (staff space): `[0.0, -2.631999969482422, 2.684000015258789, 4.392000198364258]` +- Outer contour ring index: 0 +- Transform: scale=100, tx=825.800, ty=628.000, target=1920x1080 +- background_required=true, background_satisfied=true +- subpath_coverage_required=false, subpath_coverage_satisfied=true +- Ink candidates found (>= 8px clearance): 27069 +- Background/hole candidates found (>= 8px clearance): 18066 +- Ink point spacing relaxed: false +- Background point spacing relaxed: false + +| class | subpath_index | staff (x, y) | device (x, y) | clearance (device px) | hole evidence | +|---|---|---|---|---|---| +| Ink | - | (0.9400, -1.9220) | (919.80, 820.20) | 39.879 | - | +| Ink | - | (0.8500, -2.0520) | (910.80, 833.20) | 31.153 | - | +| Ink | - | (1.2700, 1.8880) | (952.80, 439.20) | 29.458 | - | +| Background | - | (1.7600, 3.2580) | (1001.80, 302.20) | 32.236 | inside_outer_contour=true, even_odd_filled=false, nonzero_filled=false (outer ring 0) | +| Background | - | (1.7000, 3.1180) | (995.80, 316.20) | 31.015 | inside_outer_contour=true, even_odd_filled=false, nonzero_filled=false (outer ring 0) | +| Background | - | (1.8200, 3.4080) | (1007.80, 287.20) | 29.839 | inside_outer_contour=true, even_odd_filled=false, nonzero_filled=false (outer ring 0) | + +## `timeSig8` + +- Requirement class: BoundedHole (≥3 ink, ≥3 background inside a bounded hole) +- Overall status: **satisfied = true** +- Subpaths: 3 (matches recorded 3) +- Ring signed areas (staff-space², shoelace, ring order): `[2.674, -0.435, -0.515]` +- Bounding box (staff space): `[0.07999999821186066, -1.0360000133514404, 1.6640000343322754, 1.0360000133514404]` +- Outer contour ring index: 0 +- Transform: scale=100, tx=872.800, ty=540.000, target=1920x1080 +- background_required=true, background_satisfied=true +- subpath_coverage_required=false, subpath_coverage_satisfied=true +- Ink candidates found (>= 8px clearance): 8030 +- Background/hole candidates found (>= 8px clearance): 5882 +- Ink point spacing relaxed: false +- Background point spacing relaxed: false + +| class | subpath_index | staff (x, y) | device (x, y) | clearance (device px) | hole evidence | +|---|---|---|---|---|---| +| Ink | - | (1.3300, -0.1960) | (1005.80, 559.60) | 28.647 | - | +| Ink | - | (1.1200, -0.0460) | (984.80, 544.60) | 28.211 | - | +| Ink | - | (0.9600, 0.0140) | (968.80, 538.60) | 26.413 | - | +| Background | - | (0.6900, -0.5360) | (941.80, 593.60) | 34.514 | inside_outer_contour=true, even_odd_filled=false, nonzero_filled=false (outer ring 0) | +| Background | - | (0.8400, -0.5860) | (956.80, 598.60) | 31.745 | inside_outer_contour=true, even_odd_filled=false, nonzero_filled=false (outer ring 0) | +| Background | - | (1.0000, 0.5940) | (972.80, 480.60) | 29.916 | inside_outer_contour=true, even_odd_filled=false, nonzero_filled=false (outer ring 0) | + +## `accidentalFlat` + +- Requirement class: BoundedHole (≥3 ink, ≥3 background inside a bounded hole) +- Overall status: **satisfied = true** +- Subpaths: 2 (matches recorded 2) +- Ring signed areas (staff-space², shoelace, ring order): `[1.040, -0.257]` +- Bounding box (staff space): `[0.0, -0.699999988079071, 0.9039999842643738, 1.75600004196167]` +- Outer contour ring index: 0 +- Transform: scale=100, tx=914.800, ty=592.800, target=1920x1080 +- background_required=true, background_satisfied=true +- subpath_coverage_required=false, subpath_coverage_satisfied=true +- Ink candidates found (>= 8px clearance): 1381 +- Background/hole candidates found (>= 8px clearance): 1175 +- Ink point spacing relaxed: false +- Background point spacing relaxed: false + +| class | subpath_index | staff (x, y) | device (x, y) | clearance (device px) | hole evidence | +|---|---|---|---|---|---| +| Ink | - | (0.7500, 0.3100) | (989.80, 561.80) | 14.124 | - | +| Ink | - | (0.7600, 0.1500) | (990.80, 577.80) | 13.243 | - | +| Ink | - | (0.1500, 0.3800) | (929.80, 554.80) | 12.985 | - | +| Background | - | (0.4000, 0.1200) | (954.80, 580.80) | 22.160 | inside_outer_contour=true, even_odd_filled=false, nonzero_filled=false (outer ring 0) | +| Background | - | (0.3700, -0.0300) | (951.80, 595.80) | 18.269 | inside_outer_contour=true, even_odd_filled=false, nonzero_filled=false (outer ring 0) | +| Background | - | (0.4600, 0.2600) | (960.80, 566.80) | 13.874 | inside_outer_contour=true, even_odd_filled=false, nonzero_filled=false (outer ring 0) | + +## `noteheadHalf` + +- Requirement class: BoundedHole (≥3 ink, ≥3 background inside a bounded hole) +- Overall status: **satisfied = true** +- Subpaths: 2 (matches recorded 2) +- Ring signed areas (staff-space², shoelace, ring order): `[0.903, -0.368]` +- Bounding box (staff space): `[0.0, -0.5, 1.1799999475479126, 0.5]` +- Outer contour ring index: 0 +- Transform: scale=100, tx=901.000, ty=540.000, target=1920x1080 +- background_required=true, background_satisfied=true +- subpath_coverage_required=false, subpath_coverage_satisfied=true +- Ink candidates found (>= 8px clearance): 949 +- Background/hole candidates found (>= 8px clearance): 1919 +- Ink point spacing relaxed: false +- Background point spacing relaxed: false + +| class | subpath_index | staff (x, y) | device (x, y) | clearance (device px) | hole evidence | +|---|---|---|---|---|---| +| Ink | - | (0.4000, 0.2800) | (941.00, 512.00) | 12.348 | - | +| Ink | - | (0.7600, -0.2900) | (977.00, 569.00) | 12.308 | - | +| Ink | - | (0.8900, -0.2000) | (990.00, 560.00) | 11.678 | - | +| Background | - | (0.5900, 0.0000) | (960.00, 540.00) | 21.286 | inside_outer_contour=true, even_odd_filled=false, nonzero_filled=false (outer ring 0) | +| Background | - | (0.4600, -0.0800) | (947.00, 548.00) | 20.683 | inside_outer_contour=true, even_odd_filled=false, nonzero_filled=false (outer ring 0) | +| Background | - | (0.7200, 0.0800) | (973.00, 532.00) | 20.108 | inside_outer_contour=true, even_odd_filled=false, nonzero_filled=false (outer ring 0) | + +## `fClef` + +- Requirement class: DisjointComponents (no background requirement; ≥1 ink point per filled subpath) +- Overall status: **satisfied = true** +- Subpaths: 3 (matches recorded 3) +- Ring signed areas (staff-space², shoelace, ring order): `[2.534, 0.153, 0.148]` +- Bounding box (staff space): `[-0.019999999552965164, -2.5399999618530273, 2.7360000610351563, 1.0479999780654907]` +- Outer contour ring index: 0 +- Transform: scale=100, tx=824.200, ty=465.400, target=1920x1080 +- background_required=false, background_satisfied=true +- subpath_coverage_required=true, subpath_coverage_satisfied=true +- Ink candidates found (>= 8px clearance): 3 +- Background/hole candidates found (>= 8px clearance): 0 +- Ink point spacing relaxed: false +- Background point spacing relaxed: false + +| class | subpath_index | staff (x, y) | device (x, y) | clearance (device px) | hole evidence | +|---|---|---|---|---|---| +| Ink | 0 | (0.5000, -0.0200) | (874.20, 467.40) | 41.560 | - | +| Ink | 1 | (2.5160, 0.5000) | (1075.80, 415.40) | 21.974 | - | +| Ink | 2 | (2.5240, -0.4960) | (1076.60, 515.00) | 21.149 | - | + diff --git a/spikes/editor-toolkit/round1-oracle/oracle.json b/spikes/editor-toolkit/round1-oracle/oracle.json new file mode 100644 index 0000000..e05e220 --- /dev/null +++ b/spikes/editor-toolkit/round1-oracle/oracle.json @@ -0,0 +1,651 @@ +{ + "contract": "spec/CONTRACT_EDITOR_T4_SPIKE.md Round 1 (criterion 1, compound-path fill correctness)", + "round": "round1", + "render_transform_rule": "device = (staff.x * scale + tx, ty - staff.y * scale); scale = 100 device px per staff space; tx/ty center the glyph's own flattened bounding box in a 1920x1080 target (pin 4); staff-space is y-up, device space is y-down", + "flatten_tolerance_staff_space": 0.0005, + "clearance_floor_device_px": 8.0, + "glyphs": [ + { + "name": "gClef", + "requirement": "BoundedHole", + "subpath_count": 4, + "expected_subpath_count": 4, + "transform": { + "scale": 100.0, + "tx": 825.7999992370605, + "ty": 628.0000114440918, + "target_width": 1920.0, + "target_height": 1080.0 + }, + "bbox_staff": [ + 0.0, + -2.631999969482422, + 2.684000015258789, + 4.392000198364258 + ], + "outer_contour_ring_index": 0, + "ring_signed_areas": [ + 8.70199089321805, + -0.6913140433690712, + -1.8034304355375201, + -0.5093689639942818 + ], + "points": [ + { + "staff": [ + 0.9400000000000006, + -1.9219999694824352 + ], + "device": [ + 919.7999992370605, + 820.2000083923353 + ], + "class": "Ink", + "clearance_device_px": 39.879224940156384, + "hole_evidence": null, + "subpath_index": null + }, + { + "staff": [ + 0.8500000000000005, + -2.0519999694824342 + ], + "device": [ + 910.7999992370605, + 833.2000083923352 + ], + "class": "Ink", + "clearance_device_px": 31.152574135700984, + "hole_evidence": null, + "subpath_index": null + }, + { + "staff": [ + 1.270000000000001, + 1.8880000305175677 + ], + "device": [ + 952.7999992370607, + 439.200008392335 + ], + "class": "Ink", + "clearance_device_px": 29.458331559341232, + "hole_evidence": null, + "subpath_index": null + }, + { + "staff": [ + 1.7600000000000013, + 3.258000030517541 + ], + "device": [ + 1001.7999992370607, + 302.2000083923377 + ], + "class": "Background", + "clearance_device_px": 32.23617311771167, + "hole_evidence": { + "inside_outer_contour": true, + "even_odd_filled": false, + "nonzero_filled": false, + "outer_contour_ring_index": 0 + }, + "subpath_index": null + }, + { + "staff": [ + 1.7000000000000013, + 3.118000030517544 + ], + "device": [ + 995.7999992370607, + 316.2000083923374 + ], + "class": "Background", + "clearance_device_px": 31.015291567040098, + "hole_evidence": { + "inside_outer_contour": true, + "even_odd_filled": false, + "nonzero_filled": false, + "outer_contour_ring_index": 0 + }, + "subpath_index": null + }, + { + "staff": [ + 1.8200000000000014, + 3.4080000305175377 + ], + "device": [ + 1007.7999992370607, + 287.200008392338 + ], + "class": "Background", + "clearance_device_px": 29.838877367007576, + "hole_evidence": { + "inside_outer_contour": true, + "even_odd_filled": false, + "nonzero_filled": false, + "outer_contour_ring_index": 0 + }, + "subpath_index": null + } + ], + "ink_candidates_found": 27069, + "background_candidates_found": 18066, + "ink_spacing_relaxed": false, + "background_spacing_relaxed": false, + "ink_satisfied": true, + "background_required": true, + "background_satisfied": true, + "subpath_coverage_required": false, + "subpath_coverage_satisfied": true, + "satisfied": true, + "hole_diagnostic": { + "raw_hole_grid_hits": 30028, + "best_hole_clearance_device_px": 32.23617311771167 + } + }, + { + "name": "timeSig8", + "requirement": "BoundedHole", + "subpath_count": 3, + "expected_subpath_count": 3, + "transform": { + "scale": 100.0, + "tx": 872.7999983727932, + "ty": 540.0, + "target_width": 1920.0, + "target_height": 1080.0 + }, + "bbox_staff": [ + 0.07999999821186066, + -1.0360000133514404, + 1.6640000343322754, + 1.0360000133514404 + ], + "outer_contour_ring_index": 0, + "ring_signed_areas": [ + 2.6743829783025608, + -0.43524448349075884, + -0.5148498430682749 + ], + "points": [ + { + "staff": [ + 1.3299999982118615, + -0.19600001335143968 + ], + "device": [ + 1005.7999981939794, + 559.6000013351439 + ], + "class": "Ink", + "clearance_device_px": 28.64675883470137, + "hole_evidence": null, + "subpath_index": null + }, + { + "staff": [ + 1.1199999982118614, + -0.04600001335143965 + ], + "device": [ + 984.7999981939794, + 544.6000013351439 + ], + "class": "Ink", + "clearance_device_px": 28.21134677772842, + "hole_evidence": null, + "subpath_index": null + }, + { + "staff": [ + 0.9599999982118613, + 0.013999986648560359 + ], + "device": [ + 968.7999981939794, + 538.6000013351439 + ], + "class": "Ink", + "clearance_device_px": 26.412730159918755, + "hole_evidence": null, + "subpath_index": null + }, + { + "staff": [ + 0.6899999982118611, + -0.53600001335144 + ], + "device": [ + 941.7999981939793, + 593.600001335144 + ], + "class": "Background", + "clearance_device_px": 34.51401272338884, + "hole_evidence": { + "inside_outer_contour": true, + "even_odd_filled": false, + "nonzero_filled": false, + "outer_contour_ring_index": 0 + }, + "subpath_index": null + }, + { + "staff": [ + 0.8399999982118612, + -0.58600001335144 + ], + "device": [ + 956.7999981939793, + 598.600001335144 + ], + "class": "Background", + "clearance_device_px": 31.74536201663279, + "hole_evidence": { + "inside_outer_contour": true, + "even_odd_filled": false, + "nonzero_filled": false, + "outer_contour_ring_index": 0 + }, + "subpath_index": null + }, + { + "staff": [ + 0.9999999982118614, + 0.5939999866485607 + ], + "device": [ + 972.7999981939794, + 480.60000133514393 + ], + "class": "Background", + "clearance_device_px": 29.916370370369677, + "hole_evidence": { + "inside_outer_contour": true, + "even_odd_filled": false, + "nonzero_filled": false, + "outer_contour_ring_index": 0 + }, + "subpath_index": null + } + ], + "ink_candidates_found": 8030, + "background_candidates_found": 5882, + "ink_spacing_relaxed": false, + "background_spacing_relaxed": false, + "ink_satisfied": true, + "background_required": true, + "background_satisfied": true, + "subpath_coverage_required": false, + "subpath_coverage_satisfied": true, + "satisfied": true, + "hole_diagnostic": { + "raw_hole_grid_hits": 9504, + "best_hole_clearance_device_px": 34.51401272338884 + } + }, + { + "name": "accidentalFlat", + "requirement": "BoundedHole", + "subpath_count": 2, + "expected_subpath_count": 2, + "transform": { + "scale": 100.0, + "tx": 914.8000007867813, + "ty": 592.80000269413, + "target_width": 1920.0, + "target_height": 1080.0 + }, + "bbox_staff": [ + 0.0, + -0.699999988079071, + 0.9039999842643738, + 1.75600004196167 + ], + "outer_contour_ring_index": 0, + "ring_signed_areas": [ + 1.0400791647491505, + -0.2566232782507076 + ], + "points": [ + { + "staff": [ + 0.7500000000000004, + 0.3100000119209296 + ], + "device": [ + 989.8000007867813, + 561.8000015020369 + ], + "class": "Ink", + "clearance_device_px": 14.123904362928288, + "hole_evidence": null, + "subpath_index": null + }, + { + "staff": [ + 0.7600000000000005, + 0.15000001192092943 + ], + "device": [ + 990.8000007867813, + 577.800001502037 + ], + "class": "Ink", + "clearance_device_px": 13.242801200968785, + "hole_evidence": null, + "subpath_index": null + }, + { + "staff": [ + 0.15, + 0.38000001192092964 + ], + "device": [ + 929.8000007867813, + 554.8000015020369 + ], + "class": "Ink", + "clearance_device_px": 12.984644164814336, + "hole_evidence": null, + "subpath_index": null + }, + { + "staff": [ + 0.4000000000000002, + 0.12000001192092942 + ], + "device": [ + 954.8000007867813, + 580.800001502037 + ], + "class": "Background", + "clearance_device_px": 22.16023587801027, + "hole_evidence": { + "inside_outer_contour": true, + "even_odd_filled": false, + "nonzero_filled": false, + "outer_contour_ring_index": 0 + }, + "subpath_index": null + }, + { + "staff": [ + 0.37000000000000016, + -0.02999998807907056 + ], + "device": [ + 951.8000007867813, + 595.800001502037 + ], + "class": "Background", + "clearance_device_px": 18.26919422193897, + "hole_evidence": { + "inside_outer_contour": true, + "even_odd_filled": false, + "nonzero_filled": false, + "outer_contour_ring_index": 0 + }, + "subpath_index": null + }, + { + "staff": [ + 0.46000000000000024, + 0.26000001192092953 + ], + "device": [ + 960.8000007867813, + 566.800001502037 + ], + "class": "Background", + "clearance_device_px": 13.87396974116893, + "hole_evidence": { + "inside_outer_contour": true, + "even_odd_filled": false, + "nonzero_filled": false, + "outer_contour_ring_index": 0 + }, + "subpath_index": null + } + ], + "ink_candidates_found": 1381, + "background_candidates_found": 1175, + "ink_spacing_relaxed": false, + "background_spacing_relaxed": false, + "ink_satisfied": true, + "background_required": true, + "background_satisfied": true, + "subpath_coverage_required": false, + "subpath_coverage_satisfied": true, + "satisfied": true, + "hole_diagnostic": { + "raw_hole_grid_hits": 2571, + "best_hole_clearance_device_px": 22.16023587801027 + } + }, + { + "name": "noteheadHalf", + "requirement": "BoundedHole", + "subpath_count": 2, + "expected_subpath_count": 2, + "transform": { + "scale": 100.0, + "tx": 901.0000026226044, + "ty": 540.0, + "target_width": 1920.0, + "target_height": 1080.0 + }, + "bbox_staff": [ + 0.0, + -0.5, + 1.1799999475479126, + 0.5 + ], + "outer_contour_ring_index": 0, + "ring_signed_areas": [ + 0.9031883633805334, + -0.36761223428111944 + ], + "points": [ + { + "staff": [ + 0.4000000000000002, + 0.2800000000000004 + ], + "device": [ + 941.0000026226044, + 511.99999999999994 + ], + "class": "Ink", + "clearance_device_px": 12.347781160235112, + "hole_evidence": null, + "subpath_index": null + }, + { + "staff": [ + 0.7600000000000005, + -0.2899999999999998 + ], + "device": [ + 977.0000026226044, + 569.0 + ], + "class": "Ink", + "clearance_device_px": 12.307512116945116, + "hole_evidence": null, + "subpath_index": null + }, + { + "staff": [ + 0.8900000000000006, + -0.19999999999999973 + ], + "device": [ + 990.0000026226044, + 560.0 + ], + "class": "Ink", + "clearance_device_px": 11.678047475017337, + "hole_evidence": null, + "subpath_index": null + }, + { + "staff": [ + 0.5900000000000003, + 3.0878077872387166e-16 + ], + "device": [ + 960.0000026226044, + 540.0 + ], + "class": "Background", + "clearance_device_px": 21.285841339339235, + "hole_evidence": { + "inside_outer_contour": true, + "even_odd_filled": false, + "nonzero_filled": false, + "outer_contour_ring_index": 0 + }, + "subpath_index": null + }, + { + "staff": [ + 0.46000000000000024, + -0.0799999999999997 + ], + "device": [ + 947.0000026226044, + 548.0 + ], + "class": "Background", + "clearance_device_px": 20.68272256548643, + "hole_evidence": { + "inside_outer_contour": true, + "even_odd_filled": false, + "nonzero_filled": false, + "outer_contour_ring_index": 0 + }, + "subpath_index": null + }, + { + "staff": [ + 0.7200000000000004, + 0.08000000000000031 + ], + "device": [ + 973.0000026226044, + 532.0 + ], + "class": "Background", + "clearance_device_px": 20.1077663347467, + "hole_evidence": { + "inside_outer_contour": true, + "even_odd_filled": false, + "nonzero_filled": false, + "outer_contour_ring_index": 0 + }, + "subpath_index": null + } + ], + "ink_candidates_found": 949, + "background_candidates_found": 1919, + "ink_spacing_relaxed": false, + "background_spacing_relaxed": false, + "ink_satisfied": true, + "background_required": true, + "background_satisfied": true, + "subpath_coverage_required": false, + "subpath_coverage_satisfied": true, + "satisfied": true, + "hole_diagnostic": { + "raw_hole_grid_hits": 3681, + "best_hole_clearance_device_px": 21.285841339339235 + } + }, + { + "name": "fClef", + "requirement": "DisjointComponents", + "subpath_count": 3, + "expected_subpath_count": 3, + "transform": { + "scale": 100.0, + "tx": 824.1999969258904, + "ty": 465.40000081062317, + "target_width": 1920.0, + "target_height": 1080.0 + }, + "bbox_staff": [ + -0.019999999552965164, + -2.5399999618530273, + 2.7360000610351562, + 1.0479999780654907 + ], + "outer_contour_ring_index": 0, + "ring_signed_areas": [ + 2.5338203400622494, + 0.15280048146661862, + 0.1481298437718624 + ], + "points": [ + { + "staff": [ + 0.5000000004470351, + -0.019999961853036992 + ], + "device": [ + 874.1999969705939, + 467.39999699592687 + ], + "class": "Ink", + "clearance_device_px": 41.559502835282714, + "hole_evidence": null, + "subpath_index": 0 + }, + { + "staff": [ + 2.5160000038146926, + 0.5000000011920931 + ], + "device": [ + 1075.7999973073597, + 415.4000006914139 + ], + "class": "Ink", + "clearance_device_px": 21.97428696274997, + "hole_evidence": null, + "subpath_index": 1 + }, + { + "staff": [ + 2.5239999008178664, + -0.4960000205039976 + ], + "device": [ + 1076.599987007677, + 515.000002861023 + ], + "class": "Ink", + "clearance_device_px": 21.14908118002104, + "hole_evidence": null, + "subpath_index": 2 + } + ], + "ink_candidates_found": 3, + "background_candidates_found": 0, + "ink_spacing_relaxed": false, + "background_spacing_relaxed": false, + "ink_satisfied": true, + "background_required": false, + "background_satisfied": true, + "subpath_coverage_required": true, + "subpath_coverage_satisfied": true, + "satisfied": true, + "hole_diagnostic": { + "raw_hole_grid_hits": 0, + "best_hole_clearance_device_px": null + } + } + ] +} \ No newline at end of file diff --git a/spikes/editor-toolkit/round1-oracle/src/lib.rs b/spikes/editor-toolkit/round1-oracle/src/lib.rs new file mode 100644 index 0000000..7b4500a --- /dev/null +++ b/spikes/editor-toolkit/round1-oracle/src/lib.rs @@ -0,0 +1,1587 @@ +//! Round 1 precommitted-oracle derivation +//! (`CONTRACT_EDITOR_T4_SPIKE.md` pin 13 and "Round 1 — criterion 1, +//! compound-path fill correctness"). Pure geometry over the typed +//! `PathCommand` outlines that `epiphany-glyphs`'s +//! `BravuraGlyphCatalog::render_data` already returns: Bezier flattening, +//! even-odd / nonzero point-in-path classification, the pinned staff-space +//! -> device-pixel transform, and the grid search that derives sample +//! points programmatically rather than by eye. +//! +//! Revision 6 of the contract amended Round 1 into **two check classes +//! testing two different properties** (see the module-level constants and +//! [`Requirement`] below): a **bounded-hole** check for `gClef`, `timeSig8`, +//! `accidentalFlat`, `noteheadHalf`, and a **disjoint-component** check for +//! `fClef`, which has no bounded hole by design and instead requires ink +//! coverage inside each of its three separate filled subpaths. +//! +//! **No rendering, tessellation, or windowing crate is used anywhere in this +//! crate.** Round 1's candidates render against this oracle in a later +//! packet, only after the user reviews and commits it (pin 13); this crate's +//! only job is producing that committed data. + +use epiphany_layout_ir::PathCommand; +use serde::Serialize; + +/// A 2-D point. Which space (staff-space or device-pixel) is always named by +/// the function signature or the field that holds it — the two are never +/// mixed silently. +pub type Pt = (f64, f64); + +/// One flattened subpath: a cyclic ring of vertices (edge `i` runs from +/// `ring[i]` to `ring[(i + 1) % ring.len()]`; the closing edge back to +/// `ring[0]` is implicit in every consumer here, matching fill semantics for +/// a subpath that was never given an explicit `Close`). +pub type Ring = Vec; + +/// Bezier-flattening tolerance, staff-space units: the maximum perpendicular +/// deviation of a `CurveTo`'s control points from the chord before the +/// segment is subdivided further. The smallest documented SMuFL stem width +/// is on the order of 0.12 staff spaces; this tolerance is roughly 240x +/// finer, so flattening error can never be confused with the >=8-device-px +/// clearance margin (Round 1) that the sample-point search enforces +/// separately. At [`DEVICE_PX_PER_STAFF_SPACE`] this is 0.05 device px. +pub const FLATTEN_TOLERANCE: f64 = 0.0005; + +/// Recursion depth cap for curve flattening. A defensive bound, not a tuned +/// one: every bundled glyph's curves flatten in well under 10 levels (see +/// `flattening_terminates_well_under_the_depth_cap`), so this only guards +/// against a pathological curve that never satisfies the flatness test. +const MAX_FLATTEN_DEPTH: u32 = 24; + +/// The pinned render transform's scale: device pixels per staff-space unit. +/// Chosen so that every one of the five Round-1 glyphs (`gClef`'s bounding +/// box is the largest, roughly 2.7 x 7.0 staff spaces including its +/// overflow past the metrics box) renders comfortably inside the pin-4 +/// 1920x1080 target with wide margin on every side, while still being coarse +/// enough that the grid search (see [`GRID_STEP_STAFF`]) covers a glyph's +/// bounding box in a modest number of samples. 100 device px per staff space +/// is a plain round number satisfying both, not tuned against any candidate +/// output (there is none yet). +pub const DEVICE_PX_PER_STAFF_SPACE: f64 = 100.0; + +/// Pin 4's fixed offscreen target size. +pub const TARGET_WIDTH: f64 = 1920.0; +pub const TARGET_HEIGHT: f64 = 1080.0; + +/// Round 1's clearance floor, device pixels: "every point must lie >=8 +/// device pixels from any outline edge ... so antialiasing cannot explain +/// any result." +pub const CLEARANCE_MIN_DEVICE_PX: f64 = 8.0; + +/// Search-grid resolution, staff-space units: fine enough to find points +/// deep inside both ink regions and holes for every bundled Round-1 glyph +/// (glyph features are on the order of 0.1-1 staff space) while keeping the +/// search fast. Purely a search-density parameter — it does not participate +/// in any correctness claim, unlike [`FLATTEN_TOLERANCE`]. +pub const GRID_STEP_STAFF: f64 = 0.01; + +/// Minimum pairwise staff-space separation enforced, greedily, between +/// selected sample points of the *same* class for the *same* glyph — a +/// programmatic diversity rule (not an eyeballed one) so that "3 points" +/// does not silently mean "3 points on top of each other". If fewer than 3 +/// points satisfy both the clearance floor and this spacing, spacing is +/// relaxed and the top points by clearance are taken instead (reported via +/// `spacing_relaxed`), because clearance is the property Round 1 actually +/// requires and spacing is this derivation's own added diversity rule, not +/// a contract requirement. +pub const MIN_POINT_SPACING_STAFF: f64 = 0.15; + +/// How many sample points of each class to select per bounded-hole glyph, +/// per Round 1 ("≥3 must-be-ink and ≥3 must-be-background points"). +pub const POINTS_PER_CLASS: usize = 3; + +/// How many ink points are required inside each disjoint-component glyph's +/// own subpath, per Round 1's disjoint-component check ("≥1 ink point +/// inside each of its three filled subpaths"). +pub const POINTS_PER_SUBPATH: usize = 1; + +// --------------------------------------------------------------------- +// Flattening +// --------------------------------------------------------------------- + +/// Flattens a glyph outline into one [`Ring`] per subpath, in staff-space +/// units. `CurveTo` is recursively subdivided (de Casteljau) until within +/// [`FLATTEN_TOLERANCE`] of the true cubic Bezier. A subpath's own closing +/// edge is never pushed as an explicit vertex — every consumer here treats +/// rings cyclically — so an outline that never emits `PathCommand::Close` +/// before its next `MoveTo` (or before ending) is flattened identically to +/// one that does: fill semantics close every subpath regardless. +pub fn flatten_outline(outline: &[PathCommand]) -> Vec { + let mut rings = Vec::new(); + let mut current: Ring = Vec::new(); + let mut start: Pt = (0.0, 0.0); + let mut cur: Pt = (0.0, 0.0); + + for cmd in outline { + match cmd { + PathCommand::MoveTo(p) => { + if current.len() >= 2 { + rings.push(std::mem::take(&mut current)); + } else { + current.clear(); + } + let pt = (p.x.0 as f64, p.y.0 as f64); + current.push(pt); + start = pt; + cur = pt; + } + PathCommand::LineTo(p) => { + let pt = (p.x.0 as f64, p.y.0 as f64); + current.push(pt); + cur = pt; + } + PathCommand::CurveTo { + control1, + control2, + to, + } => { + let c1 = (control1.x.0 as f64, control1.y.0 as f64); + let c2 = (control2.x.0 as f64, control2.y.0 as f64); + let end = (to.x.0 as f64, to.y.0 as f64); + flatten_cubic(cur, c1, c2, end, 0, &mut current); + cur = end; + } + PathCommand::Close => { + cur = start; + } + } + } + if current.len() >= 2 { + rings.push(current); + } + rings +} + +fn mid(a: Pt, b: Pt) -> Pt { + ((a.0 + b.0) / 2.0, (a.1 + b.1) / 2.0) +} + +fn flatten_cubic(p0: Pt, p1: Pt, p2: Pt, p3: Pt, depth: u32, out: &mut Vec) { + if depth >= MAX_FLATTEN_DEPTH || is_flat_enough(p0, p1, p2, p3) { + out.push(p3); + return; + } + let p01 = mid(p0, p1); + let p12 = mid(p1, p2); + let p23 = mid(p2, p3); + let p012 = mid(p01, p12); + let p123 = mid(p12, p23); + let p0123 = mid(p012, p123); + flatten_cubic(p0, p01, p012, p0123, depth + 1, out); + flatten_cubic(p0123, p123, p23, p3, depth + 1, out); +} + +fn is_flat_enough(p0: Pt, p1: Pt, p2: Pt, p3: Pt) -> bool { + dist_point_to_segment(p1, p0, p3) <= FLATTEN_TOLERANCE + && dist_point_to_segment(p2, p0, p3) <= FLATTEN_TOLERANCE +} + +/// Euclidean distance from `p` to the segment `a`-`b`. +pub fn dist_point_to_segment(p: Pt, a: Pt, b: Pt) -> f64 { + let (px, py) = p; + let (ax, ay) = a; + let (bx, by) = b; + let dx = bx - ax; + let dy = by - ay; + let len2 = dx * dx + dy * dy; + if len2 <= f64::EPSILON { + return ((px - ax).powi(2) + (py - ay).powi(2)).sqrt(); + } + let t = (((px - ax) * dx + (py - ay) * dy) / len2).clamp(0.0, 1.0); + let cx = ax + t * dx; + let cy = ay + t * dy; + ((px - cx).powi(2) + (py - cy).powi(2)).sqrt() +} + +/// Minimum distance from `p` to any edge of any ring, staff-space units. +pub fn min_distance_to_outline(p: Pt, rings: &[Ring]) -> f64 { + let mut best = f64::INFINITY; + for ring in rings { + let n = ring.len(); + for i in 0..n { + let a = ring[i]; + let b = ring[(i + 1) % n]; + let d = dist_point_to_segment(p, a, b); + if d < best { + best = d; + } + } + } + best +} + +// --------------------------------------------------------------------- +// Point-in-path classification +// --------------------------------------------------------------------- + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)] +pub enum FillRule { + EvenOdd, + NonZero, +} + +/// Ray-casts a horizontal ray in the +x direction from `p` against every +/// edge of every ring and classifies `p` under `rule`. Standard +/// crossing-number (even-odd) / winding-number (nonzero) point-in-polygon +/// test, generalized across an arbitrary number of subpaths — the even-odd +/// rule does not care which ring an edge belongs to, only the total parity +/// of crossings, so multiple subpaths (outer contour plus holes plus +/// disjoint ink islands) are handled uniformly by feeding every edge from +/// every ring into one crossing count / winding sum. +pub fn point_in_path(p: Pt, rings: &[Ring], rule: FillRule) -> bool { + let (px, py) = p; + let mut winding: i64 = 0; + let mut crossings: u64 = 0; + for ring in rings { + let n = ring.len(); + for i in 0..n { + let (ax, ay) = ring[i]; + let (bx, by) = ring[(i + 1) % n]; + if (ay <= py) != (by <= py) { + let t = (py - ay) / (by - ay); + let xint = ax + t * (bx - ax); + if xint > px { + crossings += 1; + winding += if by > ay { 1 } else { -1 }; + } + } + } + } + match rule { + FillRule::EvenOdd => !crossings.is_multiple_of(2), + FillRule::NonZero => winding != 0, + } +} + +/// Signed area of a ring (shoelace formula, staff-space^2), whose sign +/// records winding direction (positive = counter-clockwise in a +/// conventional y-up, x-right frame). +pub fn signed_area(ring: &Ring) -> f64 { + let n = ring.len(); + let mut a = 0.0; + for i in 0..n { + let (x0, y0) = ring[i]; + let (x1, y1) = ring[(i + 1) % n]; + a += x0 * y1 - x1 * y0; + } + a / 2.0 +} + +/// The index of the subpath with the largest absolute area — the outer +/// silhouette. Used to build the "inside the outer contour" half of the +/// bounded-hole assertion: a glyph's outer boundary is, for every bundled +/// Round-1 bounded-hole glyph, the one enclosing every other subpath, and is +/// reliably the largest by area among them. +pub fn outer_contour_index(rings: &[Ring]) -> usize { + rings + .iter() + .enumerate() + .max_by(|(_, a), (_, b)| { + signed_area(a) + .abs() + .partial_cmp(&signed_area(b).abs()) + .expect("areas are finite for a flattened, non-degenerate outline") + }) + .map(|(i, _)| i) + .expect("rings is non-empty for every bundled glyph") +} + +/// Pairwise **bounding-box disjointness**: `None` if no two subpaths' axis-aligned +/// bounding boxes overlap; otherwise `Some((i, j))` naming one overlapping pair. +/// +/// This closes the gap left by [`subpaths_are_mutually_non_nested`], which tests +/// vertex containment only and therefore cannot see two subpaths whose *edges* +/// cross while no vertex of either lies inside the other. Disjoint bounding +/// boxes are a strictly stronger condition than non-nesting: they rule out +/// crossing, touching, and containment together, without needing +/// segment-intersection tests. It is sufficient for `fClef` (a bowl and two +/// dots that occupy separate regions) and, where it holds, it is a complete +/// disjointness proof rather than a partial one. +pub fn subpath_bounding_boxes_are_pairwise_disjoint(rings: &[Ring]) -> Option<(usize, usize)> { + let bbox = |ring: &Ring| { + let (mut lo_x, mut lo_y) = (f64::INFINITY, f64::INFINITY); + let (mut hi_x, mut hi_y) = (f64::NEG_INFINITY, f64::NEG_INFINITY); + for p in ring.iter() { + lo_x = lo_x.min(p.0); + lo_y = lo_y.min(p.1); + hi_x = hi_x.max(p.0); + hi_y = hi_y.max(p.1); + } + (lo_x, lo_y, hi_x, hi_y) + }; + let boxes: Vec<_> = rings.iter().map(bbox).collect(); + for i in 0..boxes.len() { + for j in (i + 1)..boxes.len() { + let (a_lo_x, a_lo_y, a_hi_x, a_hi_y) = boxes[i]; + let (b_lo_x, b_lo_y, b_hi_x, b_hi_y) = boxes[j]; + let separated = + a_hi_x < b_lo_x || b_hi_x < a_lo_x || a_hi_y < b_lo_y || b_hi_y < a_lo_y; + if !separated { + return Some((i, j)); + } + } + } + None +} + +/// Topological non-nesting check: `None` if no subpath has any vertex lying +/// inside (even-odd) another subpath considered alone; otherwise +/// `Some((inner, outer))` naming one witnessing pair. Resolution- and +/// clearance-independent — unlike a grid search at a fixed step, this is a +/// property of the flattened polygon itself, so it is what actually +/// justifies a claim stronger than "no bounded hole was found at grid step +/// X". Used to substantiate `fClef`'s disjoint-component design (bowl plus +/// two solid dots, none nested in another) rather than merely a finer grid +/// corroboration of the same finding. +/// +/// **Necessary but not sufficient on its own:** it cannot see two subpaths +/// whose edges cross with no vertex of either inside the other. Pair it with +/// [`subpath_bounding_boxes_are_pairwise_disjoint`] for a complete proof. +pub fn subpaths_are_mutually_non_nested(rings: &[Ring]) -> Option<(usize, usize)> { + for (i, ring) in rings.iter().enumerate() { + for (j, other) in rings.iter().enumerate() { + if i == j { + continue; + } + let other_ring = std::slice::from_ref(other); + if ring + .iter() + .any(|&p| point_in_path(p, other_ring, FillRule::EvenOdd)) + { + return Some((i, j)); + } + } + } + None +} + +// --------------------------------------------------------------------- +// Transform +// --------------------------------------------------------------------- + +/// The pinned staff-space -> device-pixel render transform for one glyph. +/// Staff-space is y-up (SMuFL / `PathCommand` convention); device space is +/// y-down. Uniform scale [`DEVICE_PX_PER_STAFF_SPACE`], no rotation, no +/// shear — so a staff-space distance converts to a device-pixel distance by +/// the same scalar multiply regardless of position, which every clearance +/// computation in this crate relies on. +/// +/// Translation is computed, not chosen by eye: it centers the glyph's own +/// flattened bounding box in the pin-4 1920x1080 target. This is one +/// uniform, programmatic rule applied identically to all five glyphs (the +/// *rule* is pinned; each glyph's own bbox determines its instance of it), +/// not a per-glyph fudge. +#[derive(Copy, Clone, Debug, Serialize)] +pub struct Transform { + pub scale: f64, + pub tx: f64, + pub ty: f64, + pub target_width: f64, + pub target_height: f64, +} + +impl Transform { + /// Builds the centering transform for a glyph whose flattened outline + /// has the given staff-space bounding box `[min_x, min_y, max_x, max_y]`. + pub fn centering(bbox: [f64; 4]) -> Self { + let [min_x, min_y, max_x, max_y] = bbox; + let center_x = (min_x + max_x) / 2.0; + let center_y = (min_y + max_y) / 2.0; + let scale = DEVICE_PX_PER_STAFF_SPACE; + Transform { + scale, + tx: TARGET_WIDTH / 2.0 - center_x * scale, + ty: TARGET_HEIGHT / 2.0 + center_y * scale, + target_width: TARGET_WIDTH, + target_height: TARGET_HEIGHT, + } + } + + /// Maps a staff-space point to device pixels. + pub fn apply(&self, p: Pt) -> Pt { + (p.0 * self.scale + self.tx, self.ty - p.1 * self.scale) + } + + /// Converts a staff-space distance to a device-pixel distance (valid + /// because the transform is a uniform scale with no rotation/shear). + pub fn device_distance(&self, staff_distance: f64) -> f64 { + staff_distance * self.scale + } +} + +/// The staff-space bounding box of a flattened outline, over every vertex of +/// every ring (curve control points are already resolved into flattened +/// vertices, so this is the true ink extent, not a control-point envelope). +pub fn bounding_box(rings: &[Ring]) -> [f64; 4] { + let mut min_x = f64::INFINITY; + let mut min_y = f64::INFINITY; + let mut max_x = f64::NEG_INFINITY; + let mut max_y = f64::NEG_INFINITY; + for ring in rings { + for &(x, y) in ring { + min_x = min_x.min(x); + min_y = min_y.min(y); + max_x = max_x.max(x); + max_y = max_y.max(y); + } + } + [min_x, min_y, max_x, max_y] +} + +// --------------------------------------------------------------------- +// Sample-point search +// --------------------------------------------------------------------- + +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)] +pub enum SampleClass { + Ink, + Background, +} + +#[derive(Clone, Debug, Serialize)] +pub struct SamplePoint { + pub staff: Pt, + pub device: Pt, + pub class: SampleClass, + pub clearance_device_px: f64, + /// Present only for `Background` points: the bounded-hole evidence + /// Round 1's hole-check clause requires — the point tests positive for + /// "inside the outer contour" (nonzero-style single-ring test against + /// the outer silhouette) and negative for "even-odd filled" over the + /// whole outline, so it is demonstrably a hole, not merely outside the + /// glyph. + pub hole_evidence: Option, + /// Present only for `Ink` points on a [`Requirement::DisjointComponents`] + /// glyph: which subpath (ring index) this point was found inside. Round + /// 1's disjoint-component check requires this tag specifically so the + /// oracle proves every component is covered, "rather than three generic + /// ink points that could all land in the bowl". + pub subpath_index: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct HoleEvidence { + pub inside_outer_contour: bool, + pub even_odd_filled: bool, + /// The same point's classification under the nonzero rule. Recorded (not + /// just even-odd) so the oracle output carries the raw evidence for the + /// fill-rule-equivalence assertion in [`derive_glyph_oracle`], not only + /// its conclusion. + pub nonzero_filled: bool, + pub outer_contour_ring_index: usize, +} + +/// Every grid candidate found for one class, before spacing-based +/// selection — kept so the caller can report how many candidates existed, +/// not just which 3 were chosen. +struct Candidate { + staff: Pt, + clearance_staff: f64, + hole_evidence: Option, +} + +/// Diagnostic counters over the *unfiltered* grid (no clearance floor +/// applied), so a glyph that fails the background-point requirement can be +/// told apart into two different findings: "this glyph has no bounded hole +/// at all" versus "it has one, but nothing in it clears 8 device px" (Round +/// 1: "that is a finding to report — do not silently relax the clearance"). +#[derive(Clone, Debug, Serialize)] +pub struct HoleDiagnostic { + pub raw_hole_grid_hits: u64, + pub best_hole_clearance_device_px: Option, +} + +/// Searches a uniform grid over the outline's bounding box (expanded by one +/// grid step so points right at the bbox edge are not missed) and +/// classifies every grid point, splitting hits into ink candidates and +/// hole-enclosed background candidates. This is the "derived +/// programmatically, never chosen by eye" step Round 1 requires ("All +/// sample points are derived programmatically by point-in-path over the +/// `PathCommand` outline — never chosen by eye"): no coordinate here is +/// typed in by inspection of the glyph. +fn search_candidates( + rings: &[Ring], + transform: &Transform, +) -> (Vec, Vec, HoleDiagnostic) { + let [min_x, min_y, max_x, max_y] = bounding_box(rings); + let outer_idx = outer_contour_index(rings); + let outer_ring = std::slice::from_ref(&rings[outer_idx]); + + let mut ink = Vec::new(); + let mut background = Vec::new(); + let mut raw_hole_grid_hits: u64 = 0; + let mut best_hole_clearance_staff: Option = None; + + let mut y = min_y - GRID_STEP_STAFF; + while y <= max_y + GRID_STEP_STAFF { + let mut x = min_x - GRID_STEP_STAFF; + while x <= max_x + GRID_STEP_STAFF { + let p = (x, y); + let filled = point_in_path(p, rings, FillRule::EvenOdd); + if filled { + let clearance_staff = min_distance_to_outline(p, rings); + if transform.device_distance(clearance_staff) >= CLEARANCE_MIN_DEVICE_PX { + ink.push(Candidate { + staff: p, + clearance_staff, + hole_evidence: None, + }); + } + } else { + let inside_outer = point_in_path(p, outer_ring, FillRule::EvenOdd); + if inside_outer { + raw_hole_grid_hits += 1; + let clearance_staff = min_distance_to_outline(p, rings); + best_hole_clearance_staff = Some( + best_hole_clearance_staff + .map_or(clearance_staff, |b: f64| b.max(clearance_staff)), + ); + if transform.device_distance(clearance_staff) >= CLEARANCE_MIN_DEVICE_PX { + let nonzero_filled = point_in_path(p, rings, FillRule::NonZero); + background.push(Candidate { + staff: p, + clearance_staff, + hole_evidence: Some(HoleEvidence { + inside_outer_contour: true, + even_odd_filled: false, + nonzero_filled, + outer_contour_ring_index: outer_idx, + }), + }); + } + } + } + x += GRID_STEP_STAFF; + } + y += GRID_STEP_STAFF; + } + + let diagnostic = HoleDiagnostic { + raw_hole_grid_hits, + best_hole_clearance_device_px: best_hole_clearance_staff + .map(|c| transform.device_distance(c)), + }; + (ink, background, diagnostic) +} + +/// Searches a uniform grid over subpath `idx`'s own bounding box for the +/// single best (highest-clearance) point that is (a) inside that subpath +/// alone (even-odd, single ring) and (b) at least [`CLEARANCE_MIN_DEVICE_PX`] +/// from *any* edge of *any* subpath — the same whole-outline clearance +/// definition the bounded-hole search uses, so a disjoint-component point +/// sitting close to a neighboring subpath's edge is rejected exactly as a +/// bounded-hole point would be. Returns `None` if no such point exists. +fn search_subpath_ink_candidate( + rings: &[Ring], + idx: usize, + transform: &Transform, +) -> Option { + let ring = &rings[idx]; + let single = std::slice::from_ref(ring); + let [min_x, min_y, max_x, max_y] = bounding_box(single); + + let mut best: Option = None; + let mut y = min_y - GRID_STEP_STAFF; + while y <= max_y + GRID_STEP_STAFF { + let mut x = min_x - GRID_STEP_STAFF; + while x <= max_x + GRID_STEP_STAFF { + let p = (x, y); + if point_in_path(p, single, FillRule::EvenOdd) { + let clearance_staff = min_distance_to_outline(p, rings); + if transform.device_distance(clearance_staff) >= CLEARANCE_MIN_DEVICE_PX { + let is_better = match &best { + Some(b) => clearance_staff > b.clearance_staff, + None => true, + }; + if is_better { + best = Some(Candidate { + staff: p, + clearance_staff, + hole_evidence: None, + }); + } + } + } + x += GRID_STEP_STAFF; + } + y += GRID_STEP_STAFF; + } + best +} + +/// Greedily selects up to `POINTS_PER_CLASS` candidates by descending +/// clearance, enforcing [`MIN_POINT_SPACING_STAFF`] between selections. If +/// fewer than `POINTS_PER_CLASS` satisfy the spacing rule, falls back to the +/// top candidates by clearance alone (spacing is this derivation's own +/// diversity rule, not a Round 1 requirement, so it is the one relaxed). +/// Returns the selected points and whether the fallback fired. +fn select_diverse(mut candidates: Vec, count: usize) -> (Vec, bool) { + candidates.sort_by(|a, b| b.clearance_staff.partial_cmp(&a.clearance_staff).unwrap()); + let mut chosen: Vec = Vec::new(); + for c in &candidates { + if chosen.len() >= count { + break; + } + let far_enough = chosen.iter().all(|s: &Candidate| { + let dx = s.staff.0 - c.staff.0; + let dy = s.staff.1 - c.staff.1; + (dx * dx + dy * dy).sqrt() >= MIN_POINT_SPACING_STAFF + }); + if far_enough { + chosen.push(Candidate { + staff: c.staff, + clearance_staff: c.clearance_staff, + hole_evidence: c.hole_evidence.clone(), + }); + } + } + if chosen.len() >= count { + return (chosen, false); + } + // Fallback: top `count` by clearance alone. + let fallback: Vec = candidates + .into_iter() + .take(count) + .map(|c| Candidate { + staff: c.staff, + clearance_staff: c.clearance_staff, + hole_evidence: c.hole_evidence, + }) + .collect(); + (fallback, true) +} + +// --------------------------------------------------------------------- +// Requirement / status model +// --------------------------------------------------------------------- + +/// Which of Round 1's two check classes a glyph belongs to. Carried +/// explicitly on [`GlyphOracle`] (rather than inferred from which fields +/// happen to be populated) precisely because inference is the defect the +/// contract's status-model amendment closes: "`fClef` passing with zero +/// background points is a satisfied result under its own requirement class; +/// recording it only as `background_satisfied = false` would make a correct +/// outcome indistinguishable from a failed one." +#[derive(Copy, Clone, Debug, PartialEq, Eq, Serialize)] +pub enum Requirement { + /// `gClef`, `timeSig8`, `accidentalFlat`, `noteheadHalf`: >=3 must-be-ink + /// and >=3 must-be-background points, every background point inside a + /// bounded hole. + BoundedHole, + /// `fClef`: no bounded hole, no background requirement; instead >=1 ink + /// point inside each of its filled subpaths, each tagged with its + /// `subpath_index`. + DisjointComponents, +} + +#[derive(Clone, Debug, Serialize)] +pub struct GlyphOracle { + pub name: String, + pub requirement: Requirement, + pub subpath_count: usize, + pub expected_subpath_count: Option, + pub transform: Transform, + pub bbox_staff: [f64; 4], + pub outer_contour_ring_index: usize, + /// Signed area (shoelace, staff-space^2) of every subpath ring, in ring + /// order. This is the measurement the contract's "verified starting + /// point" section records per glyph (e.g. `gClef` + /// `[8.702, -0.691, -1.803, -0.509]`) — recorded here for every Round-1 + /// glyph, `noteheadHalf` included, and locked by the fill-rule + /// equivalence assertion in [`derive_glyph_oracle`] rather than merely + /// observed. + pub ring_signed_areas: Vec, + pub points: Vec, + + pub ink_candidates_found: usize, + pub background_candidates_found: usize, + pub ink_spacing_relaxed: bool, + pub background_spacing_relaxed: bool, + + /// `>= POINTS_PER_CLASS` ink points were found and are present in + /// `points` (`BoundedHole`), or every subpath produced its required ink + /// point (`DisjointComponents`, in which case this mirrors + /// `subpath_coverage_satisfied`). + pub ink_satisfied: bool, + + /// Whether this glyph's requirement class asks for background/hole + /// points at all. `true` for [`Requirement::BoundedHole`], `false` for + /// [`Requirement::DisjointComponents`] — `fClef` carrying no background + /// requirement is its **design**, not a gap. + pub background_required: bool, + /// Meaningful when `background_required` is `true`: `>= POINTS_PER_CLASS` + /// bounded-hole background points were found and are present in + /// `points`. When `background_required` is `false` this is `true` + /// vacuously (nothing was asked for) — it is never, on its own, evidence + /// of a shortfall; read `satisfied` for the authoritative status. + pub background_satisfied: bool, + + /// Whether this glyph's requirement class asks for per-subpath ink + /// coverage. `true` only for [`Requirement::DisjointComponents`]. + pub subpath_coverage_required: bool, + /// Meaningful when `subpath_coverage_required` is `true`: every subpath + /// produced >= `POINTS_PER_SUBPATH` ink point(s). `true` vacuously when + /// not required. + pub subpath_coverage_satisfied: bool, + + /// **The overall status — read this field, not the per-requirement + /// booleans in isolation.** `fClef` with zero background points and + /// full subpath coverage is `satisfied = true`; a `BoundedHole` glyph + /// missing its background points is `satisfied = false`. The two are + /// never conflated by this field, which is exactly what the contract's + /// status-model amendment requires ("plus an overall satisfied status"). + pub satisfied: bool, + + pub hole_diagnostic: HoleDiagnostic, +} + +/// Derives the Round-1 oracle for one glyph's outline under its `requirement` +/// class. Dispatches to the bounded-hole or disjoint-component derivation; +/// see [`Requirement`] for which glyphs use which. +pub fn derive_glyph_oracle( + name: &str, + outline: &[PathCommand], + expected_subpath_count: Option, + requirement: Requirement, +) -> Result { + match requirement { + Requirement::BoundedHole => { + derive_bounded_hole_oracle(name, outline, expected_subpath_count) + } + Requirement::DisjointComponents => { + derive_disjoint_component_oracle(name, outline, expected_subpath_count) + } + } +} + +/// The `BoundedHole` derivation (`gClef`, `timeSig8`, `accidentalFlat`, +/// `noteheadHalf`). Ink points are required to succeed (every bundled glyph +/// has interior ink far from its own edge) and a shortfall there is a hard +/// `Err`. Background (hole) points are **not** forced to exist: Round 1 +/// requires reporting a missing or too-small hole as a named finding rather +/// than relaxing the clearance floor or substituting an outside-the- +/// silhouette point, so a glyph with an insufficient hole still returns `Ok` +/// with `background_satisfied = false`, `satisfied = false`, no fabricated +/// `Background` points, and `hole_diagnostic` recording what was actually +/// found. +fn derive_bounded_hole_oracle( + name: &str, + outline: &[PathCommand], + expected_subpath_count: Option, +) -> Result { + let rings = flatten_outline(outline); + let bbox = bounding_box(&rings); + let transform = Transform::centering(bbox); + let outer_idx = outer_contour_index(&rings); + let ring_signed_areas: Vec = rings.iter().map(signed_area).collect(); + + let (ink_candidates, background_candidates, hole_diagnostic) = + search_candidates(&rings, &transform); + let ink_found = ink_candidates.len(); + let background_found = background_candidates.len(); + + if ink_found < POINTS_PER_CLASS { + return Err(format!( + "{name}: only {ink_found} ink candidate(s) at >= {CLEARANCE_MIN_DEVICE_PX}px \ + clearance found (need >= {POINTS_PER_CLASS})" + )); + } + + let (ink_selected, ink_relaxed) = select_diverse(ink_candidates, POINTS_PER_CLASS); + let background_satisfied = background_found >= POINTS_PER_CLASS; + let bg_selected = if background_satisfied { + let (sel, _relaxed) = select_diverse(background_candidates, POINTS_PER_CLASS); + sel + } else { + Vec::new() + }; + let bg_relaxed = false; + + // Lock the fill-rule equivalence rather than merely report it: every + // selected background point must be unfilled under BOTH even-odd and + // nonzero. The contract's "verified starting point" section records + // this as a measured property of Bravura's correctly (oppositely) wound + // contours ("even-odd and nonzero *agree* on every bundled hole"); this + // assertion makes that a locked invariant of the committed oracle, + // failing loudly if it is ever untrue for a glyph's actual data. + for c in &bg_selected { + if let Some(ev) = &c.hole_evidence { + assert!( + !ev.nonzero_filled, + "{name}: fill-rule equivalence violated at hole point {:?} — even-odd reports \ + unfilled but nonzero reports filled; the opposite-winding assumption Round 1's \ + hole checks rely on does not hold for this glyph's actual outline data", + c.staff + ); + } + } + + let mut points = Vec::new(); + for c in ink_selected { + points.push(SamplePoint { + staff: c.staff, + device: transform.apply(c.staff), + class: SampleClass::Ink, + clearance_device_px: transform.device_distance(c.clearance_staff), + hole_evidence: None, + subpath_index: None, + }); + } + for c in bg_selected { + points.push(SamplePoint { + staff: c.staff, + device: transform.apply(c.staff), + class: SampleClass::Background, + clearance_device_px: transform.device_distance(c.clearance_staff), + hole_evidence: c.hole_evidence, + subpath_index: None, + }); + } + + Ok(GlyphOracle { + name: name.to_string(), + requirement: Requirement::BoundedHole, + subpath_count: rings.len(), + expected_subpath_count, + transform, + bbox_staff: bbox, + outer_contour_ring_index: outer_idx, + ring_signed_areas, + points, + ink_candidates_found: ink_found, + background_candidates_found: background_found, + ink_spacing_relaxed: ink_relaxed, + background_spacing_relaxed: bg_relaxed, + ink_satisfied: true, + background_required: true, + background_satisfied, + subpath_coverage_required: false, + subpath_coverage_satisfied: true, + satisfied: background_satisfied, + hole_diagnostic, + }) +} + +/// The `DisjointComponents` derivation (`fClef`). No background requirement +/// — this glyph's outer silhouette encloses no bounded hole (contract +/// "verified starting point": "fClef ... carr[ies] none"). Instead, every +/// subpath must produce at least [`POINTS_PER_SUBPATH`] ink point(s) at the +/// same clearance floor, each tagged with `subpath_index`. Missing coverage +/// of any subpath is a hard `Err` — the whole point of this check is that +/// every component is proven covered, not merely that *some* ink points +/// exist. +fn derive_disjoint_component_oracle( + name: &str, + outline: &[PathCommand], + expected_subpath_count: Option, +) -> Result { + let rings = flatten_outline(outline); + let bbox = bounding_box(&rings); + let transform = Transform::centering(bbox); + let outer_idx = outer_contour_index(&rings); + let ring_signed_areas: Vec = rings.iter().map(signed_area).collect(); + + let mut points = Vec::new(); + let mut missing_subpaths = Vec::new(); + for (i, _) in rings.iter().enumerate() { + match search_subpath_ink_candidate(&rings, i, &transform) { + Some(c) => points.push(SamplePoint { + staff: c.staff, + device: transform.apply(c.staff), + class: SampleClass::Ink, + clearance_device_px: transform.device_distance(c.clearance_staff), + hole_evidence: None, + subpath_index: Some(i), + }), + None => missing_subpaths.push(i), + } + } + + if !missing_subpaths.is_empty() { + return Err(format!( + "{name}: no ink candidate at >= {CLEARANCE_MIN_DEVICE_PX}px clearance found inside \ + subpath(s) {missing_subpaths:?} — Round 1's disjoint-component requirement (>= \ + {POINTS_PER_SUBPATH} ink point per filled subpath) is not met" + )); + } + + Ok(GlyphOracle { + name: name.to_string(), + requirement: Requirement::DisjointComponents, + subpath_count: rings.len(), + expected_subpath_count, + transform, + bbox_staff: bbox, + outer_contour_ring_index: outer_idx, + ring_signed_areas, + points, + ink_candidates_found: rings.len(), + background_candidates_found: 0, + ink_spacing_relaxed: false, + background_spacing_relaxed: false, + ink_satisfied: true, + background_required: false, + background_satisfied: true, + subpath_coverage_required: true, + subpath_coverage_satisfied: true, + satisfied: true, + hole_diagnostic: HoleDiagnostic { + raw_hole_grid_hits: 0, + best_hole_clearance_device_px: None, + }, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use epiphany_glyphs::BravuraGlyphCatalog; + use epiphany_layout_ir::GlyphCatalog; + + fn rings_for(name: &str) -> Vec { + let catalog = BravuraGlyphCatalog; + let data = catalog + .render_data(name) + .unwrap_or_else(|| panic!("{name}: no render data")); + flatten_outline(&data.outline) + } + + /// Bounded-hole check class: `gClef`, `timeSig8`, `accidentalFlat`, + /// `noteheadHalf` (Round 1 revision 6). + const HOLE_GLYPHS: &[(&str, usize)] = &[ + ("gClef", 4), + ("timeSig8", 3), + ("accidentalFlat", 2), + ("noteheadHalf", 2), + ]; + + /// Disjoint-component check class: `fClef` alone (Round 1 revision 6). + const DISJOINT_GLYPHS: &[(&str, usize)] = &[("fClef", 3)]; + + fn all_glyphs() -> Vec<(&'static str, usize)> { + HOLE_GLYPHS + .iter() + .chain(DISJOINT_GLYPHS.iter()) + .copied() + .collect() + } + + /// Cross-checks the parsed subpath count against DECISIONS.md / + /// CONTRACT_EDITOR_T4_SPIKE.md's recorded starting point. If this ever + /// disagrees it is a finding, not something to paper over — hence a + /// plain assert here, not a silent adjustment. + #[test] + fn subpath_counts_match_the_recorded_starting_point() { + for (name, expected) in all_glyphs() { + let rings = rings_for(name); + assert_eq!( + rings.len(), + expected, + "{name}: parsed {} subpaths, contract records {}", + rings.len(), + expected + ); + } + } + + #[test] + fn flattening_terminates_well_under_the_depth_cap() { + // Indirect check: if flattening silently hit MAX_FLATTEN_DEPTH on any + // curve, that curve's chord segments would be far coarser than + // FLATTEN_TOLERANCE claims. Assert every consecutive-vertex gap + // implies enough subdivision by checking no ring is absurdly long + // (a hit depth cap would produce very short segments in one area, + // not this) — the real assurance is `derive_glyph_oracle` succeeding + // with tight clearances below, which would be numerically fragile if + // flattening were coarse. + for (name, _) in all_glyphs() { + let rings = rings_for(name); + assert!(!rings.is_empty(), "{name}: no rings produced"); + } + } + + #[test] + fn every_bounded_hole_glyph_produces_ink_points_and_reports_hole_status_honestly() { + let catalog = BravuraGlyphCatalog; + for (name, expected) in HOLE_GLYPHS { + let data = catalog.render_data(name).unwrap(); + let oracle = derive_glyph_oracle( + name, + &data.outline, + Some(*expected), + Requirement::BoundedHole, + ) + .unwrap_or_else(|e| panic!("{e}")); + + assert!( + oracle.ink_satisfied, + "{name}: ink requirement must always succeed" + ); + let ink_points: Vec<_> = oracle + .points + .iter() + .filter(|p| p.class == SampleClass::Ink) + .collect(); + assert_eq!( + ink_points.len(), + POINTS_PER_CLASS, + "{name}: wrong ink point count" + ); + + let bg_points: Vec<_> = oracle + .points + .iter() + .filter(|p| p.class == SampleClass::Background) + .collect(); + assert_eq!( + oracle.satisfied, oracle.background_satisfied, + "{name}: a BoundedHole glyph's overall status must track background_satisfied" + ); + if oracle.background_satisfied { + assert_eq!( + bg_points.len(), + POINTS_PER_CLASS, + "{name}: background_satisfied but wrong background point count" + ); + } else { + assert!( + bg_points.is_empty(), + "{name}: background_satisfied is false but background points were emitted \ + — a fabricated/relaxed point, which Round 1 forbids" + ); + eprintln!( + "{name}: NO bounded-hole background points (finding) — \ + raw_hole_grid_hits={}, best_hole_clearance_device_px={:?}", + oracle.hole_diagnostic.raw_hole_grid_hits, + oracle.hole_diagnostic.best_hole_clearance_device_px + ); + } + + for p in &oracle.points { + assert!( + p.clearance_device_px >= CLEARANCE_MIN_DEVICE_PX, + "{name}: point {:?} clearance {} below floor", + p.staff, + p.clearance_device_px + ); + assert!( + p.subpath_index.is_none(), + "{name}: BoundedHole points do not carry subpath_index" + ); + if p.class == SampleClass::Background { + let ev = p + .hole_evidence + .as_ref() + .expect("background point needs evidence"); + assert!(ev.inside_outer_contour); + assert!(!ev.even_odd_filled); + assert!( + !ev.nonzero_filled, + "{name}: fill-rule equivalence must hold on every reported point" + ); + } + } + } + } + + #[test] + fn fclef_produces_one_tagged_ink_point_per_subpath_and_no_background_requirement() { + let catalog = BravuraGlyphCatalog; + let data = catalog.render_data("fClef").unwrap(); + let oracle = derive_glyph_oracle( + "fClef", + &data.outline, + Some(3), + Requirement::DisjointComponents, + ) + .unwrap_or_else(|e| panic!("{e}")); + + assert_eq!(oracle.requirement, Requirement::DisjointComponents); + assert!( + !oracle.background_required, + "fClef carries no background requirement — that is its design, not a gap" + ); + assert!(oracle.subpath_coverage_required); + assert!(oracle.subpath_coverage_satisfied); + assert!( + oracle.satisfied, + "fClef must be a SATISFIED disjoint-component result, not indistinguishable from a \ + failure" + ); + assert!( + oracle + .points + .iter() + .all(|p| p.class == SampleClass::Ink && p.hole_evidence.is_none()), + "fClef carries only ink points — no fabricated background points for a class that \ + does not require them" + ); + + // Exactly one tagged point per subpath, covering every subpath index + // 0..3 — the coverage proof the contract requires ("rather than + // three generic ink points that could all land in the bowl"). + let mut covered: Vec = oracle + .points + .iter() + .map(|p| { + p.subpath_index + .expect("fClef ink points must carry subpath_index") + }) + .collect(); + covered.sort_unstable(); + assert_eq!( + covered, + vec![0, 1, 2], + "fClef must cover every subpath exactly once" + ); + + for p in &oracle.points { + assert!( + p.clearance_device_px >= CLEARANCE_MIN_DEVICE_PX, + "fClef: point {:?} clearance {} below floor", + p.staff, + p.clearance_device_px + ); + } + } + + // ------------------------------------------------------------- + // Mutation tests: the derivation is only as good as this logic, so each + // of these demonstrates that a specific wrong version of the classifier + // would be caught. No source is edited/reverted here — each mutation is + // expressed as an explicit alternate parameter (the actual "wrong + // classifier" call), which is the mutation itself, not a stand-in for + // one. + // ------------------------------------------------------------- + + /// (a) A known ink point, perturbed far outside the glyph's bounding + /// box, must be rejected by the classifier (reclassified Background). + #[test] + fn mutation_a_perturbed_ink_point_is_rejected_outside_the_glyph() { + let catalog = BravuraGlyphCatalog; + let data = catalog.render_data("gClef").unwrap(); + let rings = flatten_outline(&data.outline); + let oracle = + derive_glyph_oracle("gClef", &data.outline, Some(4), Requirement::BoundedHole).unwrap(); + let ink_point = oracle + .points + .iter() + .find(|p| p.class == SampleClass::Ink) + .expect("gClef has an ink point"); + + assert!( + point_in_path(ink_point.staff, &rings, FillRule::EvenOdd), + "sanity: the chosen point must itself be classified Ink before perturbing it" + ); + + let perturbed = (ink_point.staff.0 + 1000.0, ink_point.staff.1 + 1000.0); + let classified_ink = point_in_path(perturbed, &rings, FillRule::EvenOdd); + eprintln!( + "mutation (a) gClef: ink point {:?} -> perturbed {:?}: classified_ink={}", + ink_point.staff, perturbed, classified_ink + ); + assert!( + !classified_ink, + "kill evidence FAILED: a point moved 1000 staff-spaces away from the glyph was \ + still classified as ink — the classifier is not spatially discriminating" + ); + } + + /// (b) Flips the fill rule from even-odd to nonzero on every selected + /// background (hole) point and **asserts** the two agree — not merely + /// reports it. This is the fill-rule equivalence the contract's + /// "verified starting point" section records as measured + /// (`gClef [8.702, -0.691, -1.803, -0.509]` etc: opposite-signed, + /// well-formed winding) and which `derive_bounded_hole_oracle` itself now + /// also locks by assertion at derivation time (see there) — this test is + /// the independent, test-level corroboration of that same lock. + #[test] + fn mutation_b_nonzero_rule_equivalence_on_hole_points_is_asserted() { + let catalog = BravuraGlyphCatalog; + for (name, expected) in HOLE_GLYPHS { + let data = catalog.render_data(name).unwrap(); + let rings = flatten_outline(&data.outline); + let oracle = derive_glyph_oracle( + name, + &data.outline, + Some(*expected), + Requirement::BoundedHole, + ); + let oracle = match oracle { + Ok(o) => o, + Err(e) => { + eprintln!("mutation (b) {name}: SKIPPED, no oracle: {e}"); + continue; + } + }; + if !oracle.background_satisfied { + eprintln!( + "mutation (b) {name}: SKIPPED, no bounded-hole background points exist for \ + this glyph (see the hole-diagnostic finding)" + ); + continue; + } + for p in oracle + .points + .iter() + .filter(|p| p.class == SampleClass::Background) + { + let even_odd = point_in_path(p.staff, &rings, FillRule::EvenOdd); + let nonzero = point_in_path(p.staff, &rings, FillRule::NonZero); + eprintln!( + "mutation (b) {name}: hole point {:?}: even_odd_filled={} nonzero_filled={} \ + reclassifies_under_nonzero={}", + p.staff, + even_odd, + nonzero, + even_odd != nonzero + ); + assert!( + !even_odd, + "sanity: a selected background point must be even-odd-unfilled" + ); + assert_eq!( + even_odd, nonzero, + "kill evidence FAILED: {name} hole point {:?} disagrees between even-odd \ + ({even_odd}) and nonzero ({nonzero}) — the fill-rule equivalence this \ + oracle locks does not hold", + p.staff + ); + } + } + } + + /// (b, supplement) **Empirical finding from mutation (b) above:** on + /// Bravura's actual extracted outline data, every hole point on every + /// bounded-hole glyph shows `reclassifies_under_nonzero=false` — even-odd + /// and nonzero *agree* that the hole is unfilled. That is the signature + /// of a well-formed font: the outer contour and its hole wind in + /// **opposite** directions, so `winding = (+1) + (-1) = 0` (nonzero: + /// excluded) at exactly the points where the even-odd crossing count is + /// 2 (even: excluded). Mutation (b) as literally "flip even-odd to + /// nonzero" therefore does not, for this real data, demonstrate that + /// fill-*rule* choice matters — it demonstrates the opposite: Bravura's + /// paths render identically under either rule, which is real, useful + /// information (recorded in `ring_signed_areas` and the derivation + /// report), not a failure of the check. + /// + /// What the check was actually trying to prove — "the holes are real + /// holes, not an artifact of only looking at the outer silhouette" — + /// still needs positive kill evidence, so this test supplies it a + /// different way: every selected background point is, by construction + /// (`hole_evidence`), inside the **outer-contour-only** fill (what a + /// renderer that ignored inner subpaths entirely would paint as ink) + /// while being outside the **whole-outline** even-odd fill. That + /// contrast — not the even-odd/nonzero rule choice — is the mutation + /// that actually reclassifies these points, and it is asserted here + /// directly rather than left implicit in `hole_evidence`. + #[test] + fn mutation_b_supplement_hole_points_reclassify_under_naive_outer_only_fill() { + let catalog = BravuraGlyphCatalog; + let mut any_hole_point_checked = false; + for (name, expected) in HOLE_GLYPHS { + let data = catalog.render_data(name).unwrap(); + let rings = flatten_outline(&data.outline); + let oracle = derive_glyph_oracle( + name, + &data.outline, + Some(*expected), + Requirement::BoundedHole, + ) + .unwrap(); + if !oracle.background_satisfied { + continue; + } + let outer_idx = oracle.outer_contour_ring_index; + let outer_only = std::slice::from_ref(&rings[outer_idx]); + for p in oracle + .points + .iter() + .filter(|p| p.class == SampleClass::Background) + { + any_hole_point_checked = true; + let naive_outer_fill = point_in_path(p.staff, outer_only, FillRule::EvenOdd); + let real_fill = point_in_path(p.staff, &rings, FillRule::EvenOdd); + eprintln!( + "mutation (b, supplement) {name}: hole point {:?}: \ + naive_outer_only_fill={naive_outer_fill} real_whole_outline_fill={real_fill}", + p.staff + ); + assert!( + naive_outer_fill, + "kill evidence FAILED: {name} hole point {:?} was not even inside the outer \ + silhouette — it cannot be a hole", + p.staff + ); + assert!( + !real_fill, + "kill evidence FAILED: {name} hole point {:?} is filled by the real, \ + whole-outline even-odd test — it is not a hole", + p.staff + ); + assert_ne!( + naive_outer_fill, real_fill, + "kill evidence FAILED: {name} hole point {:?} classifies identically under \ + naive outer-only fill and the real whole-outline fill — subpath handling \ + is not load-bearing for this point", + p.staff + ); + } + } + assert!( + any_hole_point_checked, + "no glyph had a satisfied background set to check — mutation (b, supplement) \ + validated nothing" + ); + } + + /// (c) Shrinking the clearance threshold must never *decrease* the + /// admissible candidate count, and for at least one glyph must strictly + /// increase it — proving the clearance filter is actually filtering, + /// not a no-op. Runs over every Round-1 glyph in either check class. + #[test] + fn mutation_c_shrinking_clearance_threshold_admits_more_points() { + fn count_admissible(rings: &[Ring], transform: &Transform, floor_px: f64) -> usize { + let [min_x, min_y, max_x, max_y] = bounding_box(rings); + let mut n = 0usize; + let mut y = min_y; + while y <= max_y { + let mut x = min_x; + while x <= max_x { + let d = transform.device_distance(min_distance_to_outline((x, y), rings)); + if d >= floor_px { + n += 1; + } + x += GRID_STEP_STAFF; + } + y += GRID_STEP_STAFF; + } + n + } + + let catalog = BravuraGlyphCatalog; + let mut any_strict_increase = false; + for (name, _) in all_glyphs() { + let data = catalog.render_data(name).unwrap(); + let rings = flatten_outline(&data.outline); + let bbox = bounding_box(&rings); + let transform = Transform::centering(bbox); + + let at_8px = count_admissible(&rings, &transform, CLEARANCE_MIN_DEVICE_PX); + let at_2px = count_admissible(&rings, &transform, 2.0); + eprintln!( + "mutation (c) {name}: admissible grid points at 8px clearance = {at_8px}, \ + at 2px clearance = {at_2px}" + ); + assert!( + at_2px >= at_8px, + "kill evidence FAILED: shrinking the threshold from 8px to 2px admitted fewer \ + points ({at_2px} < {at_8px}) for {name} — the clearance filter is backwards" + ); + if at_2px > at_8px { + any_strict_increase = true; + } + } + assert!( + any_strict_increase, + "kill evidence FAILED: shrinking the clearance threshold changed nothing for any \ + glyph — the clearance filter may be a no-op" + ); + } + + /// (d) The disjoint-component analogue of mutation (b, supplement): a + /// tessellator that keeps only the **largest** contour (fClef's bowl, + /// ring 0) must fail to contain either dot's ink point. This is the + /// literal scenario Round 1 names as the reason the disjoint-component + /// check exists: "A tessellator that keeps only the largest contour + /// fails here and would pass every hole check." Positive kill evidence: + /// the largest-contour-only fill must NOT contain fClef's dot points, + /// while the real whole-outline fill (all three subpaths) does. + #[test] + fn mutation_d_largest_contour_only_fill_misses_the_dot_points() { + let catalog = BravuraGlyphCatalog; + let data = catalog.render_data("fClef").unwrap(); + let rings = flatten_outline(&data.outline); + let oracle = derive_glyph_oracle( + "fClef", + &data.outline, + Some(3), + Requirement::DisjointComponents, + ) + .unwrap(); + + let largest_idx = outer_contour_index(&rings); + let largest_only = std::slice::from_ref(&rings[largest_idx]); + + let dot_points: Vec<_> = oracle + .points + .iter() + .filter(|p| p.subpath_index != Some(largest_idx)) + .collect(); + assert_eq!( + dot_points.len(), + 2, + "fClef must have exactly 2 non-largest-subpath (dot) ink points to run this mutation" + ); + + for p in &dot_points { + let real_fill = point_in_path(p.staff, &rings, FillRule::EvenOdd); + let largest_only_fill = point_in_path(p.staff, largest_only, FillRule::EvenOdd); + eprintln!( + "mutation (d) fClef: dot point {:?} (subpath {:?}): real_fill={real_fill} \ + largest_contour_only_fill={largest_only_fill}", + p.staff, p.subpath_index + ); + assert!( + real_fill, + "sanity: a selected fClef ink point must be filled by the real, whole-outline fill" + ); + assert!( + !largest_only_fill, + "kill evidence FAILED: {:?} (subpath {:?}) is filled even when only the largest \ + contour is kept — a largest-contour-only tessellator would wrongly pass this \ + point, defeating the disjoint-component check", + p.staff, p.subpath_index + ); + } + } +} + +#[cfg(test)] +mod finding_corroboration { + use super::*; + use epiphany_glyphs::BravuraGlyphCatalog; + use epiphany_layout_ir::GlyphCatalog; + + /// **What this test actually checks** (named precisely, not "at any + /// resolution"): independent corroboration of the "fClef has no bounded + /// hole" finding at **one finer grid** (0.005 staff-space step, half the + /// main search's 0.01) and with **no clearance floor applied at all** — + /// so a hole too small to hold an 8px-clear point, which the main search + /// would also report as zero, is told apart from a hole that genuinely + /// does not exist. Both report zero here, confirming fClef's main body + /// (a solid F-clef bowl) plus its two separate dot subpaths are three + /// disjoint ink islands, not an outer-plus-hole nesting. + /// + /// This test is grid-based and therefore resolution-*dependent*, whatever + /// its name once claimed; the resolution-*independent* proof of the same + /// property is `fclef_subpaths_are_topologically_non_nested`, below, + /// which is what actually justifies the stronger "at any resolution" + /// claim. + #[test] + fn fclef_has_zero_bounded_hole_grid_hits_at_a_finer_0_005_grid() { + let catalog = BravuraGlyphCatalog; + let data = catalog.render_data("fClef").unwrap(); + let rings = flatten_outline(&data.outline); + eprintln!("fClef subpaths: {}", rings.len()); + for (i, r) in rings.iter().enumerate() { + eprintln!( + " ring {i}: {} verts, signed_area={:.6}", + r.len(), + signed_area(r) + ); + } + let outer_idx = outer_contour_index(&rings); + eprintln!("outer contour idx: {outer_idx}"); + let outer_ring = std::slice::from_ref(&rings[outer_idx]); + let bbox = bounding_box(&rings); + eprintln!("bbox: {:?}", bbox); + + let mut best_clearance = f64::NEG_INFINITY; + let mut best_pt = (0.0, 0.0); + let mut hole_pixel_count = 0u64; + let step = 0.005; + let mut y = bbox[1]; + while y <= bbox[3] { + let mut x = bbox[0]; + while x <= bbox[2] { + let p = (x, y); + let filled = point_in_path(p, &rings, FillRule::EvenOdd); + if !filled { + let inside_outer = point_in_path(p, outer_ring, FillRule::EvenOdd); + if inside_outer { + hole_pixel_count += 1; + let clearance = min_distance_to_outline(p, &rings); + if clearance > best_clearance { + best_clearance = clearance; + best_pt = p; + } + } + } + x += step; + } + y += step; + } + eprintln!( + "fClef: hole-region grid hits (no clearance floor, step={step}) = \ + {hole_pixel_count}, best clearance = {:.6} staff ({:.3} device px) at {:?}", + best_clearance, + best_clearance * DEVICE_PX_PER_STAFF_SPACE, + best_pt + ); + assert_eq!( + hole_pixel_count, 0, + "fClef: corroboration expected zero bounded-hole grid hits at this finer grid; if \ + this now finds hits, the finding above is stale and must be re-checked" + ); + } + + /// The resolution-**independent** proof: fClef's three subpaths are + /// mutually non-nested (no subpath has any vertex lying inside another + /// subpath). Unlike the grid-based corroboration above, this is a + /// property of the flattened polygon itself, not of any sampling step — + /// it is what actually justifies calling fClef's disjoint-component + /// design true "at any resolution", per the review finding that the + /// grid-based test alone overclaimed that phrase. + #[test] + fn fclef_subpaths_are_topologically_non_nested() { + let catalog = BravuraGlyphCatalog; + let data = catalog.render_data("fClef").unwrap(); + let rings = flatten_outline(&data.outline); + let nesting = subpaths_are_mutually_non_nested(&rings); + eprintln!("fClef topological non-nesting check: {:?}", nesting); + assert!( + nesting.is_none(), + "fClef subpaths are topologically nested ({:?}) — the disjoint-component design \ + assumption (bowl plus two solid, disjoint dots) is false for this outline data", + nesting + ); + + // Vertex containment alone does not prove disjointness: two rings whose + // EDGES cross, with no vertex of either inside the other, would pass the + // check above. Pairwise-disjoint bounding boxes rule out crossing, + // touching, and containment at once, which upgrades this from a partial + // argument to a complete one. + let overlap = subpath_bounding_boxes_are_pairwise_disjoint(&rings); + eprintln!("fClef pairwise bbox disjointness: {:?}", overlap); + assert!( + overlap.is_none(), + "fClef subpaths {:?} have overlapping bounding boxes — non-nesting by vertex \ + containment is then not sufficient to prove disjointness, and this glyph's \ + disjoint-component design would need a segment-intersection proof instead", + overlap + ); + } + + /// Independent corroboration that `noteheadHalf` — new to Round 1 — + /// really does carry a bounded hole, cross-checked against the + /// contract's recorded measurement (`noteheadHalf [0.903, -0.368]`): + /// two subpaths, opposite-signed areas. + #[test] + fn noteheadhalf_ring_signed_areas_match_the_contracts_recorded_measurement() { + let catalog = BravuraGlyphCatalog; + let data = catalog.render_data("noteheadHalf").unwrap(); + let rings = flatten_outline(&data.outline); + let areas: Vec = rings.iter().map(signed_area).collect(); + eprintln!("noteheadHalf ring_signed_areas = {:?}", areas); + assert_eq!(areas.len(), 2, "noteheadHalf: expected 2 subpaths"); + let expected = [0.903, -0.368]; + for (a, e) in areas.iter().zip(expected.iter()) { + assert!( + (a - e).abs() < 0.01, + "noteheadHalf: measured signed area {a} does not match the contract's recorded \ + {e} within tolerance" + ); + } + } +} diff --git a/spikes/editor-toolkit/round1-oracle/src/main.rs b/spikes/editor-toolkit/round1-oracle/src/main.rs new file mode 100644 index 0000000..58ff693 --- /dev/null +++ b/spikes/editor-toolkit/round1-oracle/src/main.rs @@ -0,0 +1,314 @@ +//! Generates the committed Round-1 oracle: `oracle.json` (machine-readable, +//! consumed by candidate runs in a later packet) and `ORACLE_SUMMARY.md` +//! (human-readable, for the user's pin-13 review). Run with `cargo run -p +//! round1-oracle` from `spikes/editor-toolkit/round1-oracle/`. + +use epiphany_glyphs::BravuraGlyphCatalog; +use epiphany_layout_ir::GlyphCatalog; +use round1_oracle::{derive_glyph_oracle, GlyphOracle, Requirement, SampleClass}; +use serde::Serialize; +use std::fmt::Write as _; +use std::fs; + +/// Round 1's bounded-hole check class: "≥3 must-be-ink and ≥3 must-be- +/// background points, each inside a bounded hole". +const HOLE_GLYPHS: &[(&str, usize)] = &[ + ("gClef", 4), + ("timeSig8", 3), + ("accidentalFlat", 2), + ("noteheadHalf", 2), +]; + +/// Round 1's disjoint-component check class: "no bounded hole ... ≥1 ink +/// point inside each of its three filled subpaths". +const DISJOINT_GLYPHS: &[(&str, usize)] = &[("fClef", 3)]; + +#[derive(Serialize)] +struct OracleFile { + contract: &'static str, + round: &'static str, + render_transform_rule: &'static str, + flatten_tolerance_staff_space: f64, + clearance_floor_device_px: f64, + glyphs: Vec, +} + +fn main() { + let catalog = BravuraGlyphCatalog; + let mut glyphs = Vec::new(); + let mut findings = Vec::new(); + + for (name, expected_subpaths) in HOLE_GLYPHS { + let data = catalog + .render_data(name) + .unwrap_or_else(|| panic!("{name}: BravuraGlyphCatalog has no render data")); + match derive_glyph_oracle( + name, + &data.outline, + Some(*expected_subpaths), + Requirement::BoundedHole, + ) { + Ok(oracle) => { + if oracle.subpath_count != *expected_subpaths { + findings.push(format!( + "{name}: parsed {} subpaths, contract's verified-starting-point section \ + records {}", + oracle.subpath_count, expected_subpaths + )); + } + if !oracle.satisfied { + let d = &oracle.hole_diagnostic; + let detail = if d.raw_hole_grid_hits == 0 { + "this glyph has NO bounded hole at all under even-odd fill — every \ + subpath is either the outer silhouette or a disjoint ink island, none \ + nested inside another" + .to_string() + } else { + format!( + "this glyph has a bounded hole ({} grid hits) but the best \ + achievable clearance in it is {:.3} device px, below the \ + {}px floor", + d.raw_hole_grid_hits, + d.best_hole_clearance_device_px.unwrap_or(f64::NAN), + round1_oracle::CLEARANCE_MIN_DEVICE_PX + ) + }; + findings.push(format!( + "{name}: fewer than {} must-be-background sample points could be \ + derived — {detail}. Per Round 1's hole-check clause ('each inside a \ + bounded hole ... asserted by the derivation, not assumed'), no fallback \ + point was substituted; {name}'s oracle carries ink points only and is \ + NOT satisfied.", + round1_oracle::POINTS_PER_CLASS + )); + } + glyphs.push(oracle); + } + Err(e) => findings.push(e), + } + } + + for (name, expected_subpaths) in DISJOINT_GLYPHS { + let data = catalog + .render_data(name) + .unwrap_or_else(|| panic!("{name}: BravuraGlyphCatalog has no render data")); + match derive_glyph_oracle( + name, + &data.outline, + Some(*expected_subpaths), + Requirement::DisjointComponents, + ) { + Ok(oracle) => { + if oracle.subpath_count != *expected_subpaths { + findings.push(format!( + "{name}: parsed {} subpaths, contract's verified-starting-point section \ + records {}", + oracle.subpath_count, expected_subpaths + )); + } + // `oracle.satisfied` is expected true here (no bounded-hole + // shortfall is possible for this class — see + // `derive_glyph_oracle`'s DisjointComponents arm, which only + // returns Ok once every subpath is covered), but this is + // reported as a finding rather than assumed, matching every + // other status check in this file. + if !oracle.satisfied { + findings.push(format!( + "{name}: disjoint-component coverage requirement not met — see \ + subpath_coverage_satisfied in oracle.json" + )); + } + glyphs.push(oracle); + } + Err(e) => findings.push(e), + } + } + + let oracle_file = OracleFile { + contract: "spec/CONTRACT_EDITOR_T4_SPIKE.md Round 1 (criterion 1, compound-path fill \ + correctness)", + round: "round1", + render_transform_rule: "device = (staff.x * scale + tx, ty - staff.y * scale); scale = \ + 100 device px per staff space; tx/ty center the glyph's own flattened bounding box \ + in a 1920x1080 target (pin 4); staff-space is y-up, device space is y-down", + flatten_tolerance_staff_space: round1_oracle::FLATTEN_TOLERANCE, + clearance_floor_device_px: round1_oracle::CLEARANCE_MIN_DEVICE_PX, + glyphs, + }; + + let json = serde_json::to_string_pretty(&oracle_file).expect("oracle serializes"); + fs::write("oracle.json", &json).expect("write oracle.json"); + + let summary = render_summary(&oracle_file, &findings); + fs::write("ORACLE_SUMMARY.md", &summary).expect("write ORACLE_SUMMARY.md"); + + println!("wrote oracle.json ({} bytes)", json.len()); + println!("wrote ORACLE_SUMMARY.md"); + if !findings.is_empty() { + println!("\nFINDINGS:"); + for f in &findings { + println!(" - {f}"); + } + } +} + +fn render_summary(oracle: &OracleFile, findings: &[String]) -> String { + let mut s = String::new(); + writeln!( + s, + "# Round 1 precommitted oracle — compound-path fill correctness\n" + ) + .unwrap(); + writeln!( + s, + "Generated by `round1-oracle` (`spikes/editor-toolkit/round1-oracle`). Governed by \ + `spec/CONTRACT_EDITOR_T4_SPIKE.md` Revision 6, Round 1 (\"criterion 1, compound-path \ + fill correctness\"). No rendering crate was used to produce this file — every point \ + below is derived from the typed `PathCommand` outline alone.\n\n\ + Two check classes, testing two different properties: **bounded-hole** (`gClef`, \ + `timeSig8`, `accidentalFlat`, `noteheadHalf`) requires ≥3 ink and ≥3 must-be-background \ + points inside a bounded hole; **disjoint-component** (`fClef`) has no bounded hole by \ + design and instead requires ≥1 ink point inside each of its three filled subpaths, \ + tagged with `subpath_index`.\n" + ) + .unwrap(); + + writeln!(s, "## Render transform (pinned)\n").unwrap(); + writeln!( + s, + "`{}`\n\n- Flatten tolerance: `{}` staff-space units (Bezier chord deviation).\n\ + - Clearance floor: `{}` device px.\n", + oracle.render_transform_rule, + oracle.flatten_tolerance_staff_space, + oracle.clearance_floor_device_px + ) + .unwrap(); + + writeln!(s, "## Overall status\n").unwrap(); + writeln!(s, "| glyph | requirement | satisfied | ring_signed_areas |").unwrap(); + writeln!(s, "|---|---|---|---|").unwrap(); + for g in &oracle.glyphs { + let requirement = match g.requirement { + Requirement::BoundedHole => "BoundedHole", + Requirement::DisjointComponents => "DisjointComponents", + }; + let areas = g + .ring_signed_areas + .iter() + .map(|a| format!("{a:.3}")) + .collect::>() + .join(", "); + writeln!( + s, + "| `{}` | {} | **{}** | [{}] |", + g.name, requirement, g.satisfied, areas + ) + .unwrap(); + } + writeln!( + s, + "\n`fClef` is `satisfied = true` with zero background points — that is the correct, \ + designed-for outcome for its `DisjointComponents` requirement class, not a shortfall. \ + The `satisfied` column above is the field to read; `background_satisfied` alone would \ + make that correct outcome indistinguishable from a bounded-hole glyph's genuine failure.\n" + ) + .unwrap(); + + if !findings.is_empty() { + writeln!(s, "## Findings\n").unwrap(); + for f in findings { + writeln!(s, "- **{f}**").unwrap(); + } + writeln!(s).unwrap(); + } + + for g in &oracle.glyphs { + writeln!(s, "## `{}`\n", g.name).unwrap(); + let requirement = match g.requirement { + Requirement::BoundedHole => "BoundedHole (≥3 ink, ≥3 background inside a bounded hole)", + Requirement::DisjointComponents => { + "DisjointComponents (no background requirement; ≥1 ink point per filled subpath)" + } + }; + let subpath_note = match g.expected_subpath_count { + Some(exp) if exp == g.subpath_count => format!("matches recorded {exp}"), + Some(exp) => format!("**MISMATCH** — recorded {exp}, parsed {}", g.subpath_count), + None => "no recorded expectation".to_string(), + }; + let areas = g + .ring_signed_areas + .iter() + .map(|a| format!("{a:.3}")) + .collect::>() + .join(", "); + writeln!( + s, + "- Requirement class: {requirement}\n\ + - Overall status: **satisfied = {}**\n\ + - Subpaths: {} ({subpath_note})\n\ + - Ring signed areas (staff-space², shoelace, ring order): `[{areas}]`\n\ + - Bounding box (staff space): `{:?}`\n\ + - Outer contour ring index: {}\n\ + - Transform: scale={}, tx={:.3}, ty={:.3}, target={}x{}\n\ + - background_required={}, background_satisfied={}\n\ + - subpath_coverage_required={}, subpath_coverage_satisfied={}\n\ + - Ink candidates found (>= {}px clearance): {}\n\ + - Background/hole candidates found (>= {}px clearance): {}\n\ + - Ink point spacing relaxed: {}\n\ + - Background point spacing relaxed: {}\n", + g.satisfied, + g.subpath_count, + g.bbox_staff, + g.outer_contour_ring_index, + g.transform.scale, + g.transform.tx, + g.transform.ty, + g.transform.target_width as i32, + g.transform.target_height as i32, + g.background_required, + g.background_satisfied, + g.subpath_coverage_required, + g.subpath_coverage_satisfied, + oracle.clearance_floor_device_px, + g.ink_candidates_found, + oracle.clearance_floor_device_px, + g.background_candidates_found, + g.ink_spacing_relaxed, + g.background_spacing_relaxed, + ) + .unwrap(); + + writeln!( + s, + "| class | subpath_index | staff (x, y) | device (x, y) | clearance (device px) | hole evidence |" + ) + .unwrap(); + writeln!(s, "|---|---|---|---|---|---|").unwrap(); + for p in &g.points { + let class = match p.class { + SampleClass::Ink => "Ink", + SampleClass::Background => "Background", + }; + let subpath = match p.subpath_index { + Some(i) => i.to_string(), + None => "-".to_string(), + }; + let evidence = match &p.hole_evidence { + Some(ev) => format!( + "inside_outer_contour={}, even_odd_filled={}, nonzero_filled={} (outer ring {})", + ev.inside_outer_contour, ev.even_odd_filled, ev.nonzero_filled, ev.outer_contour_ring_index + ), + None => "-".to_string(), + }; + writeln!( + s, + "| {class} | {subpath} | ({:.4}, {:.4}) | ({:.2}, {:.2}) | {:.3} | {evidence} |", + p.staff.0, p.staff.1, p.device.0, p.device.1, p.clearance_device_px + ) + .unwrap(); + } + writeln!(s).unwrap(); + } + + s +}