Clear P12-I12: scope spacing_distortion to rhythmic columns (QMC 0.2.0)

The Standard-tier spacing floor warned on short healthy scores: three
reference-suite entries measured spacing_distortion 0.36-0.41, above the
0.32 warning floor (0.8 x 0.40), a spurious diagnostic (every score still
passed Minimal). Root cause: the CV folded in the clef-to-first-note lead
advance, which is sized by notational-furniture width, not by rhythm.

Unlike P12-I11 (an engrave-side fix, no spec change), the defect here lived
in the metric's own normative definition, so the honest fix is a catalog
change - the mirror of I11: correct the measurement rather than relax the
threshold.

Quality Metric Catalog 0.1.0 -> 0.2.0: spacing_distortion is scoped to the
system's rhythmic columns (spring slots bearing a notehead or rest). The
clef / key-signature / time-signature lead and barlines contribute no
column, so a note-to-note advance spans them. quality::census now builds its
spacing columns only from a precomputed rhythmic-slot set (is_rhythmic: a
notehead*/rest* glyph anywhere in the slot); the CV and the >= 3-column
contributing-unit rule are otherwise unchanged.

Results: RS-3/5/6 drop to 0.2188 / 0.0819 / 0.0856 (all below the floor),
and the axis stays honest on real irregularity (RS-3 keeps 0.2188 from a
mid-line accidental). RS-2/RS-4 go vacuous-0.0 (their systems carry < 3
rhythmic columns - honestly "too little to measure," cleaner than the old
furniture noise). RS-1 0.1341 -> 0.1967 (cross-barline note advances).

The 1.0 anchor, orientation, range, tier thresholds, and the eight other
axes are unchanged. This is measurement-only: the resolved layout, canonical
bytes, render goldens, and ENGRAVER_VERSION are all untouched - only the
reported spacing_distortion value moves. The duration-aware optical-spacing
open question (deviation from duration-proportional spacing) stays open; it
needs the pipeline's deferred duration-aware preferred widths.

- catalog: spacing_distortion requirement (req:qmc:spacing) redefined over
  rhythmic columns, rationale + open-question note updated, version 0.2.0,
  revision-history row; PDF rebuilt clean (0 undefined refs).
- engrave: quality::census rhythmic-column filter + is_rhythmic; module and
  spacing_raw docs; the floor-column contrast test re-pointed from b-flat's
  spacing (no longer warns) to RS-1's casting-off (still between the Standard
  0.28 and Minimal 0.72 floors); new short_scores_do_not_trip_the_standard_
  spacing_floor locks the fix.
- QMC version breadcrumbs bumped to 0.2.0 (engrave + layout-ir quality.rs,
  both DECISIONS.md, testkit RS-1 comment); reference suite companion
  unchanged (cites the catalog by name, no pinned values).
- process trail: PASS12_BATCH I12 struck; PASS12_RATIFICATION_LOG I12
  section (Version movements: QMC 0.1.0 -> 0.2.0); engrave DECISIONS quality
  decision 8 + item 7 + candidate.

861 workspace tests pass; clippy -D warnings, fmt --check, rustdoc
-D warnings, and the catalog PDF build all clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
This commit is contained in:
Levi Neuwirth 2026-07-05 13:57:10 -04:00
parent bc9fb27d36
commit 9d28dd6615
9 changed files with 211 additions and 77 deletions

View File

@ -259,7 +259,7 @@ resolved:
## Quality metrics (2026-07) — decisions
The Quality Metric Catalog companion (v0.1.0) ratified the nine normative
The Quality Metric Catalog companion (v0.2.0) ratified the nine normative
axes' formal definitions, anchors, thresholds, and the
`QualityFloorApproached` trigger; `Engraver::resolve` now computes the real
vector (the private `quality` module), replacing the all-worst placeholder.
@ -336,11 +336,34 @@ testkit's reference-suite harness.
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 38-column entries (0.360.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.
xfail row is promoted to a plain Pass. The second finding (P12-I12): three
short entries measured `spacing_distortion` 0.360.41, above the Standard
0.32 warning floor — a spurious *diagnostic* (never a Minimal failure). It
was resolved by a catalog refinement (QMC 0.1.0 → 0.2.0), see quality
decision 8: `spacing_distortion` is scoped to rhythmic (note/rest) columns,
dropping the three to 0.2188 / 0.0819 / 0.0856 — below the floor, no code
layout change.
8. **Rhythmic-column spacing (`spacing_distortion` scoped) — the honest P12-I12
fix.** The measured false positive was that a short healthy line's wide
clef-to-first-note lead advance (furniture width, not note spacing) inflated
the per-system advance CV above the Standard warning floor. The catalog
(QMC 0.2.0) scopes the axis to **rhythmic columns** — spring slots bearing a
notehead or rest — excluding the clef/key/time lead and treating barlines
transparently (a note-to-note advance spans them). `quality::census` now
builds `columns` only from slots in the precomputed rhythmic set
(`is_rhythmic`: a `notehead*`/`rest*` glyph anywhere in the slot); the CV and
contributing-unit rule (≥ 3 rhythmic columns) are otherwise unchanged. This
is the mirror of the I11 resolution — measure the right thing rather than
relax the threshold — but here the defect lived in the normative metric
definition, so it *is* a catalog change (unlike I11). Measurement-only: the
resolved layout, canonical bytes, render goldens, and `ENGRAVER_VERSION` are
untouched; only the reported `spacing_distortion` value changes (RS-3/5/6
drop below the floor, RS-2/RS-4 go vacuous-0.0 as their systems carry < 3
rhythmic columns — honestly "too little to measure"). The floor-column
regression test was re-pointed from b-flat's spacing (which no longer warns)
to RS-1's casting-off (which still sits between the Standard and Minimal
floors); a new `short_scores_do_not_trip_the_standard_spacing_floor` locks
the fix. The duration-aware optical-spacing open question stays open.
### Pass 12 candidates (quality metrics)
@ -354,11 +377,14 @@ testkit's reference-suite harness.
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 (38 columns with a wide
clef/key lead) measure spacing CV 0.360.41 — above the Standard column's
0.8 × 0.40 = 0.32 warning floor, so the default profile emits
`QualityFloorApproached(Spacing)` on tiny, healthy scores. Consider either
a duration/lead-aware refinement of the axis (the catalog's optical-spacing
open question) or excluding the lead column from the advance sequence in a
QMC minor revision.
- **P12-I12 — RESOLVED (QMC 0.2.0, rhythmic-column spacing).** With uniform
preferred widths, few-column systems (38 columns with a wide clef/key lead)
measured spacing CV 0.360.41 — above the Standard column's 0.8 × 0.40 = 0.32
warning floor, so the default profile emitted `QualityFloorApproached(Spacing)`
on tiny, healthy scores. Resolved the **lead-aware way**: the catalog scopes
`spacing_distortion` to rhythmic (note/rest) columns, excluding the
clef/key/time furniture lead and treating barlines transparently (quality
decision 8). The three entries drop to 0.2188 / 0.0819 / 0.0856 (below the
floor). The alternative — a duration-proportional (optical) redefinition —
needs the pipeline's deferred duration-aware preferred widths and stays the
catalog's open question.

View File

@ -1,5 +1,5 @@
//! **Real quality-metric computation** — the nine normative axes of the
//! *Quality Metric Catalog* companion (v0.1.0, Chapter 3), measured over what
//! *Quality Metric Catalog* companion (v0.2.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. Normalization anchors, threshold tables, and the
@ -20,9 +20,12 @@
//! 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 column advances: the distinct
//! resolved x of each glyph-bearing slot realized in the system (its first
//! member's baseline — the spacing pass's own column reference).
//! * **`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` / `beam_slope_penalty`** — **vacuous 0.0**: the
//! pipeline draws no slur or beam geometry (slurs/beams exist logically,
//! not as curves/segments), so the contributing-unit sets are empty. The
@ -108,14 +111,33 @@ struct SystemCensus {
members: Vec<Vec<usize>>,
/// Glyph-ink span `w_s` per system (0 for a glyph-less system).
width: Vec<f64>,
/// Column reference x per realized slot per system, ascending and distinct.
/// 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];
@ -131,9 +153,13 @@ fn census(input: &ConstrainedLayoutIR, cast: &CastLayout) -> SystemCensus {
Some((lo, hi)) => (lo.min(left), hi.max(right)),
None => (left, right),
});
columns[system]
.entry(glyph.horizontal_slot)
.or_insert_with(|| f64::from(cast.glyphs[index].position.x.0));
// 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()
@ -196,7 +222,8 @@ fn collision_raw(input: &ConstrainedLayoutIR, cast: &CastLayout, census: &System
}
/// `spacing_distortion` (catalog §`spacing_distortion`): mean per-system CV of
/// column advances, over systems realizing at least three columns.
/// **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 {
@ -586,18 +613,17 @@ mod tests {
#[test]
fn floor_warnings_reference_the_profiles_threshold_column() {
// The b-flat scale's spacing distortion (~0.41: eight columns whose
// flat-bearing columns advance wider) sits between the Standard
// column's floor (0.8 x 0.40 = 0.32) and the Minimal column's
// (0.8 x 0.90 = 0.72) — so the default Standard profile warns about
// Spacing and the Draft profile (which selects the Minimal column per
// the catalog's profile registry) does not.
let score = epiphany_testkit::corpus::corpus()
.into_iter()
.find(|fixture| fixture.name == "b_flat_major_scale")
.expect("corpus entry exists");
let input = to_constrained(&to_logical(&(score.build)()));
let spacing_warned = |profile: SolverProfile| {
// The ten-measure fixture's casting-off distortion (~0.45: the
// six/four widow-rebalanced split) sits between the Standard column's
// floor (0.8 x 0.35 = 0.28) and the Minimal column's (0.8 x 0.90 =
// 0.72) — so the default Standard profile warns about CastingOff and
// the Draft profile (which selects the Minimal column per the catalog's
// profile registry) does not. (This is the profile-column contrast the
// b-flat scale's spacing used to show, before spacing_distortion was
// scoped to rhythmic columns and short scores stopped warning; the
// casting-off axis carries the demonstration now.)
let input = ten_measure();
let castoff_warned = |profile: SolverProfile| {
let config = SolverConfig {
profile,
..SolverConfig::default()
@ -610,22 +636,55 @@ mod tests {
matches!(
w.kind,
SolverWarningKind::QualityFloorApproached {
metric: QualityMetricKind::Spacing
metric: QualityMetricKind::CastingOff
}
)
})
};
assert!(spacing_warned(SolverProfile::Standard));
assert!(spacing_warned(SolverProfile::Publication));
assert!(!spacing_warned(SolverProfile::Draft));
assert!(castoff_warned(SolverProfile::Standard));
assert!(castoff_warned(SolverProfile::Publication));
assert!(!castoff_warned(SolverProfile::Draft));
// The metric itself is profile-independent — only the diagnostic
// column changes.
let value = Engraver::default()
.solve(&input, &SolverConfig::default())
.metric_vector
.spacing_distortion
.casting_off_quality
.0;
assert!((0.32..=0.72).contains(&value), "spacing = {value}");
assert!((0.28..=0.72).contains(&value), "casting_off = {value}");
}
#[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]

View File

@ -425,7 +425,7 @@ object is covered); the provenance-preservation contract itself is unchanged.
## Quality Metric Catalog constants (`src/quality.rs`, 2026-07)
**Decision: the catalog's normative constants live in this crate, as a pure
transcription.** The Quality Metric Catalog companion (v0.1.0) pins the nine
transcription.** The Quality Metric Catalog companion (v0.2.0) pins the nine
axes' normalization anchors (`R_worst`), the clamped-linear normalization form
`n = min(1, raw / R_worst)`, the Minimal/Standard threshold table, the
`QualityFloorApproached` warning fraction (0.8), and the tier/profile →

View File

@ -1,7 +1,10 @@
//! The Quality Metric Catalog's normative constants (companion specification
//! *Epiphany — Quality Metric Catalog*, v0.1.0): the per-axis normalization
//! *Epiphany — Quality Metric Catalog*, v0.2.0): the per-axis normalization
//! anchors, the per-tier metric threshold table, the profile→threshold-column
//! mapping, and the `QualityFloorApproached` warning fraction.
//! mapping, and the `QualityFloorApproached` warning fraction. (The v0.2.0
//! `spacing_distortion` refinement scoped that axis's *measurement domain* to
//! rhythmic columns; it touched no anchor or threshold, so this transcription
//! of the numeric constants is unchanged.)
//!
//! This module is a **transcription**, not an invention: every number here is
//! pinned by the catalog and cited to its chapter. Solvers that compute real
@ -202,7 +205,7 @@ pub fn tier_thresholds(tier: SolverTier) -> Option<&'static MetricThresholds> {
/// The threshold column a **registered profile** selects (catalog Chapter 6,
/// `req:qmc:profiles`): `Draft` → the Minimal column (few warnings, fast
/// iteration); `Standard` and `Publication` → the Standard column (no column
/// tighter than Standard is ratified in v0.1). This is the column the solver's
/// tighter than Standard is ratified as of v0.2). This is the column the solver's
/// own `QualityFloorApproached` diagnostics reference during ordinary solves;
/// suite evaluation at a claimed tier always uses that *tier's* column
/// ([`tier_thresholds`]).

View File

@ -122,7 +122,7 @@ pub fn entries() -> Vec<SuiteEntry> {
construction: "fixtures::ten_measure_single_staff(0x000A_11CE)",
build: rs1,
// Passes every Minimal threshold under the reference engraver
// (engrave v3, QMC v0.1.0 anchors). Greedy first-fit alone left a
// (engrave v3, QMC v0.2.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

View File

@ -67,7 +67,7 @@ code instead is the failure mode this batch exists to prevent.
| 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~~ **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: 38-column entries with a wide clef/key lead measure spacing CV 0.360.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) |
| ~~P12-I12~~ **RESOLVED (QMC 0.2.0: rhythmic-column spacing)** | `epiphany-engrave` I | The Standard-tier spacing floor warned on short healthy scores: 38-column entries with a wide clef/key lead measured spacing CV 0.360.41 > the 0.32 Standard floor (a spurious *diagnostic* — the scores all passed Minimal). **Resolved by the lead-aware refinement (Quality Metric Catalog 0.1.0 → 0.2.0):** `spacing_distortion` is now scoped to the system's **rhythmic columns** (slots bearing a notehead or rest), excluding the clef / key-signature / time-signature lead and treating barlines transparently (a note-to-note advance spans them) — so the furniture-width lead gap no longer inflates the CV. The three entries drop to 0.2188 / 0.0819 / 0.0856 (all below the 0.32 floor), and the axis stays honest on real irregularity (RS-3 keeps 0.2188 from a mid-line accidental). Anchor, orientation, range, thresholds, and the eight other axes unchanged; no layout/golden change (measurement-only, `ENGRAVER_VERSION` untouched). The **duration-aware optical-spacing** open question (deviation from duration-proportional spacing) stays open — it needs the pipeline's deferred duration-proportional preferred widths. | ✅ done |
## Not yet open elsewhere
@ -80,10 +80,12 @@ 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. 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.
2026-07-03) added I11..I12. Follow-ups (2026-07-03) resolved both (struck
through above): I11 by an engrave-side casting-off widow-rebalance phase
(engrave v3, no catalog change), and I12 by scoping `spacing_distortion` to
rhythmic columns (Quality Metric Catalog 0.1.0 → 0.2.0). The only remaining
spacing follow-up is the catalog's duration-aware optical-spacing open
question, which lives in the companion, not as a batch row.
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

View File

@ -60,3 +60,17 @@ 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 |
## P12-I12 resolution (2026-07-03) — Quality Metric Catalog 0.1.0 → 0.2.0
Unlike P12-I11, the second measured finding's honest fix is a **catalog
change**: the defect was in the metric's own definition, not the engraver.
| Item | Disposition | Spec locus | Consumer |
|---|---|---|---|
| P12-I12 spacing floor on short scores | **fixed (metric measurement domain)**`spacing_distortion`'s raw measurement is scoped to the system's **rhythmic columns** (spring slots bearing a notehead or rest); the clef / key-signature / time-signature lead and barlines contribute no column, so a note-to-note advance spans them. Rationale: the leading clef-to-first-note gap is furniture width, not note spacing, and folding it into the CV flagged perfectly-spaced short lines as distorted purely for carrying a clef. The three warning entries drop from 0.360.41 to 0.2188 / 0.0819 / 0.0856 (below the Standard 0.32 floor), and the axis stays live on real irregularity. The `1.0` anchor, orientation, range, tier thresholds, and the eight other axes are **unchanged**; this is measurement-only — no layout, canonical bytes, render golden, or `ENGRAVER_VERSION` change. Catalog **0.1.0 → 0.2.0** (a normative refinement of one axis's measurement domain). The duration-aware optical-spacing open question stays open | quality\_metric\_catalog §`spacing_distortion` (`req:qmc:spacing`); revision-history row 0.2.0 | `epiphany-engrave` (`quality::census` rhythmic-column filter, `is_rhythmic`); `epiphany-testkit` (RS-3/5/6 measured values drop, still Pass) |
**Version movements.** Quality Metric Catalog 0.1.0 → 0.2.0
(`spacing_distortion` scoped to rhythmic columns). Core spec unchanged (the
axis's formal definition is delegated to the catalog; Chapter 9 carries only
the field, which is unchanged).

Binary file not shown.

View File

@ -226,7 +226,7 @@
{\Large\scshape\color{epiphanyslate}Quality Metric Catalog}\\[6pt]
{\large\itshape\color{epiphanyslate}A companion to the Core Specification}\\[14pt]
{\color{epiphanygold}\rule{3in}{0.8pt}}\\[24pt]
{\normalsize\color{epiphanyink}Version 0.1.0 --- Phase 3 (the normative metric set: formal definitions, normalization, weights, tier thresholds, profile registry)}\\[4pt]
{\normalsize\color{epiphanyink}Version 0.2.0 --- Phase 3 (the normative metric set: formal definitions, normalization, weights, tier thresholds, profile registry; \texttt{spacing\_distortion} scoped to rhythmic columns)}\\[4pt]
{\small\color{epiphanyslate}Normative for the metrics and thresholds it defines}
\vfill
\end{titlepage}
@ -246,7 +246,7 @@ deliver ``per-metric normalization functions mapping raw measurements to
metric thresholds, and the formal definition of each quality metric in the
normative metric set.''
This release (v0.1.0) delivers all four chartered items, plus two small
This companion (v0.2.0) delivers all four chartered items, plus two small
registries the core specification names but defers here:
\begin{itemize}
@ -721,44 +721,62 @@ the resolved layout already carries.
\section{\texttt{spacing\_distortion}}
\label{sec:metrics:spacing}
\textbf{Phenomenon.} Uneven horizontal distribution within a system:
columns bunched together here and stretched apart there, where the
underlying spring model asked for near-uniform advances.
\textbf{Phenomenon.} Uneven horizontal distribution within a system: the
\emph{rhythmic} columns --- those carrying notes and rests --- bunched
together here and stretched apart there, where the underlying spring model
asked for near-uniform advances.
\begin{requirement}
\label{req:qmc:spacing}
\textbf{Contributing units:} systems $s$ with $m_s \ge 3$ (at least two
advances).
\textbf{Rhythmic columns.} A \emph{rhythmic column} of a system is a
horizontal spring slot at least one of whose glyphs is a notehead or a
rest. The clef, key-signature, and time-signature lead and the barlines are
\textbf{not} rhythmic columns: their horizontal extent is notational
furniture, fixed by their content rather than by rhythm.
\textbf{Raw measurement:} per unit,
$\mathit{raw}_s = \mathrm{CV}(a^s_1, \dots, a^s_{m_s - 1})$; the axis raw
value is the arithmetic mean of $\mathit{raw}_s$ over contributing units.
\textbf{Contributing units:} systems $s$ with at least three rhythmic
columns (so at least two rhythmic advances).
\textbf{Raw measurement:} let $x^s_1 < \dots < x^s_{k_s}$ be the reference
x-positions of the system's $k_s$ rhythmic columns in order, and
$a^s_i = x^s_{i+1} - x^s_i$ the advance between consecutive rhythmic columns
(a note-to-note advance spans any barline or furniture that falls between
the two, so barlines are transparent, not breaks in the sequence). Then
$\mathit{raw}_s = \mathrm{CV}(a^s_1, \dots, a^s_{k_s - 1})$, and the axis
raw value is the arithmetic mean of $\mathit{raw}_s$ over contributing
units.
\textbf{Normalization:} $R_{\mathrm{worst}} = 1.0$;
$n = \min(1, \mathit{raw})$.
\end{requirement}
\begin{rationale}
A coefficient of variation of $1.0$ means the typical column advance
deviates from the mean by the whole mean --- spacing with no discernible
regularity. v0.1 defines \emph{geometric} regularity deliberately: the
reference spring model's preferred widths are uniform, so regular advances
are exactly what its ideal output looks like, and the collision minima
(accidental overhangs, wide columns) that legitimately perturb advances
are modest on realistic scores.
A coefficient of variation of $1.0$ means the typical advance deviates from
the mean by the whole mean --- spacing with no discernible regularity. v0.2
defines \emph{geometric} regularity over the \emph{rhythmic} columns
deliberately: the reference spring model's preferred widths are uniform, so
regular note/rest advances are exactly what its ideal output looks like,
and the collision minima (accidental overhangs, wide columns) that
legitimately perturb those advances are modest on realistic scores. The
axis is scoped to rhythmic columns because a leading clef, key signature,
or time signature is furniture whose width the spring model does not govern:
folding the wide clef-to-first-note gap into the CV would flag a
perfectly-spaced short line as distorted purely for carrying a clef ---
measuring furniture, not spacing.
\end{rationale}
\begin{openquestion}
\textbf{Optical spacing at the Standard tier.} Mature engraving spaces
columns proportionally to musical duration (with an optical correction),
not uniformly; under a duration-proportional model, this axis's ideal
would be ``advances proportional to the column's duration share,'' and a
perfectly optically-spaced line would score \emph{worse} than a uniform
one under the v0.1 definition. When the layout pipeline gains
duration-aware preferred widths, should the Standard tier redefine
rhythmic columns proportionally to musical duration (with an optical
correction), not uniformly; under a duration-proportional model, this
axis's ideal would be ``advances proportional to the column's duration
share,'' and a perfectly optically-spaced line would score \emph{worse}
than a uniform one under the v0.2 definition. When the layout pipeline
gains duration-aware preferred widths, should the Standard tier redefine
$\mathit{raw}_s$ as deviation from the duration-proportional ideal while
Minimal keeps geometric regularity? v0.1 defines geometric regularity
only.
Minimal keeps geometric regularity? v0.2 scopes the geometric definition to
rhythmic columns (removing the leading-furniture false positive on short
scores) but still measures geometric, not duration-proportional, regularity.
\end{openquestion}
\section{\texttt{slur\_shape\_penalty}}
@ -1249,7 +1267,7 @@ selections:
fraction is evaluated during ordinary solves. \texttt{Draft} selects
the Minimal column (few warnings, fast iteration);
\texttt{Standard} and \texttt{Publication} select the Standard
column. In v0.1 no column tighter than Standard is ratified;
column. As of v0.2 no column tighter than Standard is ratified;
\texttt{Publication} is registered now so that documents and
configurations can name it, and a future revision \MAY{} give it a
tighter column without a schema change.
@ -1302,6 +1320,18 @@ than inventing a parallel one.
Open questions: the notated-but-unrendered honesty edge, optical
spacing at the Standard tier, threshold tuning pending reference-suite
experience. \\
\today & \hyperref[sec:metrics:spacing]{\texttt{spacing\_distortion}}
& 0.2.0 --- Scope the \texttt{spacing\_distortion} raw measurement to the
system's \emph{rhythmic} columns (slots bearing a notehead or rest),
excluding the clef / key-signature / time-signature lead and treating
barlines transparently (a note-to-note advance spans them). Resolves the
reference-suite false positive in which a short healthy line's wide
clef-to-first-note gap inflated the CV above the Standard warning floor
(measured 0.36--0.41 on the three- to eight-column entries $\to$
0.08--0.22, below the floor) without weakening the axis on real spacing
irregularity. Normalization anchor, orientation, range, thresholds, and
the eight other axes are unchanged; the optical-spacing open question
(duration-proportional spacing) stays open. Batch item P12-I12. \\
\bottomrule
\end{longtable}