From bc9fb27d36e1aadff8df31f6b611f70154dcc010 Mon Sep 17 00:00:00 2001 From: Levi Neuwirth Date: Fri, 3 Jul 2026 19:09:51 -0400 Subject: [PATCH] Clear P12-I11: casting-off widow-rebalance evens the stub last system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RS-1 honestly failed the Minimal casting_off_quality threshold under the reference engraver: greedy first-fit left a two-measure stub last system (width CV 0.6145 -> clamped 1.0 > 0.90). Cleared the honest way — an engrave-side balance pass, no Quality Metric Catalog or core-spec change. Casting-off gains a second phase, a widow rebalance (casting::rebalance_widows, run between the greedy walk and vertical stacking): it moves whole trailing measures from a region's penultimate system into its final one, choosing the shift that minimizes the larger of the two distribution penalties the catalog defines for the break family — the width imbalance (casting_off_quality, the CV of the region's system widths) and the non-final break penalty (system_break_penalty, the mean of |W-w|/W over non-final systems). distribution_cost computes each raw by the same formula as quality.rs's casting_off_raw / system_break_raw (mean not worst, abs not clamp), so the rebalance optimizes the values the metric census will report. The two axes pull against each other, so their min-max lands on a 6/4 split for RS-1 (casting_off 1.0 -> 0.4463, system_break 0.254 -> 0.677, every axis <= 0.90) — with comfortable margin, over the fragile full-balance 5/5 (system_break 0.889, a hair under 0.90). Scope is tight: only a region's last boundary moves, and only when greedy placed it (an Automatic boundary with no break requirement or page force pinned to its slot); a user/IR-anchored or page-forced boundary is never disturbed, the penultimate system keeps >= 1 measure, the final never grows past its predecessor, and the system count is unchanged — so every break-count and page-assignment invariant (and all break-constraint tests) hold untouched. The casting_off 0.5 anchor and the 0.90 Minimal column were vindicated, not relaxed: the engraver improved, no anchor rescale / threshold loosening / RS-1 override. Core spec Chapter 9's "Minimal makes no optimality claim" already permits the heuristic, so nothing normative changed (no .tex/PDF rebuild). P12-I12 (the Standard-tier spacing floor on short scores) stays open. - engrave: rebalance_widows + distribution_cost + two-phase module docs; ENGRAVER_VERSION 2 -> 3 (a wrapping score's baked geometry differs from pure greedy); three new casting tests (even-split preference, the mean-not-worst break penalty for 3+ systems, and the resolved final system); the_wrapping_fixture_is_measured_honestly re-pinned to the 6/4 values (both axes floor-warn under Standard, status untouched). - testkit: RS-1 minimal_xfail row removed (promoted to a plain Pass); the suite ships no xfail rows. - render-svg: ten_measure.engrave.{svg,snapshot} goldens regenerated (view_box width 83.99 -> 64.95; still two systems). - process trail: PASS12_BATCH I11 struck; PASS12_RATIFICATION_LOG "no spec change" section; engrave DECISIONS casting-off decision 9 + quality item 7 + candidate promoted. 860 workspace tests pass; clippy -D warnings, fmt --check, rustdoc -D warnings all clean. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd --- crates/epiphany-engrave/DECISIONS.md | 79 +++-- crates/epiphany-engrave/src/casting.rs | 282 +++++++++++++++++- crates/epiphany-engrave/src/lib.rs | 15 +- crates/epiphany-engrave/src/quality.rs | 47 +-- ..._measure_single_staff.engrave.snapshot.txt | 2 +- .../ten_measure_single_staff.engrave.svg | 126 ++++---- .../epiphany-testkit/src/reference_suite.rs | 35 +-- spec/PASS12_BATCH.md | 7 +- spec/PASS12_RATIFICATION_LOG.md | 12 + 9 files changed, 469 insertions(+), 136 deletions(-) diff --git a/crates/epiphany-engrave/DECISIONS.md b/crates/epiphany-engrave/DECISIONS.md index a016161..d163a05 100644 --- a/crates/epiphany-engrave/DECISIONS.md +++ b/crates/epiphany-engrave/DECISIONS.md @@ -162,12 +162,44 @@ inputs the solver cannot measure. with the user's override id). The pathological-soft path keeps the old warning semantics under a new, honest name (`a_pathological_soft_break_is_skipped_and_recorded_as_ir_override`). -9. **Deferred refinements** (named, not implied): per-system justification +9. **Widow rebalance (casting-off phase 2) — the honest P12-I11 fix.** Greedy + first-fit (decision 1) is optimal for *page fill* — it packs each non-final + system as full as the width allows — but that leaves a region's **final** + system whatever is left over, often a narrow stub (a "widow") the + `casting_off_quality` axis penalizes as a global casting-off failure. A + second phase (`casting::rebalance_widows`) evens the split: it moves whole + trailing measures from a region's penultimate system into its final one, + choosing the shift that **minimizes the larger of the two distribution + penalties the Quality Metric Catalog defines for the break family** — the + width imbalance (`casting_off_quality`, the CV of the region's system widths) + and the non-final break penalty (`system_break_penalty`, the mean of + `|W − w|/W` over non-final systems) — each computed by the same formula as + the axis it stands in for, so the rebalance optimizes the values the metric + census will report, not a proxy. The + two axes pull against each other (filling non-final systems worsens + imbalance; equalizing widths worsens underfill) and both share the catalog's + `0.5` anchor, so the raw quantities compare directly and the minimizer of + their maximum is the width that best satisfies both. Scope: only a region's + **last** boundary moves, and only when greedy placed it (an `Automatic` + boundary with no break requirement or page force pinned to its slot); a + user/IR-anchored or page-forced boundary is never disturbed, the penultimate + system keeps ≥ 1 measure, and the final system never grows past its + predecessor. The **system count is unchanged**, so decision 2's break + structure, decision 5's page assignment, and every break-count test invariant + hold. Result on **P12-I11**: RS-1 casts six/four instead of eight/two + (`casting_off` 1.0 → 0.4463, every axis ≤ 0.90), the suite's asserted Xfail + row is promoted to a plain Pass, with **no Quality Metric Catalog change** — + the engraver improved, the `0.5` anchor and the `0.90` Minimal column stood. + `ENGRAVER_VERSION` 2 → 3 (a wrapping score's baked geometry differs from pure + greedy); the `ten_measure` render goldens were regenerated. Still a Minimal + heuristic, not an optimality claim. +10. **Deferred refinements** (named, not implied): per-system justification (stretching the soft springs so every full system ends at the right margin); the vertical spring solve (band heights are carried, not yet - renegotiated; systems stack by real content extents); widow/orphan control - and optimal/lookahead casting-off quality (a `Standard`-tier concern, with - `casting_off_quality` in the metric vector); casting-off caching / + renegotiated; systems stack by real content extents); orphan control and + optimal/lookahead casting-off quality beyond decision 9's tail-only widow + rebalance — a `Standard`-tier concern (full-region rebalancing, and + justification-aware casting-off once systems can stretch); casting-off caching / incremental re-cast (the spec's incremental-layout section names the casting-off cache; `solve_incremental` currently re-solves from scratch, which remains observationally equivalent); per-system clef/key restatement @@ -298,27 +330,30 @@ testkit's reference-suite harness. the tests' actual claim (an honoured break is not a *soft violation*) is preserved exactly. 7. **Measured reality on the reference suite (first real vectors).** The six - v0.1 entries measure clean on every axis except two findings the catalog's - threshold-tuning open question anticipated (both reported as Pass-12/QMC - candidates below): RS-1's `casting_off_quality` = 1.0 (the greedy stub - last line, above the Minimal 0.90 threshold — tracked as a documented - xfail row in the testkit harness), and `spacing_distortion` on 3–8-column - entries (0.36–0.41) sits above the Standard column's 0.32 warning floor, - so short scores warn under the default Standard profile. + v0.1 entries now measure clean on every Minimal axis. RS-1's + `casting_off_quality` was 1.0 under engraver **v2**'s pure greedy first-fit + (the stub last line, above the Minimal 0.90 threshold — carried as a + documented xfail row in the testkit harness, P12-I11); the **v3** + widow-rebalance phase (casting-off decision 9) evens the split to + `casting_off` = 0.4463, clearing the miss with no catalog change, and the + xfail row is promoted to a plain Pass. One finding the catalog's + threshold-tuning open question anticipated remains (a Pass-12/QMC candidate + below, P12-I12): `spacing_distortion` on 3–8-column entries (0.36–0.41) sits + above the Standard column's 0.32 warning floor, so short scores warn under + the default Standard profile — a *diagnostic* floor, not a Minimal failure. ### Pass 12 candidates (quality metrics) -- **P12 (proposed) — QMC: RS-1 fails the Minimal casting-off threshold under - the reference engraver.** First measured vectors (this crate, engraver v2): - greedy first-fit casts the RS-1 fixture into glyph spans ~78.6/18.8 staff - spaces → width CV 0.61 ≥ the 0.5 anchor → clamped 1.0 > the Minimal 0.90 - threshold. Two consistent resolutions: (a) a casting-off balance pass in - the engraver (a geometry change requiring golden regeneration and a solver - version bump), or (b) a QMC minor revision (raise the `casting_off_quality` - anchor toward ~1.0, or give Minimal a per-axis relaxation / the Reference - Suite an RS-1 override). Until ratified either way, the testkit harness - carries the miss as an asserted Xfail row (budget-harness discipline), so - it cannot rot silently. +- **P12-I11 — RESOLVED (engraver v3 widow rebalance).** First measured vectors + (engraver v2) cast the RS-1 fixture into glyph spans ~78.6/18.8 staff spaces + → width CV 0.61 ≥ the 0.5 anchor → clamped 1.0 > the Minimal 0.90 threshold. + Resolved the **honest way (option (a))**: casting-off decision 9's + widow-rebalance phase evens the split to ~59.5/37.8 (`casting_off` = 0.4463), + so every axis passes and the testkit Xfail row is promoted to a Pass. Option + (b) (a QMC anchor/threshold revision — raise the anchor, relax Minimal, or add + an RS-1 override) was deliberately **not** taken: the `0.5` anchor and the + `0.90` Minimal column stood. `ENGRAVER_VERSION` 2 → 3; `ten_measure` render + goldens regenerated. - **P12 (proposed) — QMC: the Standard spacing floor warns on short scores.** With uniform preferred widths, few-column systems (3–8 columns with a wide clef/key lead) measure spacing CV 0.36–0.41 — above the Standard column's diff --git a/crates/epiphany-engrave/src/casting.rs b/crates/epiphany-engrave/src/casting.rs index 9f3acea..afdd4b6 100644 --- a/crates/epiphany-engrave/src/casting.rs +++ b/crates/epiphany-engrave/src/casting.rs @@ -3,13 +3,14 @@ //! "resolve\[s\] page and system breaks"; Chapter 7 §"ResolvedLayoutIR" defines //! the page/system tree this pass populates). //! -//! ## The algorithm (greedy first-fit) +//! ## The algorithm (greedy first-fit, then a widow rebalance) //! //! [`SolverTier::Minimal`](epiphany_layout_ir::SolverTier) requires the break //! constraint family to be supported and every hard constraint satisfied (or an //! honest `Unsatisfiable`); it makes **no optimality claim**, so casting-off is -//! a deterministic greedy first-fit, not an optimal (Knuth–Plass-style) break -//! search: +//! a deterministic two-phase heuristic, not an optimal (Knuth–Plass-style) break +//! search. Phase 1 is greedy first-fit; phase 2 (`rebalance_widows`) evens the +//! system widths so the final system is not left a narrow stub. Phase 1: //! //! 1. **System breaking.** Per region, walk the spaced spring-slot columns in x //! order. Break into systems at **measure boundaries** — the barline columns @@ -43,6 +44,15 @@ //! flat glyph/stroke lists remain the renderer's and hit-tester's single //! coordinate space — no per-page transform exists anywhere downstream. //! +//! Phase 2 (`rebalance_widows`, run between system breaking and stacking) +//! moves whole trailing measures from a region's penultimate system into its +//! final one to even their widths — the anti-widow refinement, choosing the +//! shift that minimizes the larger of the two distribution penalties the +//! Quality Metric Catalog defines for the break family (width imbalance vs +//! non-final underfill). It leaves the system *count* unchanged and never +//! disturbs a constraint-pinned boundary, so the break structure phase 1 +//! established still holds. +//! //! ## Region-spanning strokes //! //! A stroke confined to one system (a stem, a ledger, a barline-anchored mark) @@ -455,6 +465,12 @@ pub(crate) fn cast_off( ); } + // ---- Widow rebalance (casting-off phase 2) ---------------------------- + // Greedy first-fit fills every non-final system maximally, which can leave + // a region's final system a narrow stub; even the system widths without + // moving any constraint-pinned boundary or changing the system count. + rebalance_widows(&mut systems, ®ion_slots, &reqs, width_limit); + // ---- Stroke fates ------------------------------------------------------ // Which system each slot landed in, and each region's slot span / per-system // clip intervals (the interior cut points for system-spanning strokes). @@ -905,6 +921,197 @@ fn walk_region( }); } +/// **Widow rebalance** — the casting-off pass's second phase (module docs). The +/// greedy first-fit walk fills each non-final system as full as the content +/// width allows, which is optimal for *page fill* but can leave the region's +/// **final** system a narrow stub (a "widow") — exactly what the Quality Metric +/// Catalog's `casting_off_quality` axis penalizes. This pass evens the region's +/// system widths by moving whole trailing measures from the penultimate system +/// into the final one. +/// +/// The shift is chosen to **minimize the larger of the two distribution +/// penalties the catalog defines for the break family**: the system-width +/// *imbalance* (`casting_off_quality`, the coefficient of variation of the +/// region's system widths) and the non-final *break* penalty +/// (`system_break_penalty`, the mean of `|W − w|/W` over non-final systems). +/// Each is computed by the same formula the metric census uses (see +/// [`distribution_cost`]). The two axes pull against each other — +/// filling non-final systems (few, wide systems) worsens imbalance; equalizing +/// widths (empty non-final systems) worsens underfill — and both share the +/// catalog's `0.5` worst-tolerable anchor, so their raw quantities are compared +/// directly and the minimizer of their maximum is the width that best satisfies +/// both. It is not a claim of optimality (Minimal makes none); it is a +/// deterministic anti-widow heuristic. +/// +/// Only a region's **last** boundary moves, and only when greedy placed it — an +/// `Automatic` boundary with no break requirement or page force pinned to its +/// slot. A user/IR-anchored or page-forced boundary is never disturbed, and the +/// **system count is unchanged**, so page assignment and every break-count +/// invariant the walk established still hold. The penultimate system keeps at +/// least its own first measure (never emptied), and the final system never +/// grows wider than its predecessor (no mirror-image imbalance). +fn rebalance_widows( + systems: &mut [SystemPlan], + region_slots: &[Vec], + reqs: &BTreeMap>, + width_limit: f32, +) { + if !(width_limit.is_finite() && width_limit > 0.0) { + return; // unbounded width: nothing wraps, nothing to even out + } + let w_limit = f64::from(width_limit); + // A region's systems are a contiguous run in `systems` (walk_region appends + // them per region, in region order); rebalance each run independently. + let mut start = 0; + while start < systems.len() { + let region = systems[start].region; + let mut end = start; + while end < systems.len() && systems[end].region == region { + end += 1; + } + rebalance_region( + &mut systems[start..end], + ®ion_slots[region], + reqs, + w_limit, + ); + start = end; + } +} + +/// Rebalances one region's contiguous run of systems (see [`rebalance_widows`]). +fn rebalance_region( + run: &mut [SystemPlan], + slots: &[SlotInfo], + reqs: &BTreeMap>, + w_limit: f64, +) { + let n = run.len(); + if n < 2 { + return; // a single system has no widow to fix + } + let (prev, last) = (n - 2, n - 1); + // The final boundary must be a greedy one to move it: an `Automatic` system + // break with no break requirement or page force pinned to its slot. + let Some(boundary) = run[last].boundary else { + return; + }; + if boundary.source != DecisionSource::Automatic + || run[last].page_forced + || reqs.contains_key(&boundary.slot) + { + return; + } + let width = |idx: &[usize]| -> f64 { + let lo = idx + .iter() + .map(|&k| slots[k].lo) + .fold(f32::INFINITY, f32::min); + let hi = idx + .iter() + .map(|&k| slots[k].hi) + .fold(f32::NEG_INFINITY, f32::max); + if hi > lo { + f64::from(hi - lo) + } else { + 0.0 + } + }; + // Widths of the systems before the penultimate stay fixed (only the last + // boundary moves); the objective's coefficient of variation ranges over all. + let fixed: Vec = run[..prev].iter().map(|p| width(&p.slots)).collect(); + // Measure-start positions within the penultimate system — local indices into + // its slot list. The first is the system's own opening (immovable); a split + // at a later one moves that measure and the rest into the final system. + let starts: Vec = run[prev] + .slots + .iter() + .enumerate() + .filter(|(_, &k)| slots[k].measure_barline.is_some()) + .map(|(local, _)| local) + .collect(); + if starts.len() < 2 { + return; // the penultimate system has one measure — nothing to lend + } + // Baseline: the greedy split (move nothing). Iterate candidate splits from + // the fewest measures moved (latest start) so ties keep the fuller + // predecessor; accept only a strict improvement. + let mut best_cost = distribution_cost( + &fixed, + width(&run[prev].slots), + width(&run[last].slots), + w_limit, + ); + let mut best_split: Option = None; + for &split in starts.iter().skip(1).rev() { + let kept = &run[prev].slots[..split]; + let moved = &run[prev].slots[split..]; + let last_slots: Vec = moved.iter().chain(&run[last].slots).copied().collect(); + let (w_prev, w_last) = (width(kept), width(&last_slots)); + if w_last > w_prev { + continue; // never grow the final system past its predecessor + } + let cost = distribution_cost(&fixed, w_prev, w_last, w_limit); + if cost < best_cost - 1e-9 { + best_cost = cost; + best_split = Some(split); + } + } + if let Some(split) = best_split { + let moved: Vec = run[prev].slots[split..].to_vec(); + let new_boundary_slot = slots[run[prev].slots[split]].id; + run[prev].slots.truncate(split); + let mut new_last = moved; + new_last.extend_from_slice(&run[last].slots); + run[last].slots = new_last; + run[last].boundary = Some(Boundary { + slot: new_boundary_slot, + source: DecisionSource::Automatic, + }); + } +} + +/// The rebalance objective (see [`rebalance_widows`]): the larger of the two raw +/// distribution penalties over a region's system widths — the **break** penalty +/// and the width **imbalance**. Both normalize against the catalog's shared +/// `0.5` anchor, so comparing and taking the max of the raw quantities orders +/// candidates exactly as the max of the two normalized metrics does. Each raw is +/// computed by the *same* formula as the axis it stands in for, so the rebalance +/// optimizes against the values the `quality` module will report: +/// +/// * **break** — `quality::system_break_raw`'s mean of `|W − w| / W` over the +/// **non-final** systems (absolute, so an overfull non-final system is +/// penalized too); +/// * **imbalance** — `quality::casting_off_raw`'s coefficient of variation over +/// **all** the region's system widths. +fn distribution_cost(fixed: &[f64], w_prev: f64, w_last: f64, w_limit: f64) -> f64 { + let mut widths: Vec = fixed.to_vec(); + widths.push(w_prev); + widths.push(w_last); + let count = widths.len(); + // Break penalty: mean absolute deviation from the content width over the + // non-final systems (the final system is exempt) — `system_break_raw`. + let non_final = &widths[..count - 1]; + let breaks = if non_final.is_empty() { + 0.0 + } else { + non_final + .iter() + .map(|&w| (w_limit - w).abs() / w_limit) + .sum::() + / non_final.len() as f64 + }; + // Imbalance: the coefficient of variation of all system widths — `casting_off_raw`. + let mean = widths.iter().sum::() / count as f64; + let imbalance = if mean > 0.0 { + let variance = widths.iter().map(|w| (w - mean) * (w - mean)).sum::() / count as f64; + variance.sqrt() / mean + } else { + 0.0 + }; + breaks.max(imbalance) +} + /// Decides how a stroke rides the cast systems (see [`StrokeFate`]). fn stroke_fate( stroke: &Stroke, @@ -1194,4 +1401,73 @@ mod tests { -(148.5 + INTER_PAGE_GAP) - 7.5 ); } + + #[test] + fn distribution_cost_prefers_the_even_split_over_greedy_and_full_balance() { + // The RS-1 two-system candidates, glyph-ink widths (staff spaces) from + // the ten-measure fixture at the default 90-wide content area. The + // widow rebalance minimizes the larger of imbalance (CV) and worst + // non-final underfill; the greedy 8/2 stub and the fully balanced 5/5 + // both score worse than the six/four split it settles on. + let w = 90.0; + let greedy = distribution_cost(&[], 78.57, 18.76, w); // 8/2 stub + let six_four = distribution_cost(&[], 59.52, 37.80, w); // rebalanced + let five_five = distribution_cost(&[], 50.00, 47.33, w); // full balance + assert!( + six_four < greedy && six_four < five_five, + "6/4 ({six_four:.4}) must beat greedy ({greedy:.4}) and 5/5 ({five_five:.4})" + ); + } + + #[test] + fn distribution_cost_uses_the_mean_break_penalty_not_the_worst() { + // For three or more systems the break term must be the MEAN of |W-w|/W + // over the non-final systems — the same quantity `quality::system_break_raw` + // reports — not the worst single system. With one full leading system + // fixed at W, the balanced 45/45 tail must beat the 60/30 tail: its width + // CV is lower, and the mean break penalty (diluted by the full leading + // system) does not dominate. A worst-underfill proxy would wrongly prefer + // 60/30 (its lone short system is less empty), inverting the choice. + let w = 90.0; + let fixed = [90.0]; // one full non-final system + let balanced = distribution_cost(&fixed, 45.0, 45.0, w); + let uneven = distribution_cost(&fixed, 60.0, 30.0, w); + assert!( + balanced < uneven, + "the mean break penalty prefers the balanced tail: {balanced:.4} vs {uneven:.4}" + ); + // Pin the mean-not-max semantics exactly: over the non-final systems + // [90, 45] the break penalty is mean(0, 0.5) = 0.25, below the width CV, + // so the objective here is the CV of [90, 45, 45]. + let widths = [90.0_f64, 45.0, 45.0]; + let mean = widths.iter().sum::() / 3.0; + let cv = (widths.iter().map(|x| (x - mean).powi(2)).sum::() / 3.0).sqrt() / mean; + assert!( + (balanced - cv).abs() < 1e-12, + "the objective should equal the width CV here: {balanced} vs {cv}" + ); + } + + #[test] + fn the_widow_rebalance_evens_the_final_system() { + use crate::Engraver; + use epiphany_layout_ir::{to_constrained, to_logical, ConstraintSolver, SolverConfig}; + // The ten-measure fixture wraps into two systems under the default A4 + // geometry. Greedy first-fit alone leaves a two-measure stub final + // system (its width barely a quarter of the first's); the widow + // rebalance evens the split so the final system is a substantial + // fraction of its predecessor — while the system *count* is unchanged. + let input = to_constrained(&to_logical( + &epiphany_testkit::fixtures::ten_measure_single_staff(0x000A_11CE), + )); + let report = Engraver::default().solve(&input, &SolverConfig::default()); + let page = &report.layout.pages[0]; + assert_eq!(page.systems.len(), 2, "the fixture wraps into two systems"); + let first = page.systems[0].bounding_box.size.width.0; + let last = page.systems[1].bounding_box.size.width.0; + assert!( + last > 0.5 * first, + "the rebalanced final system is not a stub: {last} vs {first}" + ); + } } diff --git a/crates/epiphany-engrave/src/lib.rs b/crates/epiphany-engrave/src/lib.rs index c0de83c..74bb6aa 100644 --- a/crates/epiphany-engrave/src/lib.rs +++ b/crates/epiphany-engrave/src/lib.rs @@ -17,9 +17,11 @@ //! bearings) — then a **casting-off pass** (the [`casting`] module; Chapter 9 //! §"The Constraint-Solving Stage": the solver "resolve\[s\] page and system //! breaks"): greedy first-fit system breaking at measure boundaries against a -//! [`PageGeometry`], vertical system stacking at the vertical-band model's -//! inter-system gap, page assignment by content height, and a real populated -//! page/system tree (Chapter 7 §"ResolvedLayoutIR"). Every chosen break is +//! [`PageGeometry`], a widow-rebalance phase that evens a region's system widths +//! so the final system is not left a stub, vertical system stacking at the +//! vertical-band model's inter-system gap, page assignment by content height, +//! and a real populated page/system tree (Chapter 7 §"ResolvedLayoutIR"). Every +//! chosen break is //! recorded as an [`epiphany_layout_ir::EngravingDecision`] whose target is a //! `MUSCLOID` id synthesized under //! [`epiphany_layout_ir::SynthesisKind::EngravedBreak`], attributed to the user @@ -116,8 +118,11 @@ pub struct Engraver { /// The implementation version of this solver (Chapter 9: within a fixed version, /// identical input produces identical output). Distinct from the stub's `0`; /// bumped to `2` when the casting-off pass landed (the resolved geometry of a -/// wrapping score differs from version `1`'s single endless system). -pub const ENGRAVER_VERSION: SolverVersion = SolverVersion(2); +/// wrapping score differs from version `1`'s single endless system), and to `3` +/// when casting-off gained its widow-rebalance phase (a wrapping score's system +/// breaks — and so its baked geometry — differ again from version `2`'s pure +/// greedy first-fit). +pub const ENGRAVER_VERSION: SolverVersion = SolverVersion(3); impl Engraver { /// An engraver casting off against the given page geometry. diff --git a/crates/epiphany-engrave/src/quality.rs b/crates/epiphany-engrave/src/quality.rs index 0c3f3a8..c338a2e 100644 --- a/crates/epiphany-engrave/src/quality.rs +++ b/crates/epiphany-engrave/src/quality.rs @@ -538,12 +538,16 @@ mod tests { // 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); and — the honest part — - // greedy first-fit leaves a two-measure stub last system (glyph spans - // ~78.6 vs ~18.8 staff spaces), which the casting-off axis measures at - // its clamped worst (CV 0.61 >= the 0.5 anchor -> 1.0). That is the - // exact "stub final system" failure the catalog says the axis exists - // to catch; the value is truthful, not a defect in the census. + // 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); @@ -552,28 +556,31 @@ mod tests { 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!( - vector.system_break_penalty.0 > 0.0 && vector.system_break_penalty.0 < 0.35, - "the non-final system is nearly full: {}", + (0.55..0.85).contains(&vector.system_break_penalty.0), + "the non-final system is evened below full: {}", vector.system_break_penalty.0 ); - assert_eq!( - vector.casting_off_quality.0, 1.0, - "the stub last line is honestly at the clamped worst" + assert!( + (0.3..0.6).contains(&vector.casting_off_quality.0), + "the rebalanced split is even, not a clamped-worst stub: {}", + vector.casting_off_quality.0 ); assert!( vector.symbol_density_uniformity.0 < 0.1, "density is even though widths are not: {}", vector.symbol_density_uniformity.0 ); - // The casting-off axis exceeds 0.8 x its threshold in every ratified - // column, so the SHOULD-level floor diagnostic fires — and, per the - // catalog, the status is untouched by it. - assert!(report.warnings.iter().any(|w| matches!( - w.kind, - SolverWarningKind::QualityFloorApproached { - metric: QualityMetricKind::CastingOff - } - ))); + // Both the casting-off and the system-break axes exceed 0.8 x their + // Standard threshold, so the SHOULD-level floor diagnostic fires for + // each — the two sides of the rebalance trade-off, honestly reported — + // and, per the catalog, the status is untouched by them. + 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); } diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.snapshot.txt b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.snapshot.txt index cca4ab2..5621923 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.snapshot.txt +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.snapshot.txt @@ -7,7 +7,7 @@ provenance_count=147 layer_count=1 hard_constraint_count=90 xml_well_formed=true -view_box=[5.4999995 -26.090391 83.99065 20.589813] +view_box=[5.4999986 -26.090391 64.94533 20.589813] class_counts: barline=10 clef=1 diff --git a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.svg b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.svg index e931b2f..5477f96 100644 --- a/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.svg +++ b/crates/epiphany-render-svg/tests/golden/ten_measure_single_staff.engrave.svg @@ -1,14 +1,14 @@ - + - - - - - + + + + + @@ -58,47 +58,47 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - + + + + + @@ -124,32 +124,32 @@ - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + - - - - + + + + diff --git a/crates/epiphany-testkit/src/reference_suite.rs b/crates/epiphany-testkit/src/reference_suite.rs index 5a0655e..dc93052 100644 --- a/crates/epiphany-testkit/src/reference_suite.rs +++ b/crates/epiphany-testkit/src/reference_suite.rs @@ -31,8 +31,11 @@ //! [`SuiteEntry::minimal_xfail`] is a *documented, measured* threshold miss — //! asserted to still miss, so the marking cannot rot (an `XPASS` fails the //! harness demanding promotion), and reported for spec-side resolution rather -//! than silently waived. v0.1 ships exactly one such row (RS-1's -//! `casting_off_quality`; see the entry and the crate's DECISIONS). +//! than silently waived. As of engrave v3 every v0.1 entry passes every Minimal +//! threshold, so no entry carries an xfail row: RS-1's original +//! `casting_off_quality` miss (P12-I11 — greedy first-fit's stub last system) +//! was cleared by the engraver's casting-off widow-rebalance phase, the machinery +//! that would have asserted the miss now stands ready for the next one. //! //! Following the testkit's library-module-per-harness policy (DECISIONS F0), //! this module holds the machinery and `tests/reference_suite.rs` asserts it — @@ -118,24 +121,16 @@ pub fn entries() -> Vec { title: "Ten-measure single staff", construction: "fixtures::ten_measure_single_staff(0x000A_11CE)", build: rs1, - // Measured 2026-07 (engrave v2, QMC v0.1.0 anchors): greedy - // first-fit casting-off leaves a two-measure stub last system - // (glyph spans ~78.6 vs ~18.8 staff spaces, width CV 0.61 >= the - // 0.5 anchor, clamped to 1.0 > the Minimal 0.90 threshold). The - // metric is truthful — this is the exact stub-last-line failure - // the catalog says the axis exists to catch — and the layout is - // byte-locked by the render goldens, so the miss is recorded here - // pending either a casting-off balance pass (a coordinated - // golden-regenerating change) or a QMC anchor/threshold revision - // (the catalog's own threshold-tuning open question). See - // DECISIONS.md. - minimal_xfail: &[MinimalXfail { - axis: QualityMetricKind::CastingOff, - reason: "greedy first-fit leaves a stub last system (width CV \ - 0.61 >= the 0.5 anchor -> 1.0 > 0.90); tracked for a \ - casting-off balance pass or a QMC v0.1 threshold \ - revision", - }], + // Passes every Minimal threshold under the reference engraver + // (engrave v3, QMC v0.1.0 anchors). Greedy first-fit alone left a + // two-measure stub last system (width CV 0.61 -> clamped 1.0 > the + // 0.90 threshold, the original P12-I11 miss); casting-off's + // widow-rebalance phase now evens the split to a six/four + // distribution (width CV ~0.22 -> casting_off ~0.45), clearing the + // miss the honest way — an engrave-side balance pass, goldens + // regenerated, no QMC anchor/threshold relaxation. See DECISIONS.md + // and the engrave casting module. + minimal_xfail: &[], }, SuiteEntry { id: "RS-2", diff --git a/spec/PASS12_BATCH.md b/spec/PASS12_BATCH.md index 7935d4c..0209685 100644 --- a/spec/PASS12_BATCH.md +++ b/spec/PASS12_BATCH.md @@ -66,7 +66,7 @@ code instead is the failure mode this batch exists to prevent. | P12-I8 | `epiphany-engrave` I | Break-constraint satisfaction predicate: implemented as "a `SystemBreakAt`/`PageBreakAt` is satisfied iff the final layout starts a system/page at that slot" (a region-first slot is trivially satisfied). Ch7/Ch9 never define satisfaction for break constraints; ratify the predicate. | G / Pass 12 (solver) | | P12-I9 | `epiphany-layout-ir` I | Honouring a user break must attribute the decision to its override (`DecisionSource::UserOverride(id)`), but constraints carry no override identity; implemented via a `ConstrainedLayoutIR.break_origins` sidecar populated by `to_constrained`. Bless the sidecar or widen the normalized constraint record. | G / Pass 12 (solver) | | P12-I10 | `epiphany-layout-ir` I | System-spanning strokes split at system boundaries need synthesized provenance for continuation segments; implemented as `SynthesisKind::Registered(SYSTEM_CONTINUATION_SYNTHESIS)` with a deterministic `(original, ordinal)` instance key. Add a first-class continuation synthesis kind or bless the registered id. | G / Pass 12 (provenance) | -| P12-I11 | `epiphany-engrave` I | RS-1 honestly fails the Minimal casting-off threshold under the reference engraver (measured 1.0 vs 0.90): greedy first-fit leaves a two-measure stub last system (width CV 0.6145 ≥ the 0.5 anchor). Resolutions: an engrave-side casting-off balance pass (golden regeneration + solver version bump), or a Quality Metric Catalog minor revision (anchor rescale / Minimal relaxation / RS-1 per-entry override). Tracked bidirectionally by the suite harness's asserted Xfail row. | G / Pass 12 (quality) | +| ~~P12-I11~~ **RESOLVED (engrave v3 widow-rebalance)** | `epiphany-engrave` I | RS-1 honestly failed the Minimal casting-off threshold under the reference engraver (measured 1.0 vs 0.90): greedy first-fit left a two-measure stub last system (width CV 0.6145 ≥ the 0.5 anchor). **Resolved the honest way — an engrave-side casting-off balance pass, no catalog change:** casting-off gained a second **widow-rebalance** phase that moves whole trailing measures from a region's penultimate system into its final one, choosing the shift that minimizes the larger of the two distribution penalties the catalog defines for the break family (width imbalance vs non-final underfill; both share the 0.5 anchor). RS-1 now casts six/four instead of eight/two: casting_off 1.0 → **0.4463**, system_break 0.254 → 0.677, every axis ≤ 0.90. `ENGRAVER_VERSION` 2 → 3; render goldens regenerated; the suite's Xfail row promoted to a plain Pass. The `casting_off` 0.5 anchor and the Minimal column stood — the engraver improved, not the threshold — so the Quality Metric Catalog is normatively unchanged. | ✅ done | | P12-I12 | `epiphany-engrave` I | The Standard-tier spacing floor warns on short healthy scores: 3–8-column entries with a wide clef/key lead measure spacing CV 0.36–0.41 > the 0.32 Standard floor. Consider a lead-aware or duration-aware refinement of the spacing_distortion raw measurement (the catalog's optical-spacing open question). | G / Pass 12 (quality) | @@ -80,7 +80,10 @@ work added C1..C4 (re-anchoring), D1 (bundle operation index), and E1..E5 value-restoring undo, 2026-07-02) added C5, K8..K11, and I7..I10. The second tranche (Quality Metric Catalog 0.1.0 + Reference Suite 0.1.0 + real engraver metrics + the suite harness + the multi-system click fix, -2026-07-03) added I11..I12. +2026-07-03) added I11..I12. A follow-up (2026-07-03) resolved I11 (struck +through above): casting-off gained a widow-rebalance phase (engrave v3), so +RS-1 passes every Minimal threshold with no catalog change — I12 (the +Standard-tier spacing floor on short scores) stays open. Agent J's Binary Format companion now exists (`spec/binary_format.tex`, v0.1.0): it ratified the P12-D1/E1/E2/E3 inputs (struck through above) and discharged the crates' provisional-codec notes diff --git a/spec/PASS12_RATIFICATION_LOG.md b/spec/PASS12_RATIFICATION_LOG.md index 42accb6..87d09a2 100644 --- a/spec/PASS12_RATIFICATION_LOG.md +++ b/spec/PASS12_RATIFICATION_LOG.md @@ -48,3 +48,15 @@ content modification, ResolveConflict beyond the concurrent case, RespellPitch v0 migration fallback, Transpose interval algebra), plus the audit's Push-3 wiring tracks (constraints, overrides, validation modes, edit barriers, operation index) — those are code work, not spec alignment. + +## P12-I11 resolution (2026-07-03) — no spec change + +The batch's one measured-and-tracked conformance miss, **P12-I11** (RS-1 +honestly failed the Minimal `casting_off_quality` threshold — greedy first-fit +left a two-measure stub last system, width CV 0.6145 clamped to 1.0 > 0.90), +is resolved **entirely on the implementation side**, so it appears here only to +record that the Quality Metric Catalog and the core spec were **not** changed. + +| Item | Disposition | Spec locus | Consumer | +|---|---|---|---| +| P12-I11 casting-off stub last system | **no spec change (engrave fix)** — casting-off gained a second **widow-rebalance** phase (`epiphany-engrave` v3): it moves whole trailing measures from a region's penultimate system into its final one, choosing the shift that minimizes the larger of the two distribution penalties the catalog already defines for the break family (`casting_off_quality` width imbalance vs `system_break_penalty` non-final underfill; both share the 0.5 anchor). RS-1 casts six/four instead of eight/two — `casting_off` 1.0 → 0.4463, every axis ≤ 0.90 — so the suite's asserted Xfail row is promoted to a plain Pass. This is the deliberately-chosen *honest* resolution: the `casting_off` 0.5 anchor and the Minimal 0.90 column were **vindicated, not relaxed** (the engraver improved, no anchor rescale / threshold loosening / RS-1 per-entry override). Core spec Chapter 9's "Minimal makes no optimality claim" already permits the heuristic; nothing normative changed | — (no spec locus; core spec + Quality Metric Catalog unchanged) | `epiphany-engrave` (`casting::rebalance_widows`, `ENGRAVER_VERSION` 3); `epiphany-testkit` (RS-1 xfail row removed); render goldens regenerated |