epiphany/crates/epiphany-engrave/src/quality.rs

1072 lines
49 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! **Real quality-metric computation** — the nine normative axes of the
//! *Quality Metric Catalog* companion (v0.3.0, Chapter 3), measured over what
//! the pipeline already produced: the resolved world-frame geometry
//! ([`CastLayout`]), the constrained input (slot identity, vertical bands), and
//! the declared page geometry. This module implements the v0.3.0 unit set:
//! `spacing_distortion` over rhythmic columns (v0.2.0) and
//! `vertical_density_penalty` over each *realization* of a gap band (v0.3.0).
//! Both revisions narrowed what is measured over, not what it is normalized
//! against: the anchors, threshold tables, and warning fraction are the
//! catalog's, transcribed unchanged in [`epiphany_layout_ir::quality`].
//!
//! Every measurement here is a pure function of the solve's inputs and its
//! resolved output — no clocks, no entropy, fixed iteration order — so repeated
//! identical solves yield bitwise-identical vectors (catalog
//! `req:qmc:determinism`). Where a metric's contributing-unit set is empty the
//! axis is exactly `0.0` (the catalog's vacuous-geometry rule,
//! `req:qmc:vacuous`), never a sentinel.
//!
//! ## Where each axis's inputs come from
//!
//! * **`collision_penalty`** — full pairwise same-system sweep over resolved
//! glyph ink boxes (positions from casting, boxes from the catalog metrics),
//! excluding same-slot pairs (slot identity = the glyph's
//! `horizontal_slot` in the constrained input; the resolved glyph list is
//! index-parallel to it) and strokes (not glyphs, never swept).
//! * **`spacing_distortion`** — per-system advances between **rhythmic**
//! columns: the distinct resolved x of each note/rest-bearing slot realized
//! in the system (its first member's baseline — the spacing pass's own column
//! 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`** — **measured** (Push 3): each drawn slur's arc
//! ratio `ρ = apex height / chord length` is penalized by its distance
//! outside the shallow-arc band `[0.08, 0.25]` (catalog §`slur_shape`),
//! measured over the *spaced* whole curves (the drawn shape, one unit per
//! slur — not the cast's per-system fragments). 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
//! preferred heights: the constrained input's `InterStaffGap` bands
//! (adjacent staff bands' resolved ink extents; the constrained stage's
//! fixed staff stacking is preserved verbatim, so this measures what the
//! resolved geometry actually shows), plus the casting pass's realized
//! inter-system gaps (consecutive systems on a page) against
//! [`VerticalBand::inter_system_gap`]'s preferred height — the same
//! constructor the stacking consults. (`to_constrained` declares no
//! `InterSystemGap` bands, so the realized page-tree gaps are the honest
//! measurable unit set; see DECISIONS.)
//! * **`system_break_penalty`** — per-region non-final systems: `|W w_s| / W`
//! with `W` the declared content width and `w_s` the system's glyph-ink
//! span.
//! * **`page_fill_efficiency`** — non-final pages: unfilled fraction of the
//! declared content height, spans from the resolved page tree's system
//! bounding boxes (top of first system to bottom of last).
//! * **`casting_off_quality`** — per-region CV of system glyph-ink widths,
//! final system included (regions with ≥ 2 systems, all widths positive).
//! * **`symbol_density_uniformity`** — per-region CV of glyphs-per-width
//! density over systems with positive width.
use std::collections::{BTreeMap, BTreeSet};
use epiphany_layout_ir::quality::{
anchors, normalize, MetricThresholds, QUALITY_FLOOR_FRACTION, QUALITY_METRIC_KINDS,
};
use epiphany_layout_ir::{
inter_staff_gap_id, ConstrainedLayoutIR, Curve, QualityMetricVector, SolverWarning,
SolverWarningKind, SpringSlotId, VerticalBand, VerticalBandId, VerticalBandKind,
};
use crate::casting::{CastLayout, PageGeometry};
/// The population coefficient of variation (catalog §"The Measurement Domain"):
/// defined for `k >= 2` values with positive mean; `None` otherwise.
fn cv(values: &[f64]) -> Option<f64> {
if values.len() < 2 {
return None;
}
let mean = values.iter().sum::<f64>() / values.len() as f64;
if mean <= 0.0 {
return None;
}
let variance =
values.iter().map(|v| (v - mean) * (v - mean)).sum::<f64>() / values.len() as f64;
Some(variance.sqrt() / mean)
}
/// The arithmetic mean over a contributing-unit set, with the catalog's
/// vacuous-geometry rule in aggregate form: the mean over an empty set is `0`.
fn mean_or_zero(values: &[f64]) -> f64 {
if values.is_empty() {
0.0
} else {
values.iter().sum::<f64>() / values.len() as 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.
///
/// Measured over the **whole spaced** slur curves — post-horizontal-remap (the
/// drawn shape) and pre-cast-split (one unit per slur) — *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.
/// Measuring the *spaced* (not constrained) curves means horizontal re-spacing
/// that flattens or steepens a drawn slur is honestly captured here rather than
/// hidden — the units are "drawn slurs" (catalog §`slur_shape`).
fn slur_shape_raw(spaced_curves: &[Curve]) -> f64 {
let per_curve: Vec<f64> = spaced_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] {
let resolved = &cast.glyphs[index];
let bounds = &input.glyphs[index].bounding_box;
[
f64::from(resolved.position.x.0 + bounds.left.0),
f64::from(resolved.position.y.0 + bounds.bottom.0),
f64::from(resolved.position.x.0 + bounds.right.0),
f64::from(resolved.position.y.0 + bounds.top.0),
]
}
/// Per-system aggregates over the casting pass's own glyph→system assignment.
struct SystemCensus {
/// Region each system slices (parallel to the other vectors).
region: Vec<usize>,
/// Glyph indices per system, in input order.
members: Vec<Vec<usize>>,
/// Glyph-ink span `w_s` per system (0 for a glyph-less system).
width: Vec<f64>,
/// Reference x of each **rhythmic** column (a slot bearing a notehead or
/// rest) per system, ascending and distinct — the spacing axis's domain
/// (catalog §`spacing_distortion`: the clef/key/time lead and barlines are
/// furniture, not rhythmic columns).
columns: Vec<Vec<f64>>,
}
/// Whether a glyph is a notehead or a rest — the mark that makes its slot a
/// **rhythmic column** (catalog §`spacing_distortion`).
fn is_rhythmic(name: &str) -> bool {
name.starts_with("notehead") || name.starts_with("rest")
}
fn census(input: &ConstrainedLayoutIR, cast: &CastLayout) -> SystemCensus {
let count = cast.region_of_system.len();
let mut members: Vec<Vec<usize>> = vec![Vec::new(); count];
let mut spans: Vec<Option<(f64, f64)>> = vec![None; count];
// Rhythmic slots: those bearing a notehead or rest. Precomputed because a
// slot's rhythmic status depends on *all* its members (a note's accidental
// may precede its notehead in input order), and only rhythmic slots become
// spacing columns (catalog §`spacing_distortion`, "rhythmic column").
let rhythmic: BTreeSet<SpringSlotId> = input
.glyphs
.iter()
.filter(|glyph| is_rhythmic(glyph.glyph.as_str()))
.map(|glyph| glyph.horizontal_slot)
.collect();
// Column reference: the slot's first member (input order) — the same
// convention the spacing and casting passes use for a slot's reference x.
let mut columns: Vec<BTreeMap<SpringSlotId, f64>> = vec![BTreeMap::new(); count];
for (index, glyph) in input.glyphs.iter().enumerate() {
let Some(&system) = cast.system_of_slot.get(&glyph.horizontal_slot) else {
// A slot no region claimed: positioned by no system, so its glyphs
// join no per-system aggregate (catalog §"The Measurement Domain").
continue;
};
members[system].push(index);
let [left, _, right, _] = ink_box(cast, input, index);
spans[system] = Some(match spans[system] {
Some((lo, hi)) => (lo.min(left), hi.max(right)),
None => (left, right),
});
// Only rhythmic columns carry spacing advances; a note-to-note advance
// spans any intervening barline or furniture (which contribute no column).
if rhythmic.contains(&glyph.horizontal_slot) {
columns[system]
.entry(glyph.horizontal_slot)
.or_insert_with(|| f64::from(cast.glyphs[index].position.x.0));
}
}
let width = spans
.iter()
.map(|span| span.map(|(lo, hi)| (hi - lo).max(0.0)).unwrap_or(0.0))
.collect();
let columns = columns
.into_iter()
.map(|by_slot| {
let mut xs: Vec<f64> = by_slot.into_values().collect();
xs.sort_by(f64::total_cmp);
xs.dedup();
xs
})
.collect();
SystemCensus {
region: cast.region_of_system.clone(),
members,
width,
columns,
}
}
/// `collision_penalty` (catalog §`collision_penalty`): colliding unordered
/// same-system, different-slot glyph pairs per glyph. Ink boxes must intersect
/// with positive area in both axes; edge-touching boxes do not collide;
/// same-slot pairs (a column's internal cluster — chord heads, their
/// accidentals, dots) are excluded; strokes are not glyphs and join no pair.
fn collision_raw(input: &ConstrainedLayoutIR, cast: &CastLayout, census: &SystemCensus) -> f64 {
let population = cast.glyphs.len();
if population == 0 {
return 0.0;
}
let mut colliding_pairs: u64 = 0;
for members in &census.members {
// Interval sweep over left edges: a pair can only overlap horizontally
// while the candidate's left edge is inside the anchor's span.
let mut boxes: Vec<(usize, [f64; 4])> = members
.iter()
.map(|&index| (index, ink_box(cast, input, index)))
.collect();
boxes.sort_by(|a, b| a.1[0].total_cmp(&b.1[0]).then(a.0.cmp(&b.0)));
for i in 0..boxes.len() {
let (index_a, a) = boxes[i];
for &(index_b, b) in boxes.iter().skip(i + 1) {
if b[0] >= a[2] {
break; // sorted by left edge: nothing further overlaps in x
}
if input.glyphs[index_a].horizontal_slot == input.glyphs[index_b].horizontal_slot {
continue; // same-column cluster: excluded by the catalog
}
let overlap_x = a[2].min(b[2]) - a[0].max(b[0]);
let overlap_y = a[3].min(b[3]) - a[1].max(b[1]);
if overlap_x > 0.0 && overlap_y > 0.0 {
colliding_pairs += 1;
}
}
}
}
colliding_pairs as f64 / population as f64
}
/// `spacing_distortion` (catalog §`spacing_distortion`): mean per-system CV of
/// **rhythmic** column advances, over systems realizing at least three rhythmic
/// columns (note/rest-bearing slots — see [`census`]).
fn spacing_raw(census: &SystemCensus) -> f64 {
let mut per_system = Vec::new();
for columns in &census.columns {
if columns.len() < 3 {
continue;
}
let advances: Vec<f64> = columns.windows(2).map(|pair| pair[1] - pair[0]).collect();
if let Some(value) = cv(&advances) {
per_system.push(value);
}
}
mean_or_zero(&per_system)
}
/// `vertical_density_penalty` (catalog §`vertical_density_penalty`): mean
/// relative deviation `|r p| / p` over the realized inter-staff and
/// inter-system gaps (see the module docs for the unit reconstruction).
fn vertical_raw(input: &ConstrainedLayoutIR, cast: &CastLayout, census: &SystemCensus) -> f64 {
let units = vertical_units(input, cast, census);
mean_or_zero(&[units.inter_staff, units.inter_system].concat())
}
/// The axis's contributing units, split by kind (catalog §`vertical_density_penalty`:
/// "each *realization* … contributes one unit per system").
///
/// Split out so tests can assert the unit **set**, not merely its mean. Once the
/// inter-staff solve realizes each gap band's declared clearance exactly, every
/// inter-staff unit is `0` on a healthy solve — the axis is a self-check that
/// fires only on a solver or bake defect. Its mean therefore cannot distinguish
/// "measured every realization" from "measured one", which is precisely the
/// defect this split lets a regression catch.
struct VerticalUnits {
inter_staff: Vec<f64>,
inter_system: Vec<f64>,
}
fn vertical_units(
input: &ConstrainedLayoutIR,
cast: &CastLayout,
census: &SystemCensus,
) -> VerticalUnits {
let mut per_unit: Vec<f64> = Vec::new();
// --- InterStaffGap bands declared by the constrained input -------------
// The realized gap between two staves is measured over their FULL content —
// every glyph, stroke, and curve the band owns — not over the band's glyph
// `members` alone. A staff's ink is mostly not glyphs: ledger lines, stems,
// and slurs routinely reach further from the staff than any notehead, and
// the inter-staff solve separates staves until their *content* clears the
// declared gap. Measuring glyphs only scored a correctly separated system
// against a gap it never targeted, reporting the ledger and slur ink the
// solve had already accounted for as crowding.
//
// Ownership comes from each primitive's declared `vertical_band`
// (`req:layoutir:primitive-band-ownership`); the geometry is read back from
// the BAKED output, so a shift the bake failed to apply to some primitive
// class surfaces here as a real deviation rather than hiding behind the
// solver's own intent.
let system_of_glyph = |index: usize| -> Option<usize> {
cast.system_of_slot
.get(&input.glyphs[index].horizontal_slot)
.copied()
};
let mut content: BTreeMap<(usize, VerticalBandId), (f64, f64)> = BTreeMap::new();
{
let mut add = |system: usize, band: VerticalBandId, lo: f64, hi: f64| {
let entry = content
.entry((system, band))
.or_insert((f64::INFINITY, f64::NEG_INFINITY));
entry.0 = entry.0.min(lo);
entry.1 = entry.1.max(hi);
};
for (index, glyph) in input.glyphs.iter().enumerate() {
if let Some(system) = system_of_glyph(index) {
let ink = ink_box(cast, input, index);
add(system, glyph.vertical_band, ink[1], ink[3]);
}
}
for (index, stroke) in cast.strokes.iter().enumerate() {
let Some(system) = cast.stroke_system[index] else {
continue;
};
let half = f64::from(stroke.thickness.0.max(0.0)) * 0.5;
let lo = f64::from(stroke.from.y.0.min(stroke.to.y.0)) - half;
let hi = f64::from(stroke.from.y.0.max(stroke.to.y.0)) + half;
add(system, stroke.vertical_band, lo, hi);
}
for (index, curve) in cast.curves.iter().enumerate() {
let Some(system) = cast.curve_system[index] else {
continue;
};
let half = f64::from(curve.thickness.0.max(0.0)) * 0.5;
for point in curve.control_points() {
let y = f64::from(point.y.0);
add(system, curve.vertical_band, y - half, y + half);
}
}
}
for (region_index, region) in input.regions.iter().enumerate() {
// The region's systems, in page order. A staff band is `to_constrained`'s
// per-*manifestation* band — one per (staff, region) — so its content can
// only land in its own region's systems, and "has content in one of this
// region's systems" identifies the region's staff bands exactly.
//
// Membership is NOT read from `VerticalBand::members`. That list holds
// glyphs, and a staff band is allowed to have none: a staff whose clef is
// unbundled engraves to an anchor *stroke*, and its staff lines are
// strokes regardless. Filtering by glyph members would silently drop such
// a staff from the axis — the same glyph-members assumption this metric
// exists to shed.
let region_systems: Vec<usize> = census
.region
.iter()
.enumerate()
.filter(|&(_, &r)| r == region_index)
.map(|(system, _)| system)
.collect();
let Some(&first_system) = region_systems.first() else {
continue;
};
// Top staff first, ordered within the region's first system: a system
// translates rigidly as a whole, so the staff ORDER is the same in each
// (only the gaps between them are renegotiated per system).
let mut staves: Vec<(f64, VerticalBandId)> = input
.vertical_bands
.iter()
.filter(|band| matches!(band.kind, VerticalBandKind::Staff(_)))
.filter_map(|band| {
content
.get(&(first_system, band.id))
.map(|&(_, top)| (top, band.id))
})
.collect();
staves.sort_by(|a, b| b.0.total_cmp(&a.0));
// The region's declared inter-staff gap bands, by their derived ids
// (gap g separates the region's staves g1 and g, per to_constrained).
let region_layout_id = region.provenance.stable_id;
for gap in 1.. {
let gap_id = inter_staff_gap_id(region_layout_id, gap);
let Some(band) = input.vertical_bands.iter().find(|band| band.id == gap_id) else {
break;
};
let preferred = f64::from(band.preferred_height.0);
if preferred <= 0.0 || staves.len() <= gap {
continue;
}
let (upper, lower) = (staves[gap - 1].1, staves[gap].1);
// EVERY system realizing the pair contributes a unit, not just the
// first. The inter-staff solve sizes each system's gaps from that
// system's own content, so one band's realized height genuinely
// differs across the systems of a region — a later system may carry
// different pressure, or a bake bug touching one primitive class.
// Measuring only the first would average away both.
for &system in &region_systems {
let (Some(&(upper_bottom, _)), Some(&(_, lower_top))) =
(content.get(&(system, upper)), content.get(&(system, lower)))
else {
continue;
};
let realized = (upper_bottom - lower_top).max(0.0);
per_unit.push((realized - preferred).abs() / preferred);
}
}
}
let inter_staff = std::mem::take(&mut per_unit);
// --- Realized inter-system gaps (consecutive systems on a page) --------
let preferred = f64::from(
VerticalBand::inter_system_gap(VerticalBandId(0))
.preferred_height
.0,
);
if preferred > 0.0 {
for page in &cast.pages {
for pair in page.systems.windows(2) {
let upper_bottom = f64::from(pair[0].bounding_box.origin.y.0);
let lower_top =
f64::from(pair[1].bounding_box.origin.y.0 + pair[1].bounding_box.size.height.0);
let realized = (upper_bottom - lower_top).max(0.0);
per_unit.push((realized - preferred).abs() / preferred);
}
}
}
VerticalUnits {
inter_staff,
inter_system: per_unit,
}
}
/// `system_break_penalty` (catalog §`system_break_penalty`): mean
/// `|W w_s| / W` over each region's non-final systems, defined only for a
/// finite positive content width.
fn system_break_raw(census: &SystemCensus, content_width: f64) -> f64 {
if !(content_width.is_finite() && content_width > 0.0) {
return 0.0;
}
let mut per_unit = Vec::new();
for (system, &region) in census.region.iter().enumerate() {
let last_of_region = census.region.iter().rposition(|&r| r == region);
if last_of_region == Some(system) {
continue; // a short last line is not a break failure
}
per_unit.push((content_width - census.width[system]).abs() / content_width);
}
mean_or_zero(&per_unit)
}
/// `page_fill_efficiency` (catalog §`page_fill_efficiency`): mean unfilled
/// fraction over non-final pages, spans measured from the resolved page tree
/// (top of the first system's content extent to the bottom of the last's).
fn page_fill_raw(cast: &CastLayout, content_height: f64) -> f64 {
if !(content_height.is_finite() && content_height > 0.0) || cast.pages.len() < 2 {
return 0.0;
}
let mut per_unit = Vec::new();
for page in &cast.pages[..cast.pages.len() - 1] {
let (Some(first), Some(last)) = (page.systems.first(), page.systems.last()) else {
continue;
};
let top = f64::from(first.bounding_box.origin.y.0 + first.bounding_box.size.height.0);
let bottom = f64::from(last.bounding_box.origin.y.0);
let fill = ((top - bottom) / content_height).min(1.0);
per_unit.push(1.0 - fill);
}
mean_or_zero(&per_unit)
}
/// `casting_off_quality` (catalog §`casting_off_quality`): mean per-region CV
/// of system widths — final system included — over regions cast onto at least
/// two systems, each with positive width.
fn casting_off_raw(input: &ConstrainedLayoutIR, census: &SystemCensus) -> f64 {
let mut per_region = Vec::new();
for region in 0..input.regions.len() {
let widths: Vec<f64> = census
.region
.iter()
.zip(&census.width)
.filter(|&(&r, _)| r == region)
.map(|(_, &w)| w)
.collect();
if widths.len() < 2 || widths.iter().any(|&w| w <= 0.0) {
continue;
}
if let Some(value) = cv(&widths) {
per_region.push(value);
}
}
mean_or_zero(&per_region)
}
/// `symbol_density_uniformity` (catalog §`symbol_density_uniformity`): mean
/// per-region CV of per-system symbol density (glyphs per staff space of
/// content width), over regions with at least two positive-width systems.
fn symbol_density_raw(input: &ConstrainedLayoutIR, census: &SystemCensus) -> f64 {
let mut per_region = Vec::new();
for region in 0..input.regions.len() {
let densities: Vec<f64> = census
.region
.iter()
.enumerate()
.filter(|&(system, &r)| r == region && census.width[system] > 0.0)
.map(|(system, _)| census.members[system].len() as f64 / census.width[system])
.collect();
if densities.len() < 2 {
continue;
}
if let Some(value) = cv(&densities) {
per_region.push(value);
}
}
mean_or_zero(&per_region)
}
/// Computes the full nine-axis [`QualityMetricVector`] for a cast layout, per
/// the Quality Metric Catalog's formulas and pinned anchors. Pure and
/// deterministic: a function of the constrained input, the cast output, and
/// the declared page geometry.
pub(crate) fn measure(
input: &ConstrainedLayoutIR,
cast: &CastLayout,
spaced_curves: &[Curve],
geometry: &PageGeometry,
) -> QualityMetricVector {
let census = census(input, cast);
let content_width = f64::from(geometry.content_width());
let content_height = f64::from(geometry.content_height());
QualityMetricVector {
collision_penalty: normalize(
collision_raw(input, cast, &census),
anchors::COLLISION_R_WORST,
),
spacing_distortion: normalize(spacing_raw(&census), anchors::SPACING_R_WORST),
// Slurs draw (E2), so their shape is now MEASURED (Push 3), not pinned:
// each drawn slur's arc ratio ρ = apex height / chord length is
// penalized by its distance outside the shallow-arc band [0.08, 0.25].
// Measured on the SPACED whole curves — post-horizontal-remap (the
// shape the reader sees), pre-cast-split (the whole slur, one unit) —
// so re-spacing that visibly flattens or steepens a slur is caught, yet
// a well-shaped slur that merely crosses a system break is not
// penalized by its fragments' diagonal chords. A curve-free layout
// measures 0 by the vacuous-geometry rule.
slur_shape_penalty: normalize(slur_shape_raw(spaced_curves), 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(
vertical_raw(input, cast, &census),
anchors::VERTICAL_DENSITY_R_WORST,
),
system_break_penalty: normalize(
system_break_raw(&census, content_width),
anchors::SYSTEM_BREAK_R_WORST,
),
page_fill_efficiency: normalize(
page_fill_raw(cast, content_height),
anchors::PAGE_FILL_R_WORST,
),
casting_off_quality: normalize(
casting_off_raw(input, &census),
anchors::CASTING_OFF_R_WORST,
),
symbol_density_uniformity: normalize(
symbol_density_raw(input, &census),
anchors::SYMBOL_DENSITY_R_WORST,
),
extension_metrics: Vec::new(),
}
}
/// The `QualityFloorApproached` warnings a computed vector earns (catalog
/// §"The `QualityFloorApproached` Warning", `req:qmc:floor-warning`): one per
/// axis whose value exceeds [`QUALITY_FLOOR_FRACTION`] × the applicable
/// threshold — the column selected by the solve's profile. The warning is
/// diagnostic; per the catalog it does **not** change the solve's status.
pub(crate) fn floor_warnings(
vector: &QualityMetricVector,
thresholds: &MetricThresholds,
) -> Vec<SolverWarning> {
QUALITY_METRIC_KINDS
.iter()
.filter_map(|&kind| {
let value = vector.axis(kind).0;
let threshold = thresholds.axis(kind);
let floor = QUALITY_FLOOR_FRACTION * threshold;
(value > floor).then(|| SolverWarning {
kind: SolverWarningKind::QualityFloorApproached { metric: kind },
affected_objects: Vec::new(),
message: format!(
"quality metric {kind:?} at {value:.4} exceeds {QUALITY_FLOOR_FRACTION} x \
the profile's threshold {threshold:.2} (floor {floor:.3})"
),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::{census, vertical_units, VerticalUnits};
use crate::Engraver;
use epiphany_layout_ir::{
to_constrained, to_logical, ConstrainedLayoutIR, ConstraintSolver, QualityMetricKind,
QualityMetricVector, SolveStatus, SolverConfig, SolverProfile, SolverWarningKind,
QUALITY_METRIC_KINDS,
};
/// The QUICKSTART ten-measure hand-off fixture: wraps into two systems
/// under the default A4 geometry — the multi-system measurement case.
fn ten_measure() -> ConstrainedLayoutIR {
to_constrained(&to_logical(
&epiphany_testkit::fixtures::ten_measure_single_staff(0x000A_11CE),
))
}
fn axes(vector: &QualityMetricVector) -> [f64; 9] {
let mut values = [0.0; 9];
for (slot, kind) in values.iter_mut().zip(QUALITY_METRIC_KINDS) {
*slot = vector.axis(kind).0;
}
values
}
#[test]
fn metric_vectors_are_bitwise_deterministic() {
// Catalog `req:qmc:determinism`: identical solve inputs yield
// bitwise-identical vectors within one implementation version — the
// metrics are a pure function of the resolved output and the inputs.
let input = ten_measure();
let a = Engraver::default().solve(&input, &SolverConfig::default());
let b = Engraver::default().solve(&input, &SolverConfig::default());
assert_eq!(a.layout.canonical_bytes(), b.layout.canonical_bytes());
for (x, y) in axes(&a.metric_vector).iter().zip(axes(&b.metric_vector)) {
assert_eq!(
x.to_bits(),
y.to_bits(),
"metric f64s must be bit-identical"
);
}
}
#[test]
fn the_wrapping_fixture_is_measured_honestly() {
// The ten-measure fixture under the default geometry, measured for
// real (values pinned loosely; the goldens pin the geometry itself):
// no cross-column collisions; regular spacing; a single page (the
// page-fill axis degenerates to exactly 0.0). Greedy first-fit alone
// would leave a two-measure stub last system (width CV 0.61 -> the
// clamped worst 1.0); casting-off's widow-rebalance evens that into a
// six/four split (system widths ~59.5 vs ~37.8 staff spaces, CV ~0.22),
// so casting_off measures ~0.45 — comfortably inside the Minimal 0.90
// threshold. The trade-off is on the break axis: the non-final system
// is deliberately left ~66% full (not greedy's ~87%), so system_break
// rises to ~0.68 — still well inside Minimal. Both axes sit above the
// Standard profile's floor (0.8 x 0.35 = 0.28), so both fire the
// SHOULD-level diagnostic, which per the catalog never changes status.
let report = Engraver::default().solve(&ten_measure(), &SolverConfig::default());
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 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");
// Per-system justification fills every NON-final system to the content
// width, so the system-break axis (which measures a non-final system's
// shortfall) collapses to near zero — the point of justification.
assert!(
vector.system_break_penalty.0 < 0.1,
"the non-final system fills the width after justification: {}",
vector.system_break_penalty.0
);
// The width-uniformity axes are non-zero: the justified non-final system
// spans the full width while the last stays ragged-right, so the two
// systems' ink widths (and their glyph densities) differ. Optimal
// casting-off balances the split (5/4 measures, not greedy's 6-plus/stub),
// which fills the final system more and pulls casting_off DOWN from the
// greedy value (~0.80 → ~0.61) — the break search's payoff. Still honest
// for a two-system score (one full line, one shorter last line); a metric
// refinement that scores casting-off on natural widths or excludes the
// intentionally-ragged final system is a follow-up (see DECISIONS).
assert!(
(0.5..0.7).contains(&vector.casting_off_quality.0),
"the balanced split leaves a moderate full-vs-ragged contrast: {}",
vector.casting_off_quality.0
);
assert!(
(0.45..0.65).contains(&vector.symbol_density_uniformity.0),
"density differs between the stretched and the ragged system: {}",
vector.symbol_density_uniformity.0
);
// The casting-off axis exceeds 0.8 × its Standard threshold, so its
// SHOULD-level floor diagnostic fires (per the catalog, never changing
// status); the system-break axis, now near zero, no longer floors.
let floored = |metric: QualityMetricKind| {
report.warnings.iter().any(|w| {
matches!(w.kind, SolverWarningKind::QualityFloorApproached { metric: m } if m == metric)
})
};
assert!(floored(QualityMetricKind::CastingOff));
assert!(!floored(QualityMetricKind::SystemBreak));
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]
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 14→24 straddle the fixture's centred two-system break (optimal
// casting-off balances to ~5/4 measures), wide enough that the height
// clamp does not bind → ρ ≈ 0.16.
score.cross_cutting.slurs.push(Slur {
id,
start_event: ev[14],
end_event: ev[24],
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 floor warning references the PROFILE'S threshold column, not a
// fixed threshold: the Standard column's casting-off threshold is
// tighter than the Minimal column's, so a value BETWEEN their floors
// warns under Standard/Publication (the Standard column) but not under
// Draft (the Minimal column). Tested directly on `floor_warnings` with a
// synthetic value between the two floors — robust to how any particular
// fixture's casting-off happens to land (justification, for one, now
// drives the ten-measure fixture's casting-off above both floors).
use epiphany_layout_ir::quality::{
profile_thresholds, MINIMAL_THRESHOLDS, QUALITY_FLOOR_FRACTION, STANDARD_THRESHOLDS,
};
use epiphany_layout_ir::NormalizedMetric;
let standard_floor = QUALITY_FLOOR_FRACTION * STANDARD_THRESHOLDS.casting_off_quality;
let minimal_floor = QUALITY_FLOOR_FRACTION * MINIMAL_THRESHOLDS.casting_off_quality;
assert!(
standard_floor < minimal_floor,
"the Standard casting-off column is tighter than the Minimal one"
);
let between = 0.5 * (standard_floor + minimal_floor);
// Only casting_off matters here; the other axes' values are irrelevant
// (the closure inspects the CastingOff warning alone).
let vector = QualityMetricVector {
casting_off_quality: NormalizedMetric(between),
..QualityMetricVector::unmeasured()
};
let castoff_warned = |profile: SolverProfile| {
super::floor_warnings(&vector, profile_thresholds(profile))
.iter()
.any(|w| {
matches!(
w.kind,
SolverWarningKind::QualityFloorApproached {
metric: QualityMetricKind::CastingOff
}
)
})
};
assert!(castoff_warned(SolverProfile::Standard));
assert!(castoff_warned(SolverProfile::Publication));
assert!(!castoff_warned(SolverProfile::Draft));
}
#[test]
fn short_scores_do_not_trip_the_standard_spacing_floor() {
// P12-I12 regression. Before `spacing_distortion` was scoped to rhythmic
// columns, a short healthy line's wide clef-to-first-note lead advance
// inflated the CV above the Standard warning floor (0.8 x 0.40 = 0.32),
// so these three-to-eight-column corpus entries spuriously warned
// (measured 0.36-0.41). Scoped to note/rest columns — the clef/key/time
// lead bears no notehead, so it contributes no column — none does.
let floor = 0.8 * 0.40;
for name in ["b_flat_major_scale", "notes_and_rests", "meter_three_four"] {
let score = epiphany_testkit::corpus::corpus()
.into_iter()
.find(|fixture| fixture.name == name)
.expect("corpus entry exists");
let input = to_constrained(&to_logical(&(score.build)()));
let report = Engraver::default().solve(&input, &SolverConfig::default());
assert!(
report.metric_vector.spacing_distortion.0 < floor,
"{name}: spacing {} still above the Standard floor {floor}",
report.metric_vector.spacing_distortion.0
);
assert!(
!report.warnings.iter().any(|w| matches!(
w.kind,
SolverWarningKind::QualityFloorApproached {
metric: QualityMetricKind::Spacing
}
)),
"{name}: spurious spacing floor warning under the default Standard profile"
);
}
}
#[test]
fn a_malformed_input_stays_unmeasured() {
// A structurally invalid input has no trustworthy geometry: the vector
// is the honest all-worst placeholder, not a vacuous all-best zero.
let mut input = ten_measure();
input.glyphs[0].baseline = epiphany_layout_ir::Point::new(f32::NAN, 0.0);
let report = Engraver::default().solve(&input, &SolverConfig::default());
assert_eq!(report.status, SolveStatus::InternalError);
assert_eq!(report.metric_vector, QualityMetricVector::unmeasured());
// ... and no floor diagnostics are derived from a placeholder.
assert!(!report
.warnings
.iter()
.any(|w| matches!(w.kind, SolverWarningKind::QualityFloorApproached { .. })));
}
#[test]
fn realized_inter_system_gaps_measure_the_band_models_preferred_height() {
// The casting pass stacks systems at the vertical-band constructor's
// preferred inter-system gap, so the vertical-density axis measures
// realized == preferred (raw 0.0) on the wrapping fixture — the honest
// near-zero the catalog's rationale describes, *measured* from the
// resolved page tree rather than assumed.
let report = Engraver::default().solve(&ten_measure(), &SolverConfig::default());
assert_eq!(report.metric_vector.vertical_density_penalty.0, 0.0);
}
#[test]
fn vertical_justification_trades_page_fill_for_inter_system_density() {
use crate::PageGeometry;
use epiphany_layout_ir::{Margins, Size2D, StaffSpace};
// On a MULTI-page layout, vertical justification spreads a non-final
// page's systems to fill the height (`page_fill` → ~0), which stretches
// its inter-system gaps beyond the band model's preferred height — a
// cost the vertical-density axis measures. The two axes trade: filling
// the page is paid for in inter-system density. A catalog refinement
// that scores only EXCESS stretch (or measures gap uniformity) is a
// deferred follow-up, alongside the casting_off / justified-raggedness
// note. Pinned so the interaction is not silent (review finding).
let geometry = PageGeometry {
size: Size2D {
width: StaffSpace(40.0),
height: StaffSpace(30.0),
},
margins: Margins {
top: StaffSpace(5.0),
right: StaffSpace(5.0),
bottom: StaffSpace(5.0),
left: StaffSpace(5.0),
},
};
let report =
Engraver::with_geometry(geometry).solve(&ten_measure(), &SolverConfig::default());
assert!(
report.layout.pages.len() >= 2,
"the small page forces multiple pages"
);
// Justification fills the non-final pages…
assert!(
report.metric_vector.page_fill_efficiency.0 < 0.1,
"non-final pages fill: {}",
report.metric_vector.page_fill_efficiency.0
);
// …at the measured cost of stretched inter-system gaps.
assert!(
report.metric_vector.vertical_density_penalty.0 > 0.0,
"stretched inter-system gaps are measured: {}",
report.metric_vector.vertical_density_penalty.0
);
}
/// Runs the real pipeline far enough to hand `vertical_units` a `CastLayout`
/// — the same spacing + casting-off `Engraver::resolve` performs.
fn units(score: &epiphany_core::Score) -> VerticalUnits {
let input = to_constrained(&to_logical(score));
let engraver = Engraver::default();
let remap = crate::HorizontalRemap::build(&input);
let (glyphs, strokes, curves) = (
remap.glyphs(&input),
remap.strokes(&input),
remap.curves(&input),
);
let geometry = engraver.geometry();
let cast = crate::casting::cast_off(&input, &glyphs, &strokes, &curves, &geometry);
let census = census(&input, &cast);
vertical_units(&input, &cast, &census)
}
/// A staff band owning no glyphs is still a staff of its region, so its gap
/// contributes. The percussion placeholder's clef has no bundled glyph and it
/// carries no notes, leaving its band's `members` (a glyph list) empty while
/// the band owns its staff-line strokes. Identifying a region's staff bands by
/// `members` drops it, and the axis loses the unit entirely.
#[test]
fn a_glyphless_staff_band_still_contributes_an_inter_staff_unit() {
let u = units(&epiphany_testkit::fixtures::percussion_placeholder_staff(1));
assert_eq!(
u.inter_staff.len(),
2,
"one unit per system realizing the pair, not zero"
);
for value in &u.inter_staff {
assert!(
*value < 1e-4,
"and the solve realizes the declared gap: {value}"
);
}
}
/// A gap band realized in several systems contributes one unit PER SYSTEM.
/// The inter-staff solve sizes each system's gaps from that system's own
/// content, so the realizations are independent measurements; the wrapping
/// fixture's two systems sit at very different staff distances.
#[test]
fn each_system_realizing_a_gap_band_contributes_its_own_unit() {
let u = units(&epiphany_testkit::fixtures::two_staff_wrapping_pressure(1));
assert_eq!(u.inter_staff.len(), 2, "two systems, two realizations");
for value in &u.inter_staff {
assert!(*value < 1e-4, "each solved to its declared gap: {value}");
}
// Three staves in one system: two gap bands, one realization each.
let u = units(&epiphany_testkit::fixtures::three_staff_close_content(1));
assert_eq!(u.inter_staff.len(), 2, "two gap bands, one system");
for value in &u.inter_staff {
assert!(*value < 1e-4, "the cascade realizes both gaps: {value}");
}
}
}