diff --git a/crates/epiphany-engrave/DECISIONS.md b/crates/epiphany-engrave/DECISIONS.md index 2da8cb0..98fde57 100644 --- a/crates/epiphany-engrave/DECISIONS.md +++ b/crates/epiphany-engrave/DECISIONS.md @@ -493,9 +493,17 @@ 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. +(`max(0, 0.08 − ρ, ρ − 0.25)`), meaned over units and normalized by +`R_worst = 0.25`. + +**The unit is the WHOLE slur, measured on the constrained `input.curves` (one +per drawn slur), not the cast output's per-system fragments** (review fix). A +break-spanning slur casts into per-system sub-cubics whose *diagonal* chords +each read flatter than the whole arc, so measuring fragments would spuriously +penalize (and double-count) a slur that is ideally shaped as a whole — +violating the catalog's "a tier that draws the ideal shallow arc measures 0" +property. The whole arc is the shape-decision unit; casting's horizontal +re-spacing is a spacing concern (`spacing_distortion`), not a shape one. 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 diff --git a/crates/epiphany-engrave/src/quality.rs b/crates/epiphany-engrave/src/quality.rs index b528a84..85f34a4 100644 --- a/crates/epiphany-engrave/src/quality.rs +++ b/crates/epiphany-engrave/src/quality.rs @@ -106,8 +106,18 @@ const SLUR_APEX_SAMPLES: usize = 32; /// 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 +/// +/// Measured over the **whole** slur curves of the constrained input (one unit +/// per drawn slur — the engraver's arc-proportion decision), *not* the cast +/// output's per-system fragments: casting splits a break-spanning slur into +/// sub-cubics whose diagonal chords each read flatter than the whole arc, which +/// would spuriously penalize (and double-count) a slur that is ideally shaped +/// as a whole. The catalog's property "a tier that draws the ideal shallow arc +/// for every slur measures 0" holds only when the whole arc is the unit. The +/// horizontal re-spacing that casting applies is a spacing concern +/// (`spacing_distortion`), not a shape one. +fn slur_shape_raw(input: &ConstrainedLayoutIR) -> f64 { + let per_curve: Vec = input .curves .iter() .filter_map(|curve| { @@ -537,7 +547,7 @@ pub(crate) fn measure( // (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), + slur_shape_penalty: normalize(slur_shape_raw(input), 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( @@ -727,6 +737,50 @@ mod tests { ); } + #[test] + fn a_break_spanning_in_band_slur_measures_zero_despite_splitting() { + // A well-shaped (ρ ≈ 0.16, in-band) slur whose span crosses a system + // break splits into per-system sub-curves. The shape axis measures the + // WHOLE slur (the constrained curve), not the fragments — whose diagonal + // chords would each read too flat — so the well-shaped slur still scores + // 0, as the catalog's "ideal arc ⇒ 0" property requires. + use epiphany_core::{Slur, SlurId, SlurKind, SpanStyle, TypedObjectId}; + use epiphany_layout_ir::to_constrained; + + let mut score = epiphany_testkit::fixtures::ten_measure_single_staff(0x000A_11CE); + let ev: Vec<_> = score.canvas.regions[0].staff_instances()[0].voices[0] + .events + .clone(); + let id: SlurId = score.identity.mint(); + // Events 22→26 straddle the fixture's two-system break (a ~one-measure + // span, wide enough that the height clamp does not bind → ρ ≈ 0.16). + score.cross_cutting.slurs.push(Slur { + id, + start_event: ev[22], + end_event: ev[26], + kind: SlurKind::Legato, + curvature_override: None, + style: SpanStyle::default(), + }); + let report = Engraver::default().solve( + &to_constrained(&to_logical(&score)), + &SolverConfig::default(), + ); + // The slur really did split (the fragment path would have fired)… + let segments = report + .layout + .curves + .iter() + .filter(|c| c.provenance.source == TypedObjectId::Slur(id)) + .count(); + assert!(segments >= 2, "the slur splits, got {segments} segment(s)"); + // …yet the shape axis is 0: the whole arc is in-band. + assert_eq!( + report.metric_vector.slur_shape_penalty.0, 0.0, + "a split but well-shaped slur is not spuriously penalized" + ); + } + #[test] fn floor_warnings_reference_the_profiles_threshold_column() { // The ten-measure fixture's casting-off distortion (~0.45: the diff --git a/spec/quality_metric_catalog.pdf b/spec/quality_metric_catalog.pdf index 65cbb7c..f771f93 100644 Binary files a/spec/quality_metric_catalog.pdf and b/spec/quality_metric_catalog.pdf differ diff --git a/spec/quality_metric_catalog.tex b/spec/quality_metric_catalog.tex index 9c13324..8c96f1a 100644 --- a/spec/quality_metric_catalog.tex +++ b/spec/quality_metric_catalog.tex @@ -790,10 +790,14 @@ practice. \begin{requirement} \label{req:qmc:slur} -\textbf{Contributing units:} drawn slur curves in $L$ with chord length -$c > 0$, where the \emph{chord} is the segment between the curve's -endpoints and the \emph{apex height} $h \ge 0$ is the maximum -perpendicular distance from the curve to its chord. +\textbf{Contributing units:} drawn slurs in $L$ with chord length +$c > 0$, where the \emph{chord} is the segment between the \emph{whole} +slur's endpoints and the \emph{apex height} $h \ge 0$ is the maximum +perpendicular distance from the curve to its chord. The unit is the whole +slur, not a per-system fragment: a slur that a casting-off pass splits +across a system break is measured once, as the arc it was shaped to be, so +a well-shaped slur that happens to break is not spuriously penalized (its +fragments' diagonal chords each read flatter than the whole). \textbf{Raw measurement:} per unit, with arc ratio $\rho = h / c$, \[