From 7d6127106914a61ff4166c79a33ac38b17458c7b Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Wed, 8 Jul 2026 17:28:28 -0400 Subject: [PATCH] Push 3 (slur quality) 3/3: measure slur_shape_penalty (off the 0.0 placeholder) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slur_shape quality axis was pinned at 0.0 (vacuous, then "by construction"). It now MEASURES, per the Quality Metric Catalog (req:qmc:slur): each drawn slur Curve's arc ratio ρ = apex height / chord length is penalized by its distance outside the shallow-arc band [0.08, 0.25] (max(0, 0.08-ρ, ρ-0.25)), meaned over curves, normalized by R_worst=0.25. Apex is the max perpendicular distance from the sampled cubic (32 points) to its endpoint chord; translation-invariant, so the post-cast curves are measured directly. Honest outcome: the Minimal tier's mid-span slurs sit at ρ = SLUR_HEIGHT_FACTOR = 0.16 (in band → 0), but the fixed min/max height clamps push short slurs above the band (bulgy) and very long ones below it (flat) — a genuine non-zero value a duration-aware Standard-tier height would improve. A curve-free layout measures 0 by the vacuous-geometry rule. No ENGRAVER_VERSION bump — measurement-only, resolved geometry / canonical bytes / render goldens untouched (quality-decision-8 rule); no RS entry carries a slur so the reference suite is unaffected. Test: an adjacent-event slur is penalized (>0), a wide-span slur is in-band (0). 935 tests, 8/8. Co-Authored-By: Claude Opus 4.8 (1M context) --- crates/epiphany-engrave/DECISIONS.md | 29 ++++- crates/epiphany-engrave/src/lib.rs | 7 +- crates/epiphany-engrave/src/quality.rs | 147 +++++++++++++++++++++---- 3 files changed, 156 insertions(+), 27 deletions(-) diff --git a/crates/epiphany-engrave/DECISIONS.md b/crates/epiphany-engrave/DECISIONS.md index f41fb41..2da8cb0 100644 --- a/crates/epiphany-engrave/DECISIONS.md +++ b/crates/epiphany-engrave/DECISIONS.md @@ -465,10 +465,8 @@ a `curve_count=0` line (a new tracked primitive kind), and the new spanning a system break into per-system sub-cubics by de Casteljau. A curve's control-point hull grows its system's extent, so a slur above the staff raises the system height for page overflow, like a volta bracket. -- `slur_shape_penalty` stops being *vacuous* 0.0 and becomes *0.0 by - construction*: the Minimal tier draws the ideal arc (or the authored - override), so a drawn slur has zero shape deviation. A real penalty awaits a - collision-aware Standard-tier solver that compromises a slur's shape. +- `slur_shape_penalty` stopped being *vacuous* 0.0 and became *0.0 by + construction* at E2; Push 3 (below) makes it a **real measurement**. ## ENGRAVER_VERSION 6 → 7: curve splitting across systems (Push 3, 2026-07-08) @@ -486,3 +484,26 @@ riding its start system whole. `ENGRAVER_VERSION` 6 → 7 — but only a break-spanning slur's baked geometry changes; a slur that fits in one system is `CurveFate::Rigid`, byte-identical to version 6, so the existing goldens are unchanged (the fixture's slurs are short). + +## slur_shape_penalty measured, not pinned (Push 3, 2026-07-08) + +The `slur_shape` quality axis moved off its `0.0` placeholder to a real +measurement per the Quality Metric Catalog (`req:qmc:slur`): for each drawn +slur `Curve` with chord `c > 0` (the segment between its endpoints) and apex +height `h` (max perpendicular distance from the curve to that chord, sampled at +`SLUR_APEX_SAMPLES = 32` points), the arc ratio `ρ = h/c` is penalized by its +distance outside the shallow-arc band `[0.08, 0.25]` +(`max(0, 0.08 − ρ, ρ − 0.25)`), meaned over curves and normalized by +`R_worst = 0.25`. The measurement is translation-invariant (chord and apex +shift together under casting), so measuring the post-cast curves is correct. + +Honest outcome: the Minimal tier's mid-span slurs sit at `ρ = height/span = +0.16` (the `SLUR_HEIGHT_FACTOR`; in band → 0), but the fixed +`SLUR_MIN_HEIGHT`/`SLUR_MAX_HEIGHT` clamps push a **short** slur above the band +(a tall arc on a tiny chord, too bulgy) and a **very long** one below it (too +flat) — so the axis is genuinely non-zero for clamped spans, which a +duration-aware Standard-tier height would improve. A curve-free layout measures +0 by the vacuous-geometry rule. **No `ENGRAVER_VERSION` bump** — a +measurement-only change that leaves the resolved geometry, canonical bytes, and +render goldens untouched (the same rule quality decision 8 followed); the RS +reference suite is unaffected (no RS entry carries a slur). diff --git a/crates/epiphany-engrave/src/lib.rs b/crates/epiphany-engrave/src/lib.rs index 084839c..13e9fbb 100644 --- a/crates/epiphany-engrave/src/lib.rs +++ b/crates/epiphany-engrave/src/lib.rs @@ -51,10 +51,9 @@ //! the private `quality` module computes all nine normative axes per the //! ratified *Quality Metric Catalog* companion (collision census, spacing //! regularity, break/page/casting-off distribution, vertical gap deviation; -//! `slur_shape` is `0.0` **by construction** now that slurs draw — the Minimal -//! tier emits the ideal arc, so a drawn slur has zero shape deviation — while -//! `beam_slope` stays vacuous-`0.0` because no drawn beam geometry exists yet), -//! normalized through the catalog's pinned anchors +//! `slur_shape` is now **measured** — each drawn slur's arc ratio against the +//! shallow-arc band — while `beam_slope` stays vacuous-`0.0` because no drawn +//! beam geometry exists yet), normalized through the catalog's pinned anchors //! ([`epiphany_layout_ir::quality`]), with //! [`SolverWarningKind::QualityFloorApproached`] diagnostics against the //! threshold column the config's profile selects. The all-worst diff --git a/crates/epiphany-engrave/src/quality.rs b/crates/epiphany-engrave/src/quality.rs index a29f9bd..b528a84 100644 --- a/crates/epiphany-engrave/src/quality.rs +++ b/crates/epiphany-engrave/src/quality.rs @@ -26,12 +26,13 @@ //! reference). The clef/key/time lead and barlines bear no notehead or rest, //! so they contribute no column and a note-to-note advance spans them //! (catalog §`spacing_distortion` — measuring rhythmic spacing, not furniture). -//! * **`slur_shape_penalty`** — **0.0 by construction** (E2): slurs now draw as -//! cubic-bézier curves, but the Minimal tier emits the *reference* arc from -//! the span and the authored/default curvature, so a drawn slur has zero -//! deviation from its ideal (an authored `curvature_override` is honored, not -//! penalized). A real penalty awaits a Standard-tier solver that compromises -//! a slur's shape to dodge collisions. **`beam_slope_penalty`** stays +//! * **`slur_shape_penalty`** — **measured** (Push 3): each drawn slur curve's +//! arc ratio `ρ = apex height / chord length` is penalized by its distance +//! outside the shallow-arc band `[0.08, 0.25]` (catalog §`slur_shape`). The +//! Minimal tier's mid-span slurs sit at `ρ ≈ 0.16` (in band, 0), but its +//! fixed height clamps push short slurs above the band (too bulgy) and very +//! long ones below it (too flat) — a real non-zero value. A curve-free layout +//! measures 0 by the vacuous-geometry rule. **`beam_slope_penalty`** stays //! **vacuous 0.0**: no beam geometry is drawn yet (beams exist logically, not //! as segments), so its contributing-unit set is empty. //! * **`vertical_density_penalty`** — realized gaps against the band model's @@ -92,6 +93,75 @@ fn mean_or_zero(values: &[f64]) -> f64 { } } +/// How many points a slur curve is sampled at to find its apex height. Even, so +/// a sample lands on the symmetric arc's `t = 0.5` apex exactly. +const SLUR_APEX_SAMPLES: usize = 32; + +/// The raw `slur_shape_penalty` measurement (Quality Metric Catalog +/// §`slur_shape_penalty`): for each drawn slur curve with chord length `c > 0` +/// — the chord being the segment between the curve's endpoints and the apex +/// height `h` the maximum perpendicular distance from the curve to that chord — +/// the arc ratio `ρ = h / c` is penalized by its distance outside the shallow- +/// arc band `[0.08, 0.25]` (`max(0, 0.08 − ρ, ρ − 0.25)`); the axis raw value +/// is the arithmetic mean over units. A curve-free layout has no units, so the +/// mean is `0` (the vacuous-geometry rule) — not by construction but by +/// measurement. +fn slur_shape_raw(cast: &CastLayout) -> f64 { + let per_curve: Vec = cast + .curves + .iter() + .filter_map(|curve| { + let cp = curve.control_points(); + let (a, b) = (point(cp[0]), point(cp[3])); + let chord = ((b.0 - a.0).powi(2) + (b.1 - a.1).powi(2)).sqrt(); + if chord <= 0.0 { + return None; // c > 0 required + } + let apex = (0..=SLUR_APEX_SAMPLES) + .map(|i| { + let t = i as f32 / SLUR_APEX_SAMPLES as f32; + perp_distance(a, b, cubic_point(cp, t)) + }) + .fold(0.0_f64, f64::max); + let rho = apex / chord; + Some((0.08 - rho).max(rho - 0.25).max(0.0)) + }) + .collect(); + mean_or_zero(&per_curve) +} + +/// A layout point as exact `f64`. +fn point(p: epiphany_layout_ir::Point) -> (f64, f64) { + (f64::from(p.x.0), f64::from(p.y.0)) +} + +/// The cubic-Bézier point at parameter `t`, as `f64`. +fn cubic_point(cp: [epiphany_layout_ir::Point; 4], t: f32) -> (f64, f64) { + let (u, t) = (f64::from(1.0 - t), f64::from(t)); + let w = [u * u * u, 3.0 * u * u * t, 3.0 * u * t * t, t * t * t]; + ( + w[0] * f64::from(cp[0].x.0) + + w[1] * f64::from(cp[1].x.0) + + w[2] * f64::from(cp[2].x.0) + + w[3] * f64::from(cp[3].x.0), + w[0] * f64::from(cp[0].y.0) + + w[1] * f64::from(cp[1].y.0) + + w[2] * f64::from(cp[2].y.0) + + w[3] * f64::from(cp[3].y.0), + ) +} + +/// Perpendicular distance from point `p` to the line through `a` and `b` +/// (0 when `a == b`). +fn perp_distance(a: (f64, f64), b: (f64, f64), p: (f64, f64)) -> f64 { + let (dx, dy) = (b.0 - a.0, b.1 - a.1); + let len = (dx * dx + dy * dy).sqrt(); + if len <= 0.0 { + return 0.0; + } + ((p.0 - a.0) * dy - (p.1 - a.1) * dx).abs() / len +} + /// One glyph's resolved ink box `[left, bottom, right, top]` (f64, exact from /// the f32 geometry). fn ink_box(cast: &CastLayout, input: &ConstrainedLayoutIR, index: usize) -> [f64; 4] { @@ -459,16 +529,15 @@ pub(crate) fn measure( anchors::COLLISION_R_WORST, ), spacing_distortion: normalize(spacing_raw(&census), anchors::SPACING_R_WORST), - // Slurs now DRAW (E2: a cubic-bézier `Curve` per slur), but their shape - // is measured as ideal by construction: the Minimal tier emits the - // reference arc itself — a symmetric cubic from the span and the - // authored (or default) curvature — so a drawn slur has zero deviation - // from its ideal, and an authored `curvature_override` is honored, not - // penalized. A real non-zero penalty awaits a collision-aware - // (Standard-tier, Push 3) solver that *compromises* a slur's shape to - // dodge collisions; until then this axis stays 0.0 by construction, not - // by vacuity. - slur_shape_penalty: normalize(0.0, anchors::SLUR_SHAPE_R_WORST), + // Slurs draw (E2), so their shape is now MEASURED (Push 3), not pinned: + // each drawn curve's arc ratio ρ = apex height / chord length is + // penalized by its distance outside the shallow-arc band [0.08, 0.25]. + // The Minimal tier's mid-span slurs sit at ρ ≈ 0.16 (in band, 0 + // penalty), but its fixed height clamps push short slurs above the band + // (too bulgy) and very long ones below it (too flat) — a real, honest + // non-zero measurement. A curve-free layout measures 0 by the + // vacuous-geometry rule. + slur_shape_penalty: normalize(slur_shape_raw(cast), anchors::SLUR_SHAPE_R_WORST), // Same vacuous rule: no drawn beam segments exist in this pipeline. beam_slope_penalty: normalize(0.0, anchors::BEAM_SLOPE_R_WORST), vertical_density_penalty: normalize( @@ -584,9 +653,9 @@ mod tests { let vector = &report.metric_vector; assert_eq!(vector.collision_penalty.0, 0.0); assert!(vector.spacing_distortion.0 > 0.0 && vector.spacing_distortion.0 < 0.3); - // The ten-measure fixture has no slur; even one would measure 0.0 - // (Minimal draws the ideal arc — deviation is 0 by construction). - assert_eq!(vector.slur_shape_penalty.0, 0.0, "ideal-by-construction"); + // The ten-measure fixture has no drawn slur curve, so the axis measures + // 0.0 by the vacuous-geometry rule (an empty contributing-unit set). + assert_eq!(vector.slur_shape_penalty.0, 0.0, "vacuous: no drawn slurs"); assert_eq!(vector.beam_slope_penalty.0, 0.0, "vacuous: no drawn beams"); assert_eq!(vector.page_fill_efficiency.0, 0.0, "vacuous: single page"); assert!( @@ -618,6 +687,46 @@ mod tests { assert_eq!(report.status, SolveStatus::Solved); } + #[test] + fn slur_shape_is_measured_penalizing_out_of_band_arcs() { + use epiphany_core::{Slur, SlurId, SlurKind, SpanStyle}; + use epiphany_layout_ir::to_constrained; + + let with_slur = |start: usize, end: usize| { + let mut s = epiphany_testkit::fixtures::ten_measure_single_staff(0x000A_11CE); + let ev: Vec<_> = s.canvas.regions[0].staff_instances()[0].voices[0] + .events + .clone(); + s.cross_cutting.slurs.push(Slur { + id: s.identity.mint::(), + start_event: ev[start], + end_event: ev[end], + kind: SlurKind::Legato, + curvature_override: None, + style: SpanStyle::default(), + }); + Engraver::default() + .solve(&to_constrained(&to_logical(&s)), &SolverConfig::default()) + .metric_vector + .slur_shape_penalty + .0 + }; + // A slur over adjacent events: the min-height clamp forces a tall arc + // over a tiny chord (ρ well above the 0.25 band), a real bulge penalty. + assert!( + with_slur(0, 1) > 0.0, + "a bulgy short slur is penalized: {}", + with_slur(0, 1) + ); + // A slur over a wide span: the auto height gives ρ ≈ 0.16, inside the + // ideal band [0.08, 0.25], so no penalty. + assert_eq!( + with_slur(0, 6), + 0.0, + "an in-band (mid-span) slur is not penalized" + ); + } + #[test] fn floor_warnings_reference_the_profiles_threshold_column() { // The ten-measure fixture's casting-off distortion (~0.45: the