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 +}