Push 3 (slur quality) 3/3: measure slur_shape_penalty (off the 0.0 placeholder)

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) <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-08 17:28:28 -04:00
parent 586969125e
commit 7d61271069
3 changed files with 156 additions and 27 deletions

View File

@ -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 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 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. the system height for page overflow, like a volta bracket.
- `slur_shape_penalty` stops being *vacuous* 0.0 and becomes *0.0 by - `slur_shape_penalty` stopped being *vacuous* 0.0 and became *0.0 by
construction*: the Minimal tier draws the ideal arc (or the authored construction* at E2; Push 3 (below) makes it a **real measurement**.
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.
## ENGRAVER_VERSION 6 → 7: curve splitting across systems (Push 3, 2026-07-08) ## 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 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 `CurveFate::Rigid`, byte-identical to version 6, so the existing goldens are
unchanged (the fixture's slurs are short). 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).

View File

@ -51,10 +51,9 @@
//! the private `quality` module computes all nine normative axes per the //! the private `quality` module computes all nine normative axes per the
//! ratified *Quality Metric Catalog* companion (collision census, spacing //! ratified *Quality Metric Catalog* companion (collision census, spacing
//! regularity, break/page/casting-off distribution, vertical gap deviation; //! regularity, break/page/casting-off distribution, vertical gap deviation;
//! `slur_shape` is `0.0` **by construction** now that slurs draw — the Minimal //! `slur_shape` is now **measured** — each drawn slur's arc ratio against the
//! tier emits the ideal arc, so a drawn slur has zero shape deviation — while //! shallow-arc band — while `beam_slope` stays vacuous-`0.0` because no drawn
//! `beam_slope` stays vacuous-`0.0` because no drawn beam geometry exists yet), //! beam geometry exists yet), normalized through the catalog's pinned anchors
//! normalized through the catalog's pinned anchors
//! ([`epiphany_layout_ir::quality`]), with //! ([`epiphany_layout_ir::quality`]), with
//! [`SolverWarningKind::QualityFloorApproached`] diagnostics against the //! [`SolverWarningKind::QualityFloorApproached`] diagnostics against the
//! threshold column the config's profile selects. The all-worst //! threshold column the config's profile selects. The all-worst

View File

@ -26,12 +26,13 @@
//! reference). The clef/key/time lead and barlines bear no notehead or rest, //! 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 //! so they contribute no column and a note-to-note advance spans them
//! (catalog §`spacing_distortion` — measuring rhythmic spacing, not furniture). //! (catalog §`spacing_distortion` — measuring rhythmic spacing, not furniture).
//! * **`slur_shape_penalty`** — **0.0 by construction** (E2): slurs now draw as //! * **`slur_shape_penalty`** — **measured** (Push 3): each drawn slur curve's
//! cubic-bézier curves, but the Minimal tier emits the *reference* arc from //! arc ratio `ρ = apex height / chord length` is penalized by its distance
//! the span and the authored/default curvature, so a drawn slur has zero //! outside the shallow-arc band `[0.08, 0.25]` (catalog §`slur_shape`). The
//! deviation from its ideal (an authored `curvature_override` is honored, not //! Minimal tier's mid-span slurs sit at `ρ ≈ 0.16` (in band, 0), but its
//! penalized). A real penalty awaits a Standard-tier solver that compromises //! fixed height clamps push short slurs above the band (too bulgy) and very
//! a slur's shape to dodge collisions. **`beam_slope_penalty`** stays //! 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 //! **vacuous 0.0**: no beam geometry is drawn yet (beams exist logically, not
//! as segments), so its contributing-unit set is empty. //! as segments), so its contributing-unit set is empty.
//! * **`vertical_density_penalty`** — realized gaps against the band model's //! * **`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<f64> = 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 /// One glyph's resolved ink box `[left, bottom, right, top]` (f64, exact from
/// the f32 geometry). /// the f32 geometry).
fn ink_box(cast: &CastLayout, input: &ConstrainedLayoutIR, index: usize) -> [f64; 4] { fn ink_box(cast: &CastLayout, input: &ConstrainedLayoutIR, index: usize) -> [f64; 4] {
@ -459,16 +529,15 @@ pub(crate) fn measure(
anchors::COLLISION_R_WORST, anchors::COLLISION_R_WORST,
), ),
spacing_distortion: normalize(spacing_raw(&census), anchors::SPACING_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 // Slurs draw (E2), so their shape is now MEASURED (Push 3), not pinned:
// is measured as ideal by construction: the Minimal tier emits the // each drawn curve's arc ratio ρ = apex height / chord length is
// reference arc itself — a symmetric cubic from the span and the // penalized by its distance outside the shallow-arc band [0.08, 0.25].
// authored (or default) curvature — so a drawn slur has zero deviation // The Minimal tier's mid-span slurs sit at ρ ≈ 0.16 (in band, 0
// from its ideal, and an authored `curvature_override` is honored, not // penalty), but its fixed height clamps push short slurs above the band
// penalized. A real non-zero penalty awaits a collision-aware // (too bulgy) and very long ones below it (too flat) — a real, honest
// (Standard-tier, Push 3) solver that *compromises* a slur's shape to // non-zero measurement. A curve-free layout measures 0 by the
// dodge collisions; until then this axis stays 0.0 by construction, not // vacuous-geometry rule.
// by vacuity. slur_shape_penalty: normalize(slur_shape_raw(cast), anchors::SLUR_SHAPE_R_WORST),
slur_shape_penalty: normalize(0.0, anchors::SLUR_SHAPE_R_WORST),
// Same vacuous rule: no drawn beam segments exist in this pipeline. // Same vacuous rule: no drawn beam segments exist in this pipeline.
beam_slope_penalty: normalize(0.0, anchors::BEAM_SLOPE_R_WORST), beam_slope_penalty: normalize(0.0, anchors::BEAM_SLOPE_R_WORST),
vertical_density_penalty: normalize( vertical_density_penalty: normalize(
@ -584,9 +653,9 @@ mod tests {
let vector = &report.metric_vector; let vector = &report.metric_vector;
assert_eq!(vector.collision_penalty.0, 0.0); assert_eq!(vector.collision_penalty.0, 0.0);
assert!(vector.spacing_distortion.0 > 0.0 && vector.spacing_distortion.0 < 0.3); 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 // The ten-measure fixture has no drawn slur curve, so the axis measures
// (Minimal draws the ideal arc — deviation is 0 by construction). // 0.0 by the vacuous-geometry rule (an empty contributing-unit set).
assert_eq!(vector.slur_shape_penalty.0, 0.0, "ideal-by-construction"); 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.beam_slope_penalty.0, 0.0, "vacuous: no drawn beams");
assert_eq!(vector.page_fill_efficiency.0, 0.0, "vacuous: single page"); assert_eq!(vector.page_fill_efficiency.0, 0.0, "vacuous: single page");
assert!( assert!(
@ -618,6 +687,46 @@ mod tests {
assert_eq!(report.status, SolveStatus::Solved); 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::<SlurId>(),
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] #[test]
fn floor_warnings_reference_the_profiles_threshold_column() { fn floor_warnings_reference_the_profiles_threshold_column() {
// The ten-measure fixture's casting-off distortion (~0.45: the // The ten-measure fixture's casting-off distortion (~0.45: the