Phase 3 tranche 2: quality conformance companions, real metrics, multi-system click fix
857 workspace tests pass; clippy -D warnings, fmt, and rustdoc clean; both new companions build with zero undefined references. Quality Metric Catalog v0.1.0 (spec/quality_metric_catalog.tex, new): - Formal definitions for all nine normative quality metrics, each with a raw measurement over resolved geometry and a clamped-linear normalization n = min(1, raw/R_worst) with pinned anchors. - The vacuous-geometry rule (a metric over absent geometry evaluates to 0.0; the notated-but-unrendered honesty edge is an open question), all-1.0 default tie-breaking weights, and the per-tier threshold table — Minimal's uniform 0.90 deliberately fails the all-worst placeholder, forcing real measurement. - Pins QualityMetricKind (referenced but never defined by the core spec) and the registered SolverProfile catalog (Draft selects the Minimal threshold column; Standard/Publication select Standard). - QualityFloorApproached fires at 0.8x the applicable threshold and is status-neutral by requirement. Reference Suite v0.1.0 (spec/reference_suite.tex, new): - Six entries referenced by deterministic builder + seed (RS-1 ten_measure_single_staff, RS-2 valid_score_rich, RS-3..6 corpus fixtures), each with the declared A4-at-8mm-staff solve geometry (Canvas.layout_defaults has no graph home yet, P12-I7). - All entries required at Minimal; the same set is the pre-declared Standard bar (no implementation claims Standard yet). Fixed- expectation tests deliberately unused in v0.1. Real metrics in the engraver (engrave/src/quality.rs, new; layout-ir/src/quality.rs = the catalog constants transcribed): - QualityMetricVector::unmeasured() replaced with computed values: collision sweep with the catalog's same-slot-cluster and stroke exclusions, per-system spacing CV, vertical gap deviations, system-break slack, page fill, casting-off width CV, symbol density; slur/beam vacuously 0.0 (no drawn geometry exists). - Bit-identical across repeated solves (tested); floor warnings never change solve status; malformed inputs keep unmeasured(). The two all-worst test pins now assert real values; the StubSolver's unmeasured() stays (Stub genuinely computes nothing). Reference-suite harness (testkit reference_suite module + tests): - Each RS entry asserts the four-condition Minimal pass (hard constraints, byte/bit determinism, well-formed Minimal report, every axis within threshold) under the F1 Pass/Xfail discipline, with the measured table printed per run. - HONEST FINDING, day one: RS-1 fails Minimal casting-off (measured 1.0 vs 0.90) — greedy first-fit leaves a two-measure stub last system (width CV 0.6145). Encoded as an asserted Xfail row (fails on XPASS) and filed as P12-I11 (engrave balance pass, or catalog revision). P12-I12: the Standard spacing floor warns on short scores with wide lead measures. Multi-system click-to-insert fix (editor-core): - Casting-off exposed two inversion breaks: position_anchors fed a non-monotonic cross-system anchor list into a monotonic inverter (system-2 clicks resolved to system-1 times), and nearest_manifestation found only system 1's staff-line segment (system-2 clicks got system-1 pitch geometry). - Fixed with a containing-system lookup over the resolved pages tree (containment, else nearest by vertical distance), per-system staff resolution, and per-system anchor filtering; degenerate-geometry fallback preserves the flat path, so all 84 pre-existing editor-core tests pass unmodified. - Five regression tests through the real Engraver over the wrapped ten-measure fixture, each shown to fail without the fix; testkit gains dev-only dependencies on editor-core and engrave. Trackers: P12-I11/I12 filed; DECISIONS entries in engrave, layout-ir, and testkit; Phase-3 memory updated. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
This commit is contained in:
parent
0316160395
commit
b9a9921d50
|
|
@ -1280,6 +1280,8 @@ dependencies = [
|
|||
"epiphany-bundle",
|
||||
"epiphany-core",
|
||||
"epiphany-determinism",
|
||||
"epiphany-editor-core",
|
||||
"epiphany-engrave",
|
||||
"epiphany-layout-ir",
|
||||
"epiphany-ops",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ use epiphany_core::{
|
|||
use epiphany_layout_ir::{
|
||||
active_clef, manifestation_layout_id, staff_step_pitch, to_constrained, to_logical, to_render,
|
||||
ConstraintSolver, ExtensionRef, HitTestMap, LayoutContent, LayoutObjectId, LogicalLayoutIR,
|
||||
Point, RenderIR, ResolvedLayoutIR, SolverConfig, TimePoint,
|
||||
Point, Rect, RenderIR, ResolvedLayoutIR, ResolvedSystem, SolverConfig, TimePoint,
|
||||
};
|
||||
use epiphany_ops::{
|
||||
advisory_violations, AcceptOutcome, AuthorId, CausalContext, DeleteEventOp,
|
||||
|
|
@ -543,14 +543,23 @@ impl EditorSession {
|
|||
})
|
||||
}
|
||||
|
||||
/// The manifested staff a world `point` is nearest, by 2D proximity — its
|
||||
/// `(region, staff instance)` and the staff's step-origin `y`. Horizontal span
|
||||
/// first (which region — one staff tiles across regions that can share a y band),
|
||||
/// then the vertical band (which staff within it). The bottom staff line carries
|
||||
/// the staff's manifestation id as its stroke `stable_id`, which is how a rendered
|
||||
/// line maps back to `(region, staff_instance)`. Both halves of click-to-insert —
|
||||
/// [`Self::staff_pitch_at`] (pitch) and [`Self::position_at`] (position) — select
|
||||
/// the staff/region through this. `None` on a non-finite point or no staff line.
|
||||
/// The manifested staff a world `point` is nearest — its `(region, staff
|
||||
/// instance)` and the staff's step-origin `y`. Both halves of click-to-insert —
|
||||
/// [`Self::staff_pitch_at`] (pitch) and [`Self::position_at`] (position) —
|
||||
/// select the staff/region through this. `None` on a non-finite point or no
|
||||
/// staff line.
|
||||
///
|
||||
/// When the layout carries real cast-off page geometry, the staff is resolved
|
||||
/// *within the system under the click* ([`Self::system_manifestation`]):
|
||||
/// casting-off splits a staff's lines per system, and only the first segment
|
||||
/// keeps the manifestation `stable_id`, so the flat scan below would always
|
||||
/// answer with system 1's origin/span. Without cast geometry (a solver that
|
||||
/// does not cast off, e.g. the stub), the flat scan is the whole story:
|
||||
/// horizontal span first (which region — one staff tiles across regions that
|
||||
/// can share a y band), then the vertical band (which staff within it). The
|
||||
/// bottom staff line carries the staff's manifestation id as its stroke
|
||||
/// `stable_id`, which is how a rendered line maps back to
|
||||
/// `(region, staff_instance)`.
|
||||
fn nearest_manifestation(&self, point: Point) -> Option<(RegionId, &StaffInstance, f32)> {
|
||||
// Reject a non-finite click up front: `dist_to_band`'s `<`/`>` would let a
|
||||
// NaN fall through as distance 0 (matching every staff), and downstream a
|
||||
|
|
@ -559,8 +568,9 @@ impl EditorSession {
|
|||
if !point.x.0.is_finite() || !point.y.0.is_finite() {
|
||||
return None;
|
||||
}
|
||||
// A 5-line staff spans four staff spaces above its bottom line.
|
||||
const STAFF_SPAN: f32 = 4.0;
|
||||
if let Some(found) = self.system_manifestation(point) {
|
||||
return Some(found);
|
||||
}
|
||||
let mut best: Option<(RegionId, &StaffInstance, f32)> = None;
|
||||
let mut best_dist = (f32::INFINITY, f32::INFINITY);
|
||||
for (region, si) in self.score.staff_instances() {
|
||||
|
|
@ -592,14 +602,114 @@ impl EditorSession {
|
|||
best
|
||||
}
|
||||
|
||||
/// The manifested staff under `point`, resolved through the cast-off page tree
|
||||
/// — the multi-system path of [`Self::nearest_manifestation`]. Finds the system
|
||||
/// under the click ([`Self::containing_system`]), reads the region it manifests
|
||||
/// from its provenance, picks the vertically nearest staff band among the
|
||||
/// system's staff records, and recovers that staff's step origin **in this
|
||||
/// system**. `None` when no system carries real geometry (the caller then falls
|
||||
/// back to the flat stroke scan), or when the containing system carries no
|
||||
/// usable staff record.
|
||||
fn system_manifestation(&self, point: Point) -> Option<(RegionId, &StaffInstance, f32)> {
|
||||
let system = self.containing_system(point)?;
|
||||
// A system manifests one region, and carries it as its provenance source
|
||||
// whether it is the region's first system (the region's own provenance) or
|
||||
// a later one (synthesized under `EngravedBreak` *from the region*) — read
|
||||
// the identity from the data rather than assuming which system this is.
|
||||
let TypedObjectId::Region(region) = system.provenance.source else {
|
||||
return None;
|
||||
};
|
||||
// The nearest staff band vertically: within one system the region is fixed,
|
||||
// and its staves are stacked in disjoint y bands, so — unlike the flat
|
||||
// scan, where x picks the region first — the vertical distance alone is
|
||||
// the discriminator.
|
||||
let staff = system
|
||||
.staves
|
||||
.iter()
|
||||
.filter(|s| rect_is_real(&s.bounding_box))
|
||||
.min_by(|a, b| {
|
||||
let da = dist_to_band(point.y.0, rect_y_band(&a.bounding_box));
|
||||
let db = dist_to_band(point.y.0, rect_y_band(&b.bounding_box));
|
||||
da.total_cmp(&db)
|
||||
})?;
|
||||
// The staff record's provenance is its bottom-most rendered line *in this
|
||||
// system* (`build_system` in the engraver's casting pass); that stroke's
|
||||
// height is the exact step origin the pitch math expects. Fall back to
|
||||
// deriving it from the staff's box, whose vertical extent is the 5-line
|
||||
// span padded by the line half-thickness on both sides — the bottom line
|
||||
// sits half the (span + padding) height above the box bottom, minus half
|
||||
// the span.
|
||||
let origin = self
|
||||
.resolved
|
||||
.strokes
|
||||
.iter()
|
||||
.find(|s| s.provenance.stable_id == staff.provenance.stable_id)
|
||||
.map(|s| s.from.y.0)
|
||||
.unwrap_or_else(|| {
|
||||
let b = &staff.bounding_box;
|
||||
b.origin.y.0 + b.size.height.0 / 2.0 - STAFF_SPAN / 2.0
|
||||
});
|
||||
let si = self
|
||||
.score
|
||||
.staff_instances()
|
||||
.find(|(r, si)| *r == region && si.staff == staff.staff)
|
||||
.map(|(_, si)| si)?;
|
||||
Some((region, si, origin))
|
||||
}
|
||||
|
||||
/// The cast-off system whose bounding box contains `point`, or — when the
|
||||
/// point is in the gutter between systems — the **nearest system by vertical
|
||||
/// distance**: systems on a page all start at the left margin, so they overlap
|
||||
/// in x and are disjoint in y, making the y band the discriminator (and a
|
||||
/// click slightly above/below a system still resolves, mirroring the flat
|
||||
/// path's nearest-staff tolerance). Only a system with real (non-degenerate)
|
||||
/// geometry is a candidate: a solver that does not cast off (the stub) emits
|
||||
/// zero-size boxes, and those must not capture clicks — `None` sends the
|
||||
/// caller down the flat single-system path unchanged.
|
||||
fn containing_system(&self, point: Point) -> Option<&ResolvedSystem> {
|
||||
if !point.x.0.is_finite() || !point.y.0.is_finite() {
|
||||
return None;
|
||||
}
|
||||
let mut nearest: Option<&ResolvedSystem> = None;
|
||||
let mut nearest_dy = f32::INFINITY;
|
||||
for system in self.resolved.pages.iter().flat_map(|p| p.systems.iter()) {
|
||||
let bounds = &system.bounding_box;
|
||||
if !rect_is_real(bounds) {
|
||||
continue;
|
||||
}
|
||||
if rect_contains(bounds, point) {
|
||||
return Some(system);
|
||||
}
|
||||
let dy = dist_to_band(point.y.0, rect_y_band(bounds));
|
||||
// Strict `<`: on a tie, the earlier system in page/reading order wins
|
||||
// (deterministic, and the gutter midpoint resolves upward).
|
||||
if dy < nearest_dy {
|
||||
nearest_dy = dy;
|
||||
nearest = Some(system);
|
||||
}
|
||||
}
|
||||
nearest
|
||||
}
|
||||
|
||||
/// The musical position a world `point` snaps to on the beat grid — the
|
||||
/// **horizontal half** of a click-to-insert. Finds the metric region under the
|
||||
/// cursor, inverts the click's `x` to a raw musical position (piecewise-linear
|
||||
/// through the region's rendered event anchors), then snaps it to `grid`. `None`
|
||||
/// if the click is off any staff, the region is non-metric (a proportional or
|
||||
/// aleatoric region has no musical position to land on), `grid` is non-positive,
|
||||
/// or the region has fewer than two rendered metric events to fix a scale from.
|
||||
/// or there are fewer than two rendered metric events to fix a scale from.
|
||||
/// The vertical half (the pitch) is [`Self::staff_pitch_at`].
|
||||
///
|
||||
/// In a cast-off multi-system layout the inverse works **within the system
|
||||
/// under the click**: each system restarts at the page's left margin, so one x
|
||||
/// names a different time on each system. A click right of a system's last
|
||||
/// anchor extrapolates that system's end segment (the empty staff after its
|
||||
/// last note — the same end-extrapolation as the flat layout, and it may name
|
||||
/// a time that *renders* on the next system: the result is a musical position,
|
||||
/// not a system-local one); a click left of its first anchor extrapolates
|
||||
/// backward and clamps at the region origin; and a system rendering fewer than
|
||||
/// two of the region's anchors yields `None`, the per-system reading of the
|
||||
/// two-anchor rule above.
|
||||
pub fn position_at(&self, point: Point, grid: &GridResolution) -> Option<GridPosition> {
|
||||
if !grid.step.is_positive() {
|
||||
return None;
|
||||
|
|
@ -610,9 +720,16 @@ impl EditorSession {
|
|||
if !self.region_is_metric(region) {
|
||||
return None;
|
||||
}
|
||||
// Constrain the anchors to the system under the click: casting-off bakes
|
||||
// every system back to the left margin, so the region-wide anchor list is
|
||||
// x-non-monotonic in time, and inverting through it would map a later
|
||||
// system's click onto the first system's times. Without cast geometry
|
||||
// (`containing_system` is `None` — the stub) the whole region is one flat
|
||||
// monotonic run, unchanged.
|
||||
let system_box = self.containing_system(point).map(|s| s.bounding_box);
|
||||
// Two anchors fix the x→time scale; with fewer, the spacing density is
|
||||
// unknown, so there is nothing to extrapolate an empty-space position from.
|
||||
let anchors = self.position_anchors(region);
|
||||
let anchors = self.position_anchors(region, system_box.as_ref());
|
||||
if anchors.len() < 2 {
|
||||
return None;
|
||||
}
|
||||
|
|
@ -672,7 +789,17 @@ impl EditorSession {
|
|||
/// in ascending time order — the samples the horizontal inverse interpolates. A
|
||||
/// glyph maps to its onset through its `Pitch`/`Event` provenance source; the
|
||||
/// leftmost glyph at an onset (the notehead/stem column) fixes that onset's x.
|
||||
fn position_anchors(&self, region: RegionId) -> Vec<(MusicalPosition, f32)> {
|
||||
///
|
||||
/// With `within` (a cast-off system's bounding box), only glyphs positioned
|
||||
/// inside that box are sampled: casting-off restarts every system at the left
|
||||
/// margin, so the region-wide list is x-non-monotonic in time, and the inverse
|
||||
/// must see a single system's monotonic run. `None` samples the whole region —
|
||||
/// the flat single-system behavior.
|
||||
fn position_anchors(
|
||||
&self,
|
||||
region: RegionId,
|
||||
within: Option<&Rect>,
|
||||
) -> Vec<(MusicalPosition, f32)> {
|
||||
// Source id (event or one of its pitches) → the event's metric onset.
|
||||
let mut onset: HashMap<TypedObjectId, MusicalPosition> = HashMap::new();
|
||||
let mut pitches: Vec<&IdentifiedPitch> = Vec::new();
|
||||
|
|
@ -705,6 +832,11 @@ impl EditorSession {
|
|||
if glyph.provenance.synthesis.is_some() {
|
||||
continue;
|
||||
}
|
||||
// Constrain to the requested system's box: a glyph on another system
|
||||
// must not contribute an anchor to this system's monotonic run.
|
||||
if within.is_some_and(|bounds| !rect_contains(bounds, glyph.position)) {
|
||||
continue;
|
||||
}
|
||||
if let Some(at) = onset.get(&glyph.provenance.source) {
|
||||
let x = glyph.position.x.0;
|
||||
by_onset
|
||||
|
|
@ -1995,6 +2127,9 @@ fn staff_step(pitch: &Pitch, steps: i32) -> Option<Pitch> {
|
|||
Some(moved)
|
||||
}
|
||||
|
||||
/// A 5-line staff spans four staff spaces above its bottom line.
|
||||
const STAFF_SPAN: f32 = 4.0;
|
||||
|
||||
/// The distance from height `y` to a staff's line band `(bottom, top)`: zero inside
|
||||
/// the band, else the gap to the nearer edge. Used to pick the staff a click is over.
|
||||
fn dist_to_band(y: f32, (bottom, top): (f32, f32)) -> f32 {
|
||||
|
|
@ -2007,6 +2142,35 @@ fn dist_to_band(y: f32, (bottom, top): (f32, f32)) -> f32 {
|
|||
}
|
||||
}
|
||||
|
||||
/// Whether a resolved bounding box carries **real** cast-off geometry: finite
|
||||
/// origin and strictly positive extent on both axes. A solver that does not cast
|
||||
/// off (the stub) emits `Rect::default()` — zero-size — boxes, which must not
|
||||
/// capture clicks; the callers fall back to the flat single-system paths instead.
|
||||
fn rect_is_real(rect: &Rect) -> bool {
|
||||
let width = rect.size.width.0;
|
||||
let height = rect.size.height.0;
|
||||
rect.origin.x.0.is_finite()
|
||||
&& rect.origin.y.0.is_finite()
|
||||
&& width.is_finite()
|
||||
&& height.is_finite()
|
||||
&& width > 0.0
|
||||
&& height > 0.0
|
||||
}
|
||||
|
||||
/// A rect's vertical band as `(bottom, top)`, the shape [`dist_to_band`] takes.
|
||||
fn rect_y_band(rect: &Rect) -> (f32, f32) {
|
||||
(rect.origin.y.0, rect.origin.y.0 + rect.size.height.0)
|
||||
}
|
||||
|
||||
/// Whether `point` lies within `rect`, edges included (a glyph exactly on a
|
||||
/// system's edge belongs to that system).
|
||||
fn rect_contains(rect: &Rect, point: Point) -> bool {
|
||||
point.x.0 >= rect.origin.x.0
|
||||
&& point.x.0 <= rect.origin.x.0 + rect.size.width.0
|
||||
&& point.y.0 >= rect.origin.y.0
|
||||
&& point.y.0 <= rect.origin.y.0 + rect.size.height.0
|
||||
}
|
||||
|
||||
/// Inverts an `x` coordinate to a raw musical position through `(onset, x)` anchors
|
||||
/// in ascending order (`>= 2`, leftmost first) — the horizontal inverse before grid
|
||||
/// snapping. Within the anchored span it interpolates the bracketing segment; outside
|
||||
|
|
@ -2734,6 +2898,13 @@ mod tests {
|
|||
}
|
||||
|
||||
/// `region`'s rendered bottom staff line as `(left_x, right_x, origin_y)`.
|
||||
///
|
||||
/// **Flat-layout (stub) helper**: it finds the stroke carrying the staff's
|
||||
/// manifestation id, which in a cast-off layout is only the *first* system's
|
||||
/// segment. Every test here runs on the [`StubSolver`], which never splits a
|
||||
/// line, so the first segment is the whole line; multi-system geometry is
|
||||
/// exercised via [`install_two_system_geometry`] and, over the real engraver,
|
||||
/// by the testkit's `multisystem_click` integration test.
|
||||
fn region_staff_line(session: &EditorSession, region: RegionId) -> (f32, f32, f32) {
|
||||
let (_, si) = session
|
||||
.score()
|
||||
|
|
@ -2765,7 +2936,7 @@ mod tests {
|
|||
fn position_at_snaps_a_click_to_the_beat_grid() {
|
||||
let session = open_rich(0x5EED);
|
||||
let region = a_region_with(&session, true);
|
||||
let anchors = session.position_anchors(region);
|
||||
let anchors = session.position_anchors(region, None);
|
||||
assert!(
|
||||
anchors.len() >= 2,
|
||||
"the metric region renders multiple notes"
|
||||
|
|
@ -2861,7 +3032,7 @@ mod tests {
|
|||
// The onset's anchor must be the notehead x, not the (leftmost) accidental
|
||||
// — the exact check, independent of how coarse the grid is.
|
||||
let anchor_x = session
|
||||
.position_anchors(region)
|
||||
.position_anchors(region, None)
|
||||
.into_iter()
|
||||
.find(|(o, _)| o == onset)
|
||||
.map(|(_, x)| x)
|
||||
|
|
@ -2916,6 +3087,293 @@ mod tests {
|
|||
assert_eq!(session.position_at(at, &zero), None);
|
||||
}
|
||||
|
||||
/// Where [`install_two_system_geometry`] puts each system's staff bottom line
|
||||
/// (the step origin), in world y: system 1 on top, system 2 below it.
|
||||
const SYS1_ORIGIN_Y: f32 = 0.0;
|
||||
const SYS2_ORIGIN_Y: f32 = -20.0;
|
||||
|
||||
/// Overwrites `session`'s resolved geometry with a hand-built **two-system
|
||||
/// cast-off layout** over its single metric region — the shape the real
|
||||
/// engraver produces and the stub never does. The first half of the region's
|
||||
/// onsets renders on system 1, the rest on system 2; both systems start at the
|
||||
/// same left margin (x restarts, so the region-wide anchor list is
|
||||
/// x-non-monotonic in time) and sit in disjoint y bands. Each system carries a
|
||||
/// staff record whose provenance is its own bottom-line stroke — system 1 the
|
||||
/// staff's manifestation provenance, system 2 a synthesized continuation —
|
||||
/// exactly as the engraver's casting pass writes them. Only the resolved
|
||||
/// geometry is replaced (render/hit-test stay the stub's): these tests
|
||||
/// exercise the resolved-geometry queries alone.
|
||||
///
|
||||
/// Returns the region, each event as `(onset, anchor x, system index)` in
|
||||
/// onset order, and the two system bounding boxes.
|
||||
fn install_two_system_geometry(
|
||||
session: &mut EditorSession,
|
||||
) -> (RegionId, Vec<(MusicalPosition, f32, usize)>, Rect, Rect) {
|
||||
use epiphany_layout_ir::{
|
||||
BoundingBox, GlyphReference, GlyphStyle, Margins, Provenance, ResolvedGlyph,
|
||||
ResolvedPage, ResolvedStaff, Size2D, StaffSpace, Stroke, SynthesisInstanceKey,
|
||||
SynthesisKind,
|
||||
};
|
||||
|
||||
let region = a_region_with(session, true);
|
||||
let staff = session
|
||||
.score()
|
||||
.staff_instances()
|
||||
.find(|(r, _)| *r == region)
|
||||
.map(|(_, si)| si.staff)
|
||||
.expect("the metric region has a staff instance");
|
||||
let events = region_pitched_events(session, region);
|
||||
assert!(
|
||||
events.len() >= 4,
|
||||
"four onsets give each system two anchors to fix a scale"
|
||||
);
|
||||
assert!(
|
||||
events.windows(2).all(|w| w[0].0 < w[1].0),
|
||||
"onsets are strictly ascending (distinct)"
|
||||
);
|
||||
let half = events.len() / 2;
|
||||
|
||||
let staff_source = TypedObjectId::Staff(staff);
|
||||
// System 1 keeps the staff's manifestation provenance; system 2's line is
|
||||
// an engraver-synthesized continuation with its own stable id — the split
|
||||
// casting-off performs on a system-spanning stroke.
|
||||
let line_provenance = [
|
||||
Provenance::manifested(staff_source, region, vec![]),
|
||||
Provenance::synthesized(
|
||||
staff_source,
|
||||
SynthesisKind::EngravedBreak,
|
||||
SynthesisInstanceKey(1),
|
||||
vec![],
|
||||
),
|
||||
];
|
||||
let origins = [SYS1_ORIGIN_Y, SYS2_ORIGIN_Y];
|
||||
|
||||
let systems: Vec<ResolvedSystem> = origins
|
||||
.iter()
|
||||
.zip(&line_provenance)
|
||||
.enumerate()
|
||||
.map(|(s, (&origin, provenance))| ResolvedSystem {
|
||||
provenance: if s == 0 {
|
||||
Provenance::projected(TypedObjectId::Region(region), vec![])
|
||||
} else {
|
||||
Provenance::synthesized(
|
||||
TypedObjectId::Region(region),
|
||||
SynthesisKind::EngravedBreak,
|
||||
SynthesisInstanceKey(2),
|
||||
vec![],
|
||||
)
|
||||
},
|
||||
bounding_box: Rect {
|
||||
origin: Point::new(0.0, origin - 2.0),
|
||||
size: Size2D {
|
||||
width: StaffSpace(90.0),
|
||||
height: StaffSpace(STAFF_SPAN + 4.0),
|
||||
},
|
||||
},
|
||||
staves: vec![ResolvedStaff {
|
||||
provenance: provenance.clone(),
|
||||
staff,
|
||||
bounding_box: Rect {
|
||||
origin: Point::new(0.0, origin - 0.05),
|
||||
size: Size2D {
|
||||
width: StaffSpace(88.0),
|
||||
height: StaffSpace(STAFF_SPAN + 0.1),
|
||||
},
|
||||
},
|
||||
}],
|
||||
measures: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
let strokes: Vec<Stroke> = origins
|
||||
.iter()
|
||||
.zip(&line_provenance)
|
||||
.map(|(&y, provenance)| Stroke {
|
||||
provenance: provenance.clone(),
|
||||
from: Point::new(0.0, y),
|
||||
to: Point::new(88.0, y),
|
||||
thickness: StaffSpace(0.1),
|
||||
layer: 0,
|
||||
style: GlyphStyle::default(),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let mut placed: Vec<(MusicalPosition, f32, usize)> = Vec::new();
|
||||
let glyphs: Vec<ResolvedGlyph> = events
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(i, (onset, pid))| {
|
||||
let system = usize::from(i >= half);
|
||||
let local = if system == 0 { i } else { i - half };
|
||||
// 20 staff spaces per quarter, both systems restarting at x = 10.
|
||||
let x = 10.0 + 20.0 * local as f32;
|
||||
placed.push((onset.clone(), x, system));
|
||||
ResolvedGlyph {
|
||||
provenance: Provenance::manifested(TypedObjectId::Pitch(*pid), region, vec![]),
|
||||
glyph: GlyphReference::borrowed("noteheadBlack"),
|
||||
position: Point::new(x, origins[system] + 1.0),
|
||||
transform: None,
|
||||
bounding_box: BoundingBox::new(0.0, -0.5, 1.2, 0.5),
|
||||
style: GlyphStyle::default(),
|
||||
layer: 0,
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
||||
let (sys1_box, sys2_box) = (systems[0].bounding_box, systems[1].bounding_box);
|
||||
session.resolved.pages = vec![ResolvedPage {
|
||||
provenance: Provenance::projected(TypedObjectId::Region(region), vec![]),
|
||||
number: 1,
|
||||
size: Size2D::default(),
|
||||
margins: Margins::default(),
|
||||
systems,
|
||||
free_objects: Vec::new(),
|
||||
}];
|
||||
session.resolved.glyphs = glyphs;
|
||||
session.resolved.strokes = strokes;
|
||||
(region, placed, sys1_box, sys2_box)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn containing_system_requires_real_cast_geometry() {
|
||||
// The stub's page tree carries only degenerate (zero-size) system boxes:
|
||||
// no system may capture a click, and the flat single-system path stays in
|
||||
// charge — which is what keeps every pre-casting behavior unchanged.
|
||||
let session = open_plain(1);
|
||||
assert!(
|
||||
!session.resolved().pages.is_empty(),
|
||||
"the stub emits a page tree"
|
||||
);
|
||||
assert!(session.containing_system(Point::new(1.0, 0.0)).is_none());
|
||||
let region = a_region_with(&session, true);
|
||||
let at = point_on_region_staff(&session, region);
|
||||
assert!(
|
||||
session.staff_pitch_at(at).is_some(),
|
||||
"the flat path still resolves the click"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn staff_pitch_at_reads_the_clicked_system_origin() {
|
||||
let mut session = open_plain(1);
|
||||
let (_region, _placed, sys1, sys2) = install_two_system_geometry(&mut session);
|
||||
|
||||
// Same staff-relative height, one click per system: the pitch must match —
|
||||
// system 2's step origin is its own bottom line, not system 1's. (The
|
||||
// regression: only the first line segment keeps the manifestation stable
|
||||
// id, so the flat path read every system-2 click against system 1's
|
||||
// origin, ~20 staff spaces off.)
|
||||
let p1 = session
|
||||
.staff_pitch_at(Point::new(30.0, SYS1_ORIGIN_Y + 1.0))
|
||||
.expect("a staff under the system-1 click");
|
||||
let p2 = session
|
||||
.staff_pitch_at(Point::new(30.0, SYS2_ORIGIN_Y + 1.0))
|
||||
.expect("a staff under the system-2 click");
|
||||
assert_eq!(
|
||||
p1.staff_instance, p2.staff_instance,
|
||||
"one staff, two systems"
|
||||
);
|
||||
assert_eq!(
|
||||
(p2.nominal, p2.octave),
|
||||
(p1.nominal, p1.octave),
|
||||
"the same staff-relative height names the same pitch in either system"
|
||||
);
|
||||
|
||||
// The containing system is keyed on the click's y — full containment first…
|
||||
let in_sys2 = session
|
||||
.containing_system(Point::new(30.0, SYS2_ORIGIN_Y + 1.0))
|
||||
.expect("system 2 contains the point");
|
||||
assert_eq!(in_sys2.bounding_box, sys2);
|
||||
// …and a click in the inter-system gutter resolves to the nearest system
|
||||
// by vertical distance (mirroring the nearest-staff tolerance), never to
|
||||
// nothing.
|
||||
let just_under_sys1 = Point::new(30.0, rect_y_band(&sys1).0 - 1.0);
|
||||
assert_eq!(
|
||||
session
|
||||
.containing_system(just_under_sys1)
|
||||
.expect("the gutter still resolves")
|
||||
.bounding_box,
|
||||
sys1
|
||||
);
|
||||
let just_over_sys2 = Point::new(30.0, rect_y_band(&sys2).1 + 1.0);
|
||||
assert_eq!(
|
||||
session
|
||||
.containing_system(just_over_sys2)
|
||||
.expect("the gutter still resolves")
|
||||
.bounding_box,
|
||||
sys2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_at_inverts_within_the_clicked_system() {
|
||||
let mut session = open_plain(1);
|
||||
let (region, placed, _sys1, sys2) = install_two_system_geometry(&mut session);
|
||||
let half = placed.iter().filter(|(_, _, s)| *s == 0).count();
|
||||
// The fixture's onsets are consecutive quarters, so a quarter grid puts
|
||||
// every rendered onset on the grid.
|
||||
let quarter = grid(1, 4);
|
||||
let step = MusicalDuration(RationalTime::new(1, 4).unwrap());
|
||||
|
||||
// Every anchor click snaps to its own onset — in both systems.
|
||||
for (onset, x, system) in &placed {
|
||||
let y = if *system == 0 {
|
||||
SYS1_ORIGIN_Y
|
||||
} else {
|
||||
SYS2_ORIGIN_Y
|
||||
} + 1.0;
|
||||
let gp = session
|
||||
.position_at(Point::new(*x, y), &quarter)
|
||||
.expect("a metric position under the click");
|
||||
assert_eq!(
|
||||
&gp.position, onset,
|
||||
"the click snaps to the clicked system's onset"
|
||||
);
|
||||
}
|
||||
// The regression pinned directly: system 2's first anchor shares its x
|
||||
// with system 1's first anchor but is a *later* time.
|
||||
let (first_sys2_onset, x0, _) = placed[half].clone();
|
||||
let gp = session
|
||||
.position_at(Point::new(x0, SYS2_ORIGIN_Y + 1.0), &quarter)
|
||||
.expect("a metric position under the click");
|
||||
assert_eq!(gp.position, first_sys2_onset);
|
||||
assert!(
|
||||
gp.position > placed[0].0,
|
||||
"a system-2 click is not a system-1 time"
|
||||
);
|
||||
|
||||
// Anchor filtering: within system 2's box the run is monotonic in x and
|
||||
// carries exactly the second half of the onsets; the unfiltered
|
||||
// region-wide list is x-non-monotonic (the hazard the filter removes).
|
||||
let filtered = session.position_anchors(region, Some(&sys2));
|
||||
assert_eq!(filtered.len(), placed.len() - half);
|
||||
assert!(filtered.windows(2).all(|w| w[0].1 < w[1].1));
|
||||
assert_eq!(filtered[0].0, first_sys2_onset);
|
||||
let flat = session.position_anchors(region, None);
|
||||
assert_eq!(flat.len(), placed.len());
|
||||
assert!(
|
||||
!flat.windows(2).all(|w| w[0].1 < w[1].1),
|
||||
"the region-wide anchor list is x-non-monotonic across systems"
|
||||
);
|
||||
|
||||
// End extrapolation stays within the clicked system: one anchor gap right
|
||||
// of a system's last note is that system's next grid slot. For system 1
|
||||
// that names the time system 2 renders first — the result is a musical
|
||||
// position, not a system-local one.
|
||||
let (last_onset, last_x, _) = placed.last().cloned().unwrap();
|
||||
let past = session
|
||||
.position_at(Point::new(last_x + 20.0, SYS2_ORIGIN_Y + 1.0), &quarter)
|
||||
.expect("empty space past the last note still resolves");
|
||||
assert_eq!(past.position, last_onset + step.clone());
|
||||
let (sys1_last_onset, sys1_last_x, _) = placed[half - 1].clone();
|
||||
let hang = session
|
||||
.position_at(
|
||||
Point::new(sys1_last_x + 20.0, SYS1_ORIGIN_Y + 1.0),
|
||||
&quarter,
|
||||
)
|
||||
.expect("system 1's trailing space still resolves");
|
||||
assert_eq!(hang.position, sys1_last_onset + step);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn position_at_rejects_non_finite_clicks() {
|
||||
let session = open_rich(0x5EED);
|
||||
|
|
@ -3088,7 +3546,7 @@ mod tests {
|
|||
position: &MusicalPosition,
|
||||
y: f32,
|
||||
) -> Point {
|
||||
let anchors = session.position_anchors(region);
|
||||
let anchors = session.position_anchors(region, None);
|
||||
let (p0, x0) = (anchors[0].0 .0.to_f64(), anchors[0].1 as f64);
|
||||
let last = anchors.last().unwrap();
|
||||
let (p1, x1) = (last.0 .0.to_f64(), last.1 as f64);
|
||||
|
|
|
|||
|
|
@ -36,9 +36,12 @@ report `SolverTier::Stub`, never `Minimal` (Chapter 9 §"Conformance Tiers").
|
|||
landed and now reports `Minimal` — which it fully earns after casting-off: the
|
||||
break constraint family is genuinely supported (spec §"Conformance Tiers",
|
||||
Minimal row), and `Minimal` makes no optimality claim, so greedy first-fit
|
||||
casting-off is legitimate. The quality-metric vector stays the conservative
|
||||
all-worst placeholder (`QualityMetricVector::unmeasured`) until the Quality Metric
|
||||
Catalog lands (`Standard` tier work).
|
||||
casting-off is legitimate. Since the Quality Metric Catalog companion's
|
||||
ratification, the solve also reports a **real quality-metric vector** —
|
||||
accurate metric vectors are part of the Minimal claim — computed per the
|
||||
catalog's formulas (see "Quality metrics (2026-07)" below). The all-worst
|
||||
placeholder (`QualityMetricVector::unmeasured`) remains only for malformed
|
||||
inputs the solver cannot measure.
|
||||
|
||||
## Implementation decisions (QUICKSTART "Decisions you'll need to make")
|
||||
|
||||
|
|
@ -221,3 +224,106 @@ resolved:
|
|||
itself, not its artefacts). Carried as
|
||||
`Registered(SYSTEM_CONTINUATION_SYNTHESIS)`; the spec should either add a
|
||||
continuation kind or bless the registered id.
|
||||
|
||||
## Quality metrics (2026-07) — decisions
|
||||
|
||||
The Quality Metric Catalog companion (v0.1.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.
|
||||
The catalog's normative constants (anchors, the Minimal/Standard threshold
|
||||
table, the 0.8 warning fraction, the tier/profile→column mappings) are
|
||||
transcribed once in `epiphany_layout_ir::quality` and consumed here and by the
|
||||
testkit's reference-suite harness.
|
||||
|
||||
1. **Where each axis's inputs come from.** All nine are pure functions of the
|
||||
constrained input, the cast layout, and the declared page geometry — data
|
||||
the pipeline already had (see the `quality` module docs for the per-axis
|
||||
map). The casting pass exposes its own glyph→system assignment
|
||||
(`CastLayout::system_of_slot`, `region_of_system`) so the census ranges
|
||||
over what the solve actually did, never a reconstruction. Slot identity
|
||||
(the collision axis's same-column exclusion) is the glyph's
|
||||
`horizontal_slot` in the constrained input, index-parallel to the resolved
|
||||
glyph list. Widths/columns/densities use glyph **ink boxes** per the
|
||||
catalog's measurement domain (strokes are not glyphs); page spans use the
|
||||
resolved page tree's system bounding boxes.
|
||||
2. **Vacuous axes.** `slur_shape_penalty` and `beam_slope_penalty` are exactly
|
||||
`0.0`: the pipeline draws no slur or beam geometry (both exist logically,
|
||||
not as curves/segments), so their contributing-unit sets are empty and the
|
||||
catalog's vacuous-geometry rule (`req:qmc:vacuous`) applies. The catalog's
|
||||
"notated-but-unrendered" open question explicitly owns this honesty edge;
|
||||
the axes are wired so the first slur/beam-drawing release is measured from
|
||||
day one.
|
||||
3. **Vertical density's unit set.** `to_constrained` declares `InterStaffGap`
|
||||
bands but **no** `InterSystemGap` bands (the casting pass reads
|
||||
`VerticalBand::inter_system_gap` directly). Implemented units: (a) the
|
||||
input's `InterStaffGap` bands, adjacency reconstructed from
|
||||
`inter_staff_gap_id(region, g)` (gap *g* separates the region's staves
|
||||
*g−1*/*g*), realized separation measured between the adjacent staff bands'
|
||||
resolved ink extents within a common system — i.e. what the resolved
|
||||
geometry actually shows, since constrained `y` is pass-through; (b) the
|
||||
casting pass's realized inter-system gaps (consecutive systems on a page),
|
||||
measured from the resolved page tree against the same constructor's
|
||||
preferred height the stacking consulted. Today (b) measures realized ≡
|
||||
preferred (raw 0), and (a) is empty for every single-staff-per-region
|
||||
score; a multi-staff region honestly measures ~1.0 because the constrained
|
||||
stage's fixed 12-staff-space pitch is far from the band model's preferred
|
||||
2.0 gap — the metric is truthful, the vertical spring solve that would
|
||||
negotiate it is the deferred work.
|
||||
4. **Floor warnings never change the status.** Catalog
|
||||
`req:qmc:floor-warning`: the `QualityFloorApproached` warning "is
|
||||
diagnostic: emitting it does not change the solve's status". Implemented
|
||||
literally: `status` is computed before the metric census, and quality
|
||||
warnings are appended after — a solve with clean constraints stays
|
||||
`Solved` even when it carries quality diagnostics. (This is also
|
||||
load-bearing for downstream regression locks that assert `Solved` on
|
||||
fixtures whose casting-off quality honestly warns.) The applicable
|
||||
threshold column is the one the config's profile selects
|
||||
(`profile_thresholds`: Draft→Minimal, Standard/Publication→Standard;
|
||||
default profile Standard), so `SolverConfig` is now threaded into
|
||||
`resolve`.
|
||||
5. **Malformed inputs stay unmeasured.** A structurally invalid or
|
||||
forged-catalog input has no trustworthy geometry (the census would sweep
|
||||
unverified boxes), so it keeps `QualityMetricVector::unmeasured()` and
|
||||
earns no floor diagnostics. An `Unsatisfiable` solve of a *valid* problem
|
||||
is measured honestly — its real geometry exists.
|
||||
6. **No-flip verification.** Existing tests asserting `Solved` on healthy
|
||||
fixtures were re-run against the real metrics: none flipped (warnings
|
||||
cannot flip status, and no metric enters the status computation). Two
|
||||
engrave tests asserting `warnings.is_empty()` after an honoured break were
|
||||
narrowed to "no `LargeSoftConstraintViolation`": their micro-fixtures
|
||||
(two-note scores broken at the last note column) honestly cast off into
|
||||
wildly uneven system widths, so the casting-off axis fires its SHOULD-level
|
||||
floor diagnostic — the metric is telling the truth about the layout, and
|
||||
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.
|
||||
|
||||
### 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 (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
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -178,6 +178,14 @@ pub(crate) struct CastLayout {
|
|||
pub system_start_slots: BTreeSet<SpringSlotId>,
|
||||
/// Slots at which a page begins: the first slot of each page's first system.
|
||||
pub page_start_slots: BTreeSet<SpringSlotId>,
|
||||
/// Which system (global index, page order) each realized slot landed in —
|
||||
/// the casting pass's own assignment, which the quality-metric census
|
||||
/// ranges over (a slot absent here was claimed by no region and its glyphs
|
||||
/// belong to no per-system aggregate).
|
||||
pub system_of_slot: BTreeMap<SpringSlotId, usize>,
|
||||
/// The region each system slices, indexed by global system index (the
|
||||
/// per-region grouping the casting-off quality metrics aggregate by).
|
||||
pub region_of_system: Vec<usize>,
|
||||
}
|
||||
|
||||
/// One realized spring slot in spaced (pre-casting) coordinates, with the
|
||||
|
|
@ -759,6 +767,8 @@ pub(crate) fn cast_off(
|
|||
decisions,
|
||||
system_start_slots,
|
||||
page_start_slots,
|
||||
system_of_slot,
|
||||
region_of_system: systems.iter().map(|plan| plan.region).collect(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -45,10 +45,17 @@
|
|||
//! Having earned it, [`Engraver::tier`] reports [`SolverTier::Minimal`] — which
|
||||
//! (Chapter 9 §"Conformance Tiers" / QUICKSTART) means *hard constraints
|
||||
//! satisfied, no claim about optimality* — greedy first-fit casting-off is
|
||||
//! legitimate at this tier. It therefore makes **no normalized-metric claim**:
|
||||
//! the quality-metric vector stays the conservative all-worst "no claim"
|
||||
//! placeholder ([`QualityMetricVector::unmeasured`]) until the Quality Metric
|
||||
//! Catalog lands (Phase 3 / `Standard`). Still deferred to a later tier: the
|
||||
//! legitimate at this tier. The solve reports a **real quality-metric vector**:
|
||||
//! the private `quality` module computes all nine normative axes per the
|
||||
//! ratified *Quality Metric Catalog* companion (collision census, spacing
|
||||
//! regularity, break/page/casting-off distribution, vertical gap deviation;
|
||||
//! slur/beam shape are vacuous-`0.0` because no drawn slur/beam geometry exists
|
||||
//! yet), normalized through the catalog's pinned anchors
|
||||
//! ([`epiphany_layout_ir::quality`]), with
|
||||
//! [`SolverWarningKind::QualityFloorApproached`] diagnostics against the
|
||||
//! threshold column the config's profile selects. The all-worst
|
||||
//! [`QualityMetricVector::unmeasured`] placeholder remains only for malformed
|
||||
//! inputs the solver cannot measure. Still deferred to a later tier: the
|
||||
//! **vertical spring pass** (glyph `y` within a system is the constrained
|
||||
//! natural staff layout, preserved verbatim; systems stack by real content
|
||||
//! extents), per-system justification/stretch, and optimal break search.
|
||||
|
|
@ -63,16 +70,17 @@
|
|||
//! [`epiphany-render-svg`]: ../epiphany_render_svg/index.html
|
||||
|
||||
pub mod casting;
|
||||
mod quality;
|
||||
mod spacing;
|
||||
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use epiphany_layout_ir::{
|
||||
all_available, Axis, BravuraCatalog, ConstrainedLayoutIR, ConstraintId, ConstraintSolver,
|
||||
ConstraintStrength, GlyphCatalog, GlyphObject, GlyphObjectId, InvalidationSet,
|
||||
LayoutConstraint, Point, QualityMetricVector, Rect, ResolvedGlyph, ResolvedLayoutIR,
|
||||
SolveReport, SolveStatus, SolverBudgetUsed, SolverConfig, SolverState, SolverTier,
|
||||
SolverVersion, SolverWarning, SolverWarningKind, SpringSlotId, Stroke,
|
||||
all_available, profile_thresholds, Axis, BravuraCatalog, ConstrainedLayoutIR, ConstraintId,
|
||||
ConstraintSolver, ConstraintStrength, GlyphCatalog, GlyphObject, GlyphObjectId,
|
||||
InvalidationSet, LayoutConstraint, Point, QualityMetricVector, Rect, ResolvedGlyph,
|
||||
ResolvedLayoutIR, SolveReport, SolveStatus, SolverBudgetUsed, SolverConfig, SolverState,
|
||||
SolverTier, SolverVersion, SolverWarning, SolverWarningKind, SpringSlotId, Stroke,
|
||||
};
|
||||
|
||||
pub use casting::{PageGeometry, INTER_PAGE_GAP, SYSTEM_CONTINUATION_SYNTHESIS};
|
||||
|
|
@ -128,14 +136,20 @@ impl Engraver {
|
|||
/// slots (each glyph to its slot's `x`, baseline `y` preserved), then the
|
||||
/// casting-off pass (system breaking, vertical stacking, page assignment —
|
||||
/// see [`casting`]), then evaluation of the declared constraints by
|
||||
/// strength. A malformed input — an unknown glyph, a forged catalog
|
||||
/// identity, or invalid structure — yields [`SolveStatus::InternalError`]; a
|
||||
/// valid problem whose `Required` constraints cannot all be satisfied yields
|
||||
/// [`SolveStatus::Unsatisfiable`] (naming the unsatisfied constraints). Both
|
||||
/// are diagnostic-only; neither panics. Violated `Preferred` constraints
|
||||
/// yield soft-violation warnings under [`SolveStatus::SolvedWithWarnings`]
|
||||
/// — a valid, renderable layout.
|
||||
fn resolve(&self, input: &ConstrainedLayoutIR) -> SolveReport {
|
||||
/// strength, then the **quality-metric census** (the private `quality`
|
||||
/// module): all nine normative axes of the Quality Metric Catalog computed
|
||||
/// over the cast geometry, with `QualityFloorApproached` warnings against
|
||||
/// the threshold column the config's profile selects (diagnostic — per the
|
||||
/// catalog they never change the status). A malformed input — an unknown
|
||||
/// glyph, a forged catalog identity, or invalid structure — yields
|
||||
/// [`SolveStatus::InternalError`] with the all-worst unmeasured vector
|
||||
/// (nothing trustworthy to measure); a valid problem whose `Required`
|
||||
/// constraints cannot all be satisfied yields
|
||||
/// [`SolveStatus::Unsatisfiable`] (naming the unsatisfied constraints), its
|
||||
/// real geometry measured honestly. Neither panics. Violated `Preferred`
|
||||
/// constraints yield soft-violation warnings under
|
||||
/// [`SolveStatus::SolvedWithWarnings`] — a valid, renderable layout.
|
||||
fn resolve(&self, input: &ConstrainedLayoutIR, config: &SolverConfig) -> SolveReport {
|
||||
let structural_valid = input.validate().is_ok();
|
||||
|
||||
// Short-circuit before catalog construction so an unknown glyph yields a
|
||||
|
|
@ -237,6 +251,27 @@ impl Engraver {
|
|||
});
|
||||
}
|
||||
|
||||
// The quality-metric census (Quality Metric Catalog): measured whenever
|
||||
// the geometry is trustworthy — structure valid (the cast ran) and the
|
||||
// catalog identity genuine (the glyph boxes the census sweeps are the
|
||||
// real bundled metrics). A malformed input keeps the all-worst
|
||||
// unmeasured placeholder: there is nothing honest to measure. The
|
||||
// floor warnings reference the threshold column the config's profile
|
||||
// selects (Draft -> Minimal, Standard/Publication -> Standard); per the
|
||||
// catalog they are diagnostic and never change `status`, which was
|
||||
// fixed above.
|
||||
let metric_vector = match (&cast, catalog_valid) {
|
||||
(Some(cast), true) => {
|
||||
let vector = quality::measure(input, cast, &self.geometry);
|
||||
warnings.extend(quality::floor_warnings(
|
||||
&vector,
|
||||
profile_thresholds(config.profile),
|
||||
));
|
||||
vector
|
||||
}
|
||||
_ => QualityMetricVector::unmeasured(),
|
||||
};
|
||||
|
||||
// The final layout is the cast world frame: real pages and systems,
|
||||
// glyph/stroke positions baked, the engraver's break decisions appended
|
||||
// to the pipeline's (Chapter 7 §"ResolvedLayoutIR": decisions "including
|
||||
|
|
@ -268,11 +303,9 @@ impl Engraver {
|
|||
},
|
||||
unsatisfied_constraints,
|
||||
warnings,
|
||||
// Minimal makes no normalized-metric claim (Chapter 9 / QUICKSTART:
|
||||
// "satisfies hard constraints but makes no normalized-metric claims";
|
||||
// the Quality Metric Catalog is Phase 3), so the vector is the
|
||||
// conservative all-worst "no claim" placeholder, like the stub's.
|
||||
metric_vector: QualityMetricVector::unmeasured(),
|
||||
// The real nine-axis census computed above (or the honest all-worst
|
||||
// placeholder for a malformed input the solver could not measure).
|
||||
metric_vector,
|
||||
budget_used: SolverBudgetUsed {
|
||||
// The horizontal pass and the casting-off walk each touch every
|
||||
// slot once; report the spacing pass's touch honestly.
|
||||
|
|
@ -547,9 +580,11 @@ fn within(g: &ResolvedGlyph, region: &Rect) -> bool {
|
|||
impl ConstraintSolver for Engraver {
|
||||
fn tier(&self) -> SolverTier {
|
||||
// Minimal (Chapter 9): it evaluates and satisfies the IR's declared hard
|
||||
// constraints, reporting honestly which (if any) it cannot. It makes no
|
||||
// normalized-metric claim — `Minimal` means hard constraints satisfied,
|
||||
// not optimal quality (the Quality Metric Catalog is Phase 3 / `Standard`).
|
||||
// constraints, reporting honestly which (if any) it cannot, and computes
|
||||
// real quality-metric vectors per the Quality Metric Catalog — accurate
|
||||
// reports being part of the Minimal claim. `Minimal` still makes no
|
||||
// optimality claim (greedy first-fit casting-off is legitimate here);
|
||||
// the Standard tier's tighter thresholds are not claimed.
|
||||
SolverTier::Minimal
|
||||
}
|
||||
|
||||
|
|
@ -557,8 +592,8 @@ impl ConstraintSolver for Engraver {
|
|||
ENGRAVER_VERSION
|
||||
}
|
||||
|
||||
fn solve(&self, input: &ConstrainedLayoutIR, _config: &SolverConfig) -> SolveReport {
|
||||
self.resolve(input)
|
||||
fn solve(&self, input: &ConstrainedLayoutIR, config: &SolverConfig) -> SolveReport {
|
||||
self.resolve(input, config)
|
||||
}
|
||||
|
||||
fn solve_incremental(
|
||||
|
|
@ -566,13 +601,13 @@ impl ConstraintSolver for Engraver {
|
|||
input: &ConstrainedLayoutIR,
|
||||
_prior: &SolverState,
|
||||
_invalidations: &InvalidationSet,
|
||||
_config: &SolverConfig,
|
||||
config: &SolverConfig,
|
||||
) -> SolveReport {
|
||||
// The scaffold recomputes spacing from scratch, which is trivially
|
||||
// observationally equivalent to a scoped incremental solve (Chapter 9
|
||||
// §"Observational Equivalence"). Real incremental scoping is Minimal-tier
|
||||
// work.
|
||||
self.resolve(input)
|
||||
self.resolve(input, config)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -588,14 +623,33 @@ mod tests {
|
|||
|
||||
#[test]
|
||||
fn reports_the_minimal_tier_it_has_earned() {
|
||||
use epiphany_layout_ir::{MINIMAL_THRESHOLDS, QUALITY_METRIC_KINDS};
|
||||
// It evaluates the declared hard constraints, so it reports Minimal — above
|
||||
// the interface-only stub, below the metric-claiming Standard tier.
|
||||
// the interface-only stub, below the tighter-threshold Standard tier.
|
||||
assert_eq!(Engraver::default().tier(), SolverTier::Minimal);
|
||||
assert!(Engraver::default().tier() > StubSolver.tier());
|
||||
assert!(Engraver::default().tier() < SolverTier::Standard);
|
||||
// Minimal makes no normalized-metric claim (the catalog is Phase 3).
|
||||
// Accurate metric vectors are part of the Minimal claim (Chapter 9;
|
||||
// Quality Metric Catalog): the vector is *real* — never the all-worst
|
||||
// unmeasured placeholder — collision-free on this clean fixture, and
|
||||
// every axis is a valid NormalizedMetric within the catalog's Minimal
|
||||
// threshold column (the fixture's three regions each cast onto a
|
||||
// single system, so the break-family axes degenerate to exactly 0.0
|
||||
// under the vacuous-geometry rule).
|
||||
let report = Engraver::default().solve(&fixture(), &SolverConfig::default());
|
||||
assert_eq!(report.metric_vector, QualityMetricVector::unmeasured());
|
||||
assert_ne!(report.metric_vector, QualityMetricVector::unmeasured());
|
||||
assert_eq!(report.metric_vector.collision_penalty.0, 0.0);
|
||||
for kind in QUALITY_METRIC_KINDS {
|
||||
let value = report.metric_vector.axis(kind).0;
|
||||
assert!(
|
||||
value.is_finite() && (0.0..=1.0).contains(&value),
|
||||
"{kind:?}"
|
||||
);
|
||||
assert!(
|
||||
value <= MINIMAL_THRESHOLDS.axis(kind),
|
||||
"{kind:?} = {value} exceeds its Minimal threshold"
|
||||
);
|
||||
}
|
||||
assert_eq!(Engraver::default().version(), ENGRAVER_VERSION);
|
||||
assert_ne!(Engraver::default().version(), StubSolver.version());
|
||||
}
|
||||
|
|
@ -795,7 +849,19 @@ mod tests {
|
|||
});
|
||||
let report = Engraver::default().solve(&input, &SolverConfig::default());
|
||||
assert_eq!(report.status, SolveStatus::Solved, "{:?}", report.warnings);
|
||||
assert!(report.warnings.is_empty());
|
||||
// The honoured break is never reported as a soft violation. (The
|
||||
// report legitimately carries QualityFloorApproached diagnostics: this
|
||||
// two-note micro-score casts off into wildly uneven system widths,
|
||||
// which the casting-off axis honestly measures — quality warnings are
|
||||
// diagnostic and, per the catalog, never change the status.)
|
||||
assert!(
|
||||
!report.warnings.iter().any(|w| matches!(
|
||||
w.kind,
|
||||
SolverWarningKind::LargeSoftConstraintViolation { .. }
|
||||
)),
|
||||
"an honoured break must not surface as a soft violation: {:?}",
|
||||
report.warnings
|
||||
);
|
||||
assert!(report.satisfied_hard_constraints);
|
||||
assert_eq!(system_count(&report.layout), 2);
|
||||
assert!(report
|
||||
|
|
@ -906,7 +972,19 @@ mod tests {
|
|||
let engraver = Engraver::default();
|
||||
let report = engraver.solve(&constrained, &SolverConfig::default());
|
||||
assert_eq!(report.status, SolveStatus::Solved, "{:?}", report.warnings);
|
||||
assert!(report.warnings.is_empty(), "an honoured break never warns");
|
||||
// An honoured break never warns *about the break* (no soft violation).
|
||||
// The report may carry QualityFloorApproached diagnostics — this
|
||||
// few-note score's user break honestly leaves a stub last system,
|
||||
// which the casting-off axis measures; quality warnings never change
|
||||
// the status per the catalog.
|
||||
assert!(
|
||||
!report.warnings.iter().any(|w| matches!(
|
||||
w.kind,
|
||||
SolverWarningKind::LargeSoftConstraintViolation { .. }
|
||||
)),
|
||||
"an honoured break never surfaces as a soft violation: {:?}",
|
||||
report.warnings
|
||||
);
|
||||
assert!(report.satisfied_hard_constraints);
|
||||
assert!(report.unsatisfied_constraints.is_empty());
|
||||
assert!(
|
||||
|
|
@ -1216,8 +1294,11 @@ mod tests {
|
|||
assert_eq!(resolved.provenance, original.provenance);
|
||||
assert_eq!(resolved.glyph, original.glyph);
|
||||
}
|
||||
// The metric vector is the honest all-worst placeholder (no metrics yet).
|
||||
assert_eq!(report.metric_vector, QualityMetricVector::unmeasured());
|
||||
// The metric vector is real — computed per the Quality Metric Catalog,
|
||||
// never the all-worst placeholder — and this clean pipeline fixture is
|
||||
// collision-free under the full same-system census.
|
||||
assert_ne!(report.metric_vector, QualityMetricVector::unmeasured());
|
||||
assert_eq!(report.metric_vector.collision_penalty.0, 0.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
|
|||
|
|
@ -0,0 +1,650 @@
|
|||
//! **Real quality-metric computation** — the nine normative axes of the
|
||||
//! *Quality Metric Catalog* companion (v0.1.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
|
||||
//! warning fraction are the catalog's, transcribed 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 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).
|
||||
//! * **`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
|
||||
//! catalog pins vacuous-0.0 deliberately and owns the honesty edge (its
|
||||
//! "notated-but-unrendered" open question): rendering completeness is
|
||||
//! governed by constraint families and visual acceptance, not these axes.
|
||||
//! * **`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, GlyphObject, GlyphObjectId, 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
|
||||
}
|
||||
}
|
||||
|
||||
/// 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>,
|
||||
/// Column reference x per realized slot per system, ascending and distinct.
|
||||
columns: Vec<Vec<f64>>,
|
||||
}
|
||||
|
||||
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];
|
||||
// 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),
|
||||
});
|
||||
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
|
||||
/// column advances, over systems realizing at least three columns.
|
||||
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 mut per_unit: Vec<f64> = Vec::new();
|
||||
|
||||
// --- InterStaffGap bands declared by the constrained input -------------
|
||||
let index_of: BTreeMap<GlyphObjectId, usize> = input
|
||||
.glyphs
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, glyph)| (GlyphObject::id(glyph), index))
|
||||
.collect();
|
||||
let system_of_glyph = |index: usize| -> Option<usize> {
|
||||
cast.system_of_slot
|
||||
.get(&input.glyphs[index].horizontal_slot)
|
||||
.copied()
|
||||
};
|
||||
for (region_index, region) in input.regions.iter().enumerate() {
|
||||
// The region's laid-out staff bands, top staff first, ordered within
|
||||
// the region's first system (systems translate rigidly, so within-
|
||||
// system y order is the region's staff order).
|
||||
let first_system = census.region.iter().position(|&r| r == region_index);
|
||||
let Some(first_system) = first_system else {
|
||||
continue;
|
||||
};
|
||||
let region_glyphs: BTreeSet<GlyphObjectId> = region.glyphs.iter().copied().collect();
|
||||
let mut staves: Vec<(f64, Vec<usize>)> = Vec::new();
|
||||
for band in &input.vertical_bands {
|
||||
if !matches!(band.kind, VerticalBandKind::Staff(_)) {
|
||||
continue;
|
||||
}
|
||||
if !band.members.iter().any(|id| region_glyphs.contains(id)) {
|
||||
continue;
|
||||
}
|
||||
let members: Vec<usize> = band
|
||||
.members
|
||||
.iter()
|
||||
.filter_map(|id| index_of.get(id).copied())
|
||||
.collect();
|
||||
let top_in_first = members
|
||||
.iter()
|
||||
.filter(|&&index| system_of_glyph(index) == Some(first_system))
|
||||
.map(|&index| ink_box(cast, input, index)[3])
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
if top_in_first.is_finite() {
|
||||
staves.push((top_in_first, members));
|
||||
}
|
||||
}
|
||||
// Top staff first.
|
||||
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 g−1 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 = &staves[gap - 1].1;
|
||||
let lower = &staves[gap].1;
|
||||
// Realized iff the adjacent content shares a system; measure the
|
||||
// separation there (rigid system translation makes every common
|
||||
// system agree).
|
||||
let common: BTreeSet<usize> = upper
|
||||
.iter()
|
||||
.filter_map(|&index| system_of_glyph(index))
|
||||
.filter(|system| {
|
||||
lower
|
||||
.iter()
|
||||
.any(|&index| system_of_glyph(index) == Some(*system))
|
||||
})
|
||||
.collect();
|
||||
let Some(&system) = common.iter().next() else {
|
||||
continue;
|
||||
};
|
||||
let upper_bottom = upper
|
||||
.iter()
|
||||
.filter(|&&index| system_of_glyph(index) == Some(system))
|
||||
.map(|&index| ink_box(cast, input, index)[1])
|
||||
.fold(f64::INFINITY, f64::min);
|
||||
let lower_top = lower
|
||||
.iter()
|
||||
.filter(|&&index| system_of_glyph(index) == Some(system))
|
||||
.map(|&index| ink_box(cast, input, index)[3])
|
||||
.fold(f64::NEG_INFINITY, f64::max);
|
||||
let realized = (upper_bottom - lower_top).max(0.0);
|
||||
per_unit.push((realized - preferred).abs() / preferred);
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mean_or_zero(&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, ®ion) 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,
|
||||
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),
|
||||
// No drawn slur geometry exists in this pipeline (slurs are logical
|
||||
// objects, not curves): the contributing-unit set is empty, so the
|
||||
// axis is exactly 0.0 per the catalog's vacuous-geometry rule. The
|
||||
// catalog's "notated-but-unrendered" open question owns the honesty
|
||||
// edge; the definition is pinned so the first slur-drawing release is
|
||||
// measured from day one.
|
||||
slur_shape_penalty: normalize(0.0, 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 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); 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.
|
||||
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);
|
||||
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");
|
||||
assert!(
|
||||
vector.system_break_penalty.0 > 0.0 && vector.system_break_penalty.0 < 0.35,
|
||||
"the non-final system is nearly 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!(
|
||||
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
|
||||
}
|
||||
)));
|
||||
assert_eq!(report.status, SolveStatus::Solved);
|
||||
}
|
||||
|
||||
#[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| {
|
||||
let config = SolverConfig {
|
||||
profile,
|
||||
..SolverConfig::default()
|
||||
};
|
||||
Engraver::default()
|
||||
.solve(&input, &config)
|
||||
.warnings
|
||||
.iter()
|
||||
.any(|w| {
|
||||
matches!(
|
||||
w.kind,
|
||||
SolverWarningKind::QualityFloorApproached {
|
||||
metric: QualityMetricKind::Spacing
|
||||
}
|
||||
)
|
||||
})
|
||||
};
|
||||
assert!(spacing_warned(SolverProfile::Standard));
|
||||
assert!(spacing_warned(SolverProfile::Publication));
|
||||
assert!(!spacing_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
|
||||
.0;
|
||||
assert!((0.32..=0.72).contains(&value), "spacing = {value}");
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
|
|
@ -421,3 +421,27 @@ object is covered); the provenance-preservation contract itself is unchanged.
|
|||
P12-I2 wired it: `epiphany-determinism` reserves the built-in
|
||||
`DomainTag::LAYOUT_OBJECT_ID` and `provenance.rs` (and the engraving-decision
|
||||
id) route through it. See the ratified-block note at the top of this file.
|
||||
|
||||
## 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
|
||||
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 →
|
||||
threshold-column mappings (Minimal has its own column; Standard and Advanced
|
||||
use the Standard column; profiles Draft → Minimal column, Standard and
|
||||
Publication → Standard column, Standard the default). Both consumers — the
|
||||
`epiphany-engrave` solver (computing vectors and floor diagnostics) and the
|
||||
`epiphany-testkit` reference-suite harness (asserting per-tier thresholds) —
|
||||
need the same numbers, and this crate is the only one both already depend on,
|
||||
so the constants live here (`quality.rs`) with doc comments citing the
|
||||
companion by chapter/section. **Every value is transcribed, none invented**;
|
||||
a change to any of them is a catalog revision first, mirrored here. The
|
||||
module is additive: no canonical encoding is touched (metric values remain
|
||||
diagnostic-only, structurally outside `ResolvedLayoutIR` — the catalog's own
|
||||
requirement), and the `StubSolver` still computes nothing and keeps its
|
||||
all-worst `unmeasured()` vector, which a transcription test pins as excluded
|
||||
by the Minimal column ("measuring is part of the Minimal claim"). The catalog
|
||||
also blesses the existing `TieBreakingWeights::default()` (all 1.0) as the
|
||||
normative defaults — pinned by test rather than re-declared.
|
||||
|
|
|
|||
|
|
@ -73,6 +73,7 @@ pub mod glyph;
|
|||
pub mod hittest;
|
||||
pub mod logical;
|
||||
pub mod provenance;
|
||||
pub mod quality;
|
||||
pub mod render;
|
||||
pub mod resolved;
|
||||
pub mod roundtrip;
|
||||
|
|
@ -126,6 +127,10 @@ pub use provenance::{
|
|||
continuation_instance_key, manifestation_layout_id, stable_layout_id, synthesized_layout_id,
|
||||
LayoutObjectId, Provenance, SynthesisInstanceKey, SynthesisKind, SynthesisRegistryId,
|
||||
};
|
||||
pub use quality::{
|
||||
normalize, profile_thresholds, r_worst, tier_thresholds, MetricThresholds, MINIMAL_THRESHOLDS,
|
||||
QUALITY_FLOOR_FRACTION, QUALITY_METRIC_KINDS, STANDARD_THRESHOLDS,
|
||||
};
|
||||
pub use render::{
|
||||
to_render, ColorConfiguration, ColorSpace, PassthroughRenderProducer,
|
||||
RasterizationConfiguration, RenderConfiguration, RenderIR, RenderIRProducer, RenderPrimitive,
|
||||
|
|
|
|||
|
|
@ -0,0 +1,341 @@
|
|||
//! The Quality Metric Catalog's normative constants (companion specification
|
||||
//! *Epiphany — Quality Metric Catalog*, v0.1.0): the per-axis normalization
|
||||
//! anchors, the per-tier metric threshold table, the profile→threshold-column
|
||||
//! mapping, and the `QualityFloorApproached` warning fraction.
|
||||
//!
|
||||
//! 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
|
||||
//! metrics (e.g. `epiphany-engrave`) normalize raw measurements through
|
||||
//! [`normalize`] with the [`anchors`] of catalog Chapter 3 ("The Nine Normative
|
||||
//! Metrics"), and reference the threshold tables of catalog Chapter 5
|
||||
//! ("Per-Tier Metric Thresholds") — as does the reference-suite harness in
|
||||
//! `epiphany-testkit`. The in-crate [`StubSolver`](crate::StubSolver) computes
|
||||
//! no metrics and touches none of this (it stays on the all-worst
|
||||
//! [`QualityMetricVector::unmeasured`] placeholder, the honest "no claim"
|
||||
//! vector the catalog's vacuous-geometry requirement reserves for a solver
|
||||
//! that computes no metrics at all).
|
||||
|
||||
use crate::solver::{
|
||||
NormalizedMetric, QualityMetricKind, QualityMetricVector, SolverProfile, SolverTier,
|
||||
};
|
||||
|
||||
/// The nine normative metric axes in their catalog order (catalog §"The
|
||||
/// Normative Metric Set and `QualityMetricKind`", Table "kind-mapping").
|
||||
pub const QUALITY_METRIC_KINDS: [QualityMetricKind; 9] = [
|
||||
QualityMetricKind::Collision,
|
||||
QualityMetricKind::Spacing,
|
||||
QualityMetricKind::SlurShape,
|
||||
QualityMetricKind::BeamSlope,
|
||||
QualityMetricKind::VerticalDensity,
|
||||
QualityMetricKind::SystemBreak,
|
||||
QualityMetricKind::PageFill,
|
||||
QualityMetricKind::CastingOff,
|
||||
QualityMetricKind::SymbolDensity,
|
||||
];
|
||||
|
||||
/// The per-axis normalization anchors `R_worst` (catalog Chapter 3): each
|
||||
/// normative metric defines a dimensionless raw measurement `raw >= 0` and a
|
||||
/// pinned anchor, and normalizes by the clamped-linear map
|
||||
/// `n = min(1, raw / R_worst)` (catalog §"Normalization Form",
|
||||
/// `req:qmc:normalization-form`). Implementations MUST use these anchors;
|
||||
/// arbitrary normalization is non-conforming.
|
||||
pub mod anchors {
|
||||
/// `collision_penalty` (catalog §`collision_penalty`): colliding
|
||||
/// cross-column pairs per glyph; one collision per twenty glyphs is
|
||||
/// worst-tolerable.
|
||||
pub const COLLISION_R_WORST: f64 = 0.05;
|
||||
/// `spacing_distortion` (catalog §`spacing_distortion`): mean per-system
|
||||
/// CV of column advances; CV 1.0 is spacing with no discernible
|
||||
/// regularity.
|
||||
pub const SPACING_R_WORST: f64 = 1.0;
|
||||
/// `slur_shape_penalty` (catalog §`slur_shape_penalty`): mean deviation of
|
||||
/// the arc ratio from the ideal band `[0.08, 0.25]`; a semicircular slur
|
||||
/// (deviation 0.25) is worst-tolerable.
|
||||
pub const SLUR_SHAPE_R_WORST: f64 = 0.25;
|
||||
/// `beam_slope_penalty` (catalog §`beam_slope_penalty`): mean slope excess
|
||||
/// over 0.25; slope 0.5 (deviation 0.25) is worst-tolerable.
|
||||
pub const BEAM_SLOPE_R_WORST: f64 = 0.25;
|
||||
/// `vertical_density_penalty` (catalog §`vertical_density_penalty`): mean
|
||||
/// relative gap deviation `|r - p| / p`; a gap off by its own preferred
|
||||
/// size is worst-tolerable.
|
||||
pub const VERTICAL_DENSITY_R_WORST: f64 = 1.0;
|
||||
/// `system_break_penalty` (catalog §`system_break_penalty`): mean
|
||||
/// `|W - w_s| / W` over non-final systems; half-empty (or half-overflowing)
|
||||
/// non-final systems are worst-tolerable.
|
||||
pub const SYSTEM_BREAK_R_WORST: f64 = 0.5;
|
||||
/// `page_fill_efficiency` (catalog §`page_fill_efficiency`): mean unfilled
|
||||
/// fraction of non-final pages; three-quarters empty is worst-tolerable.
|
||||
pub const PAGE_FILL_R_WORST: f64 = 0.75;
|
||||
/// `casting_off_quality` (catalog §`casting_off_quality`): mean per-region
|
||||
/// CV of system widths (final system included); CV 0.5 is worst-tolerable.
|
||||
pub const CASTING_OFF_R_WORST: f64 = 0.5;
|
||||
/// `symbol_density_uniformity` (catalog §`symbol_density_uniformity`):
|
||||
/// mean per-region CV of glyphs-per-width densities; CV 0.5 is
|
||||
/// worst-tolerable.
|
||||
pub const SYMBOL_DENSITY_R_WORST: f64 = 0.5;
|
||||
}
|
||||
|
||||
/// The pinned anchor `R_worst` for a normative axis (catalog Chapter 3; see
|
||||
/// [`anchors`]).
|
||||
pub fn r_worst(kind: QualityMetricKind) -> f64 {
|
||||
match kind {
|
||||
QualityMetricKind::Collision => anchors::COLLISION_R_WORST,
|
||||
QualityMetricKind::Spacing => anchors::SPACING_R_WORST,
|
||||
QualityMetricKind::SlurShape => anchors::SLUR_SHAPE_R_WORST,
|
||||
QualityMetricKind::BeamSlope => anchors::BEAM_SLOPE_R_WORST,
|
||||
QualityMetricKind::VerticalDensity => anchors::VERTICAL_DENSITY_R_WORST,
|
||||
QualityMetricKind::SystemBreak => anchors::SYSTEM_BREAK_R_WORST,
|
||||
QualityMetricKind::PageFill => anchors::PAGE_FILL_R_WORST,
|
||||
QualityMetricKind::CastingOff => anchors::CASTING_OFF_R_WORST,
|
||||
QualityMetricKind::SymbolDensity => anchors::SYMBOL_DENSITY_R_WORST,
|
||||
}
|
||||
}
|
||||
|
||||
/// The catalog's clamped-linear normalization (catalog §"Normalization Form",
|
||||
/// `req:qmc:normalization-form`): `n = min(1, raw / R_worst)`, so `raw = 0`
|
||||
/// (the ideal) normalizes to `0.0` and `raw >= R_worst` (the worst-tolerable
|
||||
/// anchor and beyond) normalizes to `1.0`.
|
||||
///
|
||||
/// `raw` must be a finite, non-negative measurement and `r_worst` a positive
|
||||
/// anchor, per the catalog; the result is a valid [`NormalizedMetric`] by
|
||||
/// construction.
|
||||
pub fn normalize(raw: f64, r_worst: f64) -> NormalizedMetric {
|
||||
assert!(
|
||||
raw.is_finite() && raw >= 0.0,
|
||||
"a raw quality measurement must be finite and non-negative (got {raw})"
|
||||
);
|
||||
assert!(
|
||||
r_worst > 0.0,
|
||||
"a normalization anchor must be positive (got {r_worst})"
|
||||
);
|
||||
NormalizedMetric::new((raw / r_worst).min(1.0))
|
||||
}
|
||||
|
||||
/// One column of the catalog's per-tier threshold table (catalog Chapter 5,
|
||||
/// Table "tier-thresholds"): the maximum permitted [`NormalizedMetric`] value
|
||||
/// per axis for a reference-suite entry evaluated at that tier.
|
||||
#[derive(Copy, Clone, PartialEq, Debug)]
|
||||
pub struct MetricThresholds {
|
||||
pub collision_penalty: f64,
|
||||
pub spacing_distortion: f64,
|
||||
pub slur_shape_penalty: f64,
|
||||
pub beam_slope_penalty: f64,
|
||||
pub vertical_density_penalty: f64,
|
||||
pub system_break_penalty: f64,
|
||||
pub page_fill_efficiency: f64,
|
||||
pub casting_off_quality: f64,
|
||||
pub symbol_density_uniformity: f64,
|
||||
}
|
||||
|
||||
impl MetricThresholds {
|
||||
/// The column's threshold for one axis.
|
||||
pub fn axis(&self, kind: QualityMetricKind) -> f64 {
|
||||
match kind {
|
||||
QualityMetricKind::Collision => self.collision_penalty,
|
||||
QualityMetricKind::Spacing => self.spacing_distortion,
|
||||
QualityMetricKind::SlurShape => self.slur_shape_penalty,
|
||||
QualityMetricKind::BeamSlope => self.beam_slope_penalty,
|
||||
QualityMetricKind::VerticalDensity => self.vertical_density_penalty,
|
||||
QualityMetricKind::SystemBreak => self.system_break_penalty,
|
||||
QualityMetricKind::PageFill => self.page_fill_efficiency,
|
||||
QualityMetricKind::CastingOff => self.casting_off_quality,
|
||||
QualityMetricKind::SymbolDensity => self.symbol_density_uniformity,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The **Minimal** threshold column (catalog Chapter 5, Table
|
||||
/// "tier-thresholds"): uniformly `0.90` — relaxed but non-vacuous, excluding
|
||||
/// layouts at an axis's worst-tolerable anchor and the all-worst unmeasured
|
||||
/// placeholder ("measuring is part of the Minimal claim").
|
||||
pub const MINIMAL_THRESHOLDS: MetricThresholds = MetricThresholds {
|
||||
collision_penalty: 0.90,
|
||||
spacing_distortion: 0.90,
|
||||
slur_shape_penalty: 0.90,
|
||||
beam_slope_penalty: 0.90,
|
||||
vertical_density_penalty: 0.90,
|
||||
system_break_penalty: 0.90,
|
||||
page_fill_efficiency: 0.90,
|
||||
casting_off_quality: 0.90,
|
||||
symbol_density_uniformity: 0.90,
|
||||
};
|
||||
|
||||
/// The **Standard** threshold column (catalog Chapter 5, Table
|
||||
/// "tier-thresholds"): professional engraving quality — collisions bounded
|
||||
/// tightest (`0.25`), the break family at `0.35`, distribution/vertical proxies
|
||||
/// at `0.40`, slur/beam shape at `0.30`.
|
||||
pub const STANDARD_THRESHOLDS: MetricThresholds = MetricThresholds {
|
||||
collision_penalty: 0.25,
|
||||
spacing_distortion: 0.40,
|
||||
slur_shape_penalty: 0.30,
|
||||
beam_slope_penalty: 0.30,
|
||||
vertical_density_penalty: 0.40,
|
||||
system_break_penalty: 0.35,
|
||||
page_fill_efficiency: 0.40,
|
||||
casting_off_quality: 0.35,
|
||||
symbol_density_uniformity: 0.40,
|
||||
};
|
||||
|
||||
/// The `QualityFloorApproached` warning fraction (catalog §"The
|
||||
/// `QualityFloorApproached` Warning", `req:qmc:floor-warning`): a solver SHOULD
|
||||
/// warn for axis `k` when `k`'s computed value exceeds **0.8×** the applicable
|
||||
/// threshold — the one selected by the solve's [`SolverProfile`]
|
||||
/// ([`profile_thresholds`]). The warning is diagnostic: emitting it does not
|
||||
/// change the solve's status.
|
||||
pub const QUALITY_FLOOR_FRACTION: f64 = 0.8;
|
||||
|
||||
/// The threshold column a **conformance tier** is evaluated against on the
|
||||
/// reference suite (catalog Chapter 5): `Minimal` has its own relaxed column;
|
||||
/// `Standard` the professional column; `Advanced` imposes the Standard column
|
||||
/// on the nine normative axes (plus per-extension thresholds,
|
||||
/// `req:qmc:advanced`, which this table does not model). `Stub` is below every
|
||||
/// conformance tier and is evaluated against nothing — it computes no metrics
|
||||
/// and passes no suite.
|
||||
pub fn tier_thresholds(tier: SolverTier) -> Option<&'static MetricThresholds> {
|
||||
match tier {
|
||||
SolverTier::Stub => None,
|
||||
SolverTier::Minimal => Some(&MINIMAL_THRESHOLDS),
|
||||
SolverTier::Standard | SolverTier::Advanced => Some(&STANDARD_THRESHOLDS),
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// own `QualityFloorApproached` diagnostics reference during ordinary solves;
|
||||
/// suite evaluation at a claimed tier always uses that *tier's* column
|
||||
/// ([`tier_thresholds`]).
|
||||
pub fn profile_thresholds(profile: SolverProfile) -> &'static MetricThresholds {
|
||||
match profile {
|
||||
SolverProfile::Draft => &MINIMAL_THRESHOLDS,
|
||||
SolverProfile::Standard | SolverProfile::Publication => &STANDARD_THRESHOLDS,
|
||||
}
|
||||
}
|
||||
|
||||
impl QualityMetricVector {
|
||||
/// The vector's value for one normative axis, by its
|
||||
/// [`QualityMetricKind`] (catalog Table "kind-mapping": each kind names
|
||||
/// exactly one vector field).
|
||||
pub fn axis(&self, kind: QualityMetricKind) -> NormalizedMetric {
|
||||
match kind {
|
||||
QualityMetricKind::Collision => self.collision_penalty,
|
||||
QualityMetricKind::Spacing => self.spacing_distortion,
|
||||
QualityMetricKind::SlurShape => self.slur_shape_penalty,
|
||||
QualityMetricKind::BeamSlope => self.beam_slope_penalty,
|
||||
QualityMetricKind::VerticalDensity => self.vertical_density_penalty,
|
||||
QualityMetricKind::SystemBreak => self.system_break_penalty,
|
||||
QualityMetricKind::PageFill => self.page_fill_efficiency,
|
||||
QualityMetricKind::CastingOff => self.casting_off_quality,
|
||||
QualityMetricKind::SymbolDensity => self.symbol_density_uniformity,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::solver::TieBreakingWeights;
|
||||
|
||||
#[test]
|
||||
fn normalization_is_the_catalogs_clamped_linear_map() {
|
||||
assert_eq!(normalize(0.0, 0.5).0, 0.0);
|
||||
assert_eq!(normalize(0.25, 0.5).0, 0.5);
|
||||
assert_eq!(normalize(0.5, 0.5).0, 1.0);
|
||||
// At and beyond the anchor clamps to the worst-tolerable 1.0.
|
||||
assert_eq!(normalize(3.0, 0.5).0, 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[should_panic(expected = "finite and non-negative")]
|
||||
fn normalization_rejects_a_negative_raw() {
|
||||
let _ = normalize(-0.1, 0.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_is_uniformly_more_permissive_than_standard() {
|
||||
// Catalog Table "tier-thresholds": "Minimal is uniformly more
|
||||
// permissive than Standard on every axis."
|
||||
for kind in QUALITY_METRIC_KINDS {
|
||||
assert!(
|
||||
MINIMAL_THRESHOLDS.axis(kind) > STANDARD_THRESHOLDS.axis(kind),
|
||||
"{kind:?}"
|
||||
);
|
||||
// Both columns are valid NormalizedMetric bounds.
|
||||
assert!((0.0..=1.0).contains(&MINIMAL_THRESHOLDS.axis(kind)));
|
||||
assert!((0.0..=1.0).contains(&STANDARD_THRESHOLDS.axis(kind)));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_minimal_column_excludes_the_unmeasured_placeholder() {
|
||||
// Catalog Chapter 5 rationale: "a solver reporting the unmeasured 1.0
|
||||
// placeholder cannot pass the Minimal suite" — measuring is part of
|
||||
// the Minimal claim.
|
||||
let unmeasured = QualityMetricVector::unmeasured();
|
||||
assert!(QUALITY_METRIC_KINDS
|
||||
.iter()
|
||||
.any(|&k| unmeasured.axis(k).0 > MINIMAL_THRESHOLDS.axis(k)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tier_and_profile_columns_map_per_the_catalog() {
|
||||
// Tiers (catalog ch5): Minimal has its own column; Standard and
|
||||
// Advanced share the Standard column; Stub is evaluated against nothing.
|
||||
assert_eq!(tier_thresholds(SolverTier::Stub), None);
|
||||
assert_eq!(
|
||||
tier_thresholds(SolverTier::Minimal),
|
||||
Some(&MINIMAL_THRESHOLDS)
|
||||
);
|
||||
assert_eq!(
|
||||
tier_thresholds(SolverTier::Standard),
|
||||
Some(&STANDARD_THRESHOLDS)
|
||||
);
|
||||
assert_eq!(
|
||||
tier_thresholds(SolverTier::Advanced),
|
||||
Some(&STANDARD_THRESHOLDS)
|
||||
);
|
||||
// Profiles (catalog ch6): Draft → Minimal column; Standard and
|
||||
// Publication → Standard column; Standard is the default profile.
|
||||
assert_eq!(
|
||||
profile_thresholds(SolverProfile::Draft),
|
||||
&MINIMAL_THRESHOLDS
|
||||
);
|
||||
assert_eq!(
|
||||
profile_thresholds(SolverProfile::Standard),
|
||||
&STANDARD_THRESHOLDS
|
||||
);
|
||||
assert_eq!(
|
||||
profile_thresholds(SolverProfile::Publication),
|
||||
&STANDARD_THRESHOLDS
|
||||
);
|
||||
assert_eq!(SolverProfile::default(), SolverProfile::Standard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_tie_breaking_weights_are_the_catalogs_normative_defaults() {
|
||||
// Catalog Chapter 4 (`req:qmc:weights`): every one of the nine weights
|
||||
// defaults to 1.0 — blessing the implementation's existing `Default`.
|
||||
let w = TieBreakingWeights::default();
|
||||
for value in [
|
||||
w.collision,
|
||||
w.spacing,
|
||||
w.slur_shape,
|
||||
w.beam_slope,
|
||||
w.vertical_density,
|
||||
w.system_break,
|
||||
w.page_fill,
|
||||
w.casting_off,
|
||||
w.symbol_density,
|
||||
] {
|
||||
assert_eq!(value, 1.0);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_axis_has_a_positive_anchor() {
|
||||
for kind in QUALITY_METRIC_KINDS {
|
||||
assert!(r_worst(kind) > 0.0, "{kind:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -13,11 +13,12 @@
|
|||
//! constraints declared it stays a renderable passthrough but claims no
|
||||
//! satisfaction (see [`StubSolver`]).
|
||||
//!
|
||||
//! **Quality-metric *computation* is deliberately not implemented** (QUICKSTART:
|
||||
//! "only the interface — don't implement quality metrics"): the
|
||||
//! **The stub computes no quality metrics** (QUICKSTART: "only the interface —
|
||||
//! don't implement quality metrics"): the
|
||||
//! [`QualityMetricVector`]/[`NormalizedMetric`] *types* and the
|
||||
//! [`TieBreakingWeights`] exist (the interface requires them), but the
|
||||
//! normalization functions of the Quality Metric Catalog are not. The
|
||||
//! [`TieBreakingWeights`] exist (the interface requires them), and the Quality
|
||||
//! Metric Catalog's normative anchors and threshold tables are transcribed in
|
||||
//! [`crate::quality`] for solvers that do measure (`epiphany-engrave`). The
|
||||
//! `StubSolver` is not a conformant solver and passes no reference suite, so it
|
||||
//! reports the [`SolverTier::Stub`] tier (the honest non-conformance rung, below
|
||||
//! `Minimal`) and an all-worst metric vector. Those values are deliberately
|
||||
|
|
@ -90,8 +91,9 @@ pub struct SolverVersion(pub u32);
|
|||
|
||||
/// The conformance profile under which to solve (Chapter 9 §"The Solver
|
||||
/// Interface": `SolverConfig.profile` — selects metric thresholds and the active
|
||||
/// constraint/extension set). The per-profile thresholds live in the Quality
|
||||
/// Metric Catalog, deferred with the quality metrics.
|
||||
/// constraint/extension set). The registered profile catalog and each profile's
|
||||
/// threshold column are the Quality Metric Catalog's Chapter 6, transcribed as
|
||||
/// [`crate::quality::profile_thresholds`].
|
||||
#[derive(Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Debug, Default)]
|
||||
pub enum SolverProfile {
|
||||
/// Fast, low-quality (draft) profile.
|
||||
|
|
@ -104,8 +106,9 @@ pub enum SolverProfile {
|
|||
}
|
||||
|
||||
/// Tie-breaking weights among layouts of equivalent quality (Chapter 9
|
||||
/// §"Quality Metrics": `TieBreakingWeights`). The normative defaults live in the
|
||||
/// Quality Metric Catalog (deferred); v0 defaults every weight to `1.0`.
|
||||
/// §"Quality Metrics": `TieBreakingWeights`). The normative defaults are the
|
||||
/// Quality Metric Catalog's Chapter 4: every weight `1.0` — exactly this
|
||||
/// type's [`Default`].
|
||||
#[derive(Copy, Clone, PartialEq, Debug)]
|
||||
pub struct TieBreakingWeights {
|
||||
pub collision: f64,
|
||||
|
|
@ -206,13 +209,14 @@ pub struct ExtensionMetric {
|
|||
}
|
||||
|
||||
/// The quality metric vector for a layout (Chapter 9 §"Quality Metrics":
|
||||
/// `QualityMetricVector`). v0 carries the type but computes **no** values: an
|
||||
/// interface-only solver reports the conservative all-worst placeholder
|
||||
/// `QualityMetricVector`). An interface-only solver that computes no metrics
|
||||
/// reports the conservative all-worst placeholder
|
||||
/// ([`QualityMetricVector::unmeasured`], every metric `1.0`), never a measured
|
||||
/// value, so a caller cannot mistake an unmeasured layout for a good one. (The
|
||||
/// derived [`Default`] is all-`0.0`/nominal-best and is *not* what the stub
|
||||
/// reports; the normalization functions of the Quality Metric Catalog are
|
||||
/// deferred.)
|
||||
/// reports.) A measuring solver computes each axis per the Quality Metric
|
||||
/// Catalog's formulas, normalized through [`crate::quality::normalize`] with
|
||||
/// the catalog's pinned anchors ([`crate::quality::anchors`]).
|
||||
#[derive(Clone, PartialEq, Debug, Default)]
|
||||
pub struct QualityMetricVector {
|
||||
pub collision_penalty: NormalizedMetric,
|
||||
|
|
|
|||
|
|
@ -16,11 +16,17 @@ epiphany-ops.workspace = true
|
|||
# now drives the real crate instead of an in-tree stub.
|
||||
epiphany-layout-ir.workspace = true
|
||||
|
||||
# The Chapter 10 performance benches (worklist F1) are the only dev-dependency
|
||||
# user: the budget-gate logic itself lives in `src/budget.rs` on plain `std`,
|
||||
# so the library builds without criterion.
|
||||
# The Chapter 10 performance benches (worklist F1) run under criterion; the
|
||||
# budget-gate logic itself lives in `src/budget.rs` on plain `std`, so the
|
||||
# library builds without it. The editor-core + engrave pair drives the
|
||||
# multi-system click-to-insert regression test (`tests/multisystem_click.rs`)
|
||||
# over the real casting-off engraver — dev-only, so the library dependency
|
||||
# graph is unchanged (engrave's own dev-dependency on this crate makes the
|
||||
# cycle dev-only, which cargo permits).
|
||||
[dev-dependencies]
|
||||
criterion.workspace = true
|
||||
epiphany-editor-core.workspace = true
|
||||
epiphany-engrave.workspace = true
|
||||
|
||||
# Drives the whole suite at scale outside the unit-test timeout — the analogue
|
||||
# of epiphany-determinism's `fuzz_roundtrip` and epiphany-bundle's `fuzz_crash`.
|
||||
|
|
|
|||
|
|
@ -211,3 +211,53 @@ once ≥3 ambiguities accumulate (same rule as v0 → Pass 11). Agent H's landin
|
|||
contributed five candidates (P12-H1…P12-H5, recorded in
|
||||
`crates/epiphany-core/DECISIONS.md`), which crosses the threshold, so the batch is
|
||||
open. F does not resolve these; F collects them.
|
||||
|
||||
## F5 — The Reference Suite harness (`src/reference_suite.rs`, 2026-07)
|
||||
|
||||
The Reference Suite companion (v0.1.0) charters the v0.1 entry set — six
|
||||
scores named by reference-implementation **builder and seed** — and its
|
||||
non-normative Harness Binding chapter says the executable binding "is
|
||||
delivered with the reference implementation." This module is that binding,
|
||||
in the F0 shape: a library module holding the machinery
|
||||
(`entries`/`evaluate_minimal`/`table`), asserted by
|
||||
`tests/reference_suite.rs` with **one test per entry** so a failure names its
|
||||
entry, plus pins for the entry-set shape, the declared A4 default geometry
|
||||
(the companion's solve-configuration requirement — asserted against
|
||||
`Engraver::default().geometry()`), and RS-2's builder identity
|
||||
(`corpus gen_valid_score_rich` ≡ `generators::valid_score_rich(0xF302)`,
|
||||
byte-for-byte).
|
||||
|
||||
**Solver-parametric on purpose.** The library module takes
|
||||
`&dyn ConstraintSolver`; the integration test supplies the real `Engraver`.
|
||||
This keeps `epiphany-engrave` a dev-only dependency (the library dependency
|
||||
graph is unchanged, mirroring the multi-system click test) while the harness
|
||||
itself stays reusable against any solver claiming Minimal.
|
||||
|
||||
**The four-condition pass rule, with the F1 Pass/Xfail discipline on
|
||||
condition 4.** `evaluate_minimal` asserts the companion's per-entry rule
|
||||
exactly: hard-constraint satisfaction (renderable, non-partial,
|
||||
`satisfied_hard_constraints`, nothing unsatisfied); internal determinism
|
||||
(byte-identical `ResolvedLayoutIR` canonical bytes *and* bitwise-identical
|
||||
metric vectors across repeated solves); a well-formed, accurate report
|
||||
(Minimal tier claim, every axis a finite `[0,1]` value, never the unmeasured
|
||||
placeholder); and every axis at or below the Quality Metric Catalog's
|
||||
Minimal-column threshold. The first real measurement (2026-07) found exactly
|
||||
one miss: **RS-1's `casting_off_quality` = 1.0** — greedy first-fit leaves a
|
||||
two-measure stub last system (glyph spans ~78.6/18.8 staff spaces, width CV
|
||||
0.61 ≥ the 0.5 anchor), the exact failure the axis exists to catch, on a
|
||||
layout that is byte-locked by the render goldens. Waiving it silently would
|
||||
fake conformance; failing the workspace would misreport a ratified-spec
|
||||
tension as a code bug. So condition 4 carries the budget harness's (F1)
|
||||
discipline: the miss is a **documented `minimal_xfail` row asserted to still
|
||||
miss** — if the layout or the catalog changes and RS-1 comes within
|
||||
threshold, the harness fails demanding promotion (remove the row), exactly
|
||||
like an F1 `XPASS`. The row's resolution is spec-side and tracked in
|
||||
`epiphany-engrave/DECISIONS.md`'s Pass-12 candidates (casting-off balance
|
||||
pass with golden regeneration, or a QMC anchor/threshold minor revision —
|
||||
the catalog's own threshold-tuning open question anticipated this).
|
||||
|
||||
**Eligibility tiers vs. solver tiers.** The corpus `Tier`
|
||||
(Common/Edge/Torture) is Agent H's eligibility taxonomy; the suite's tiers
|
||||
(Minimal/Standard) are Chapter 9 conformance tiers. The companion carries the
|
||||
same caution; the harness resolves corpus entries by `name` string only and
|
||||
never reads the corpus tier.
|
||||
|
|
|
|||
|
|
@ -108,6 +108,12 @@ pub mod roundtrip;
|
|||
pub mod corpus;
|
||||
pub mod prepass_harness;
|
||||
|
||||
// Phase 3: the Reference Suite companion's executable binding — the six-entry
|
||||
// v0.1 set solved by the real engraver under the declared configuration, with
|
||||
// the four-condition Minimal pass rule (`tests/reference_suite.rs` asserts it
|
||||
// per entry). Library-module-per-harness per DECISIONS F0.
|
||||
pub mod reference_suite;
|
||||
|
||||
pub mod convergence;
|
||||
pub mod equivocation;
|
||||
pub mod migration;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,335 @@
|
|||
//! The **Reference Suite harness** — the executable binding of the *Reference
|
||||
//! Suite* companion (v0.1.0) the companion's non-normative Harness Binding
|
||||
//! chapter says is "delivered with the reference implementation".
|
||||
//!
|
||||
//! The six v0.1 entries ([`entries`]) are transcribed from the companion's
|
||||
//! Chapter 3 ("The v0.1 Entry Set"), each named by reference-implementation
|
||||
//! builder and seed exactly as the companion's score-referencing rule
|
||||
//! (`req:refsuite:referencing`) prescribes: a seeded test-kit builder
|
||||
//! (`fixtures::ten_measure_single_staff`), a core generator the corpus pins
|
||||
//! (`generators::valid_score_rich` seed `0xF302` = corpus entry
|
||||
//! `gen_valid_score_rich`), or a zero-argument corpus entry resolved by its
|
||||
//! `name` string. [`evaluate_minimal`] runs one entry through the standard
|
||||
//! pipeline (`to_logical` → `to_constrained` → `solve`) under the companion's
|
||||
//! declared solve configuration — the solver's documented default A4 geometry
|
||||
//! and the default [`SolverConfig`] (`Standard` profile, unbounded
|
||||
//! deterministic budget, the Quality Metric Catalog's default tie-breaking
|
||||
//! weights) — and asserts the companion's four-condition per-entry pass rule
|
||||
//! (`req:refsuite:pass`) at the **Minimal** tier:
|
||||
//!
|
||||
//! 1. every hard constraint satisfied (renderable, non-partial status);
|
||||
//! 2. internal determinism (byte-identical `ResolvedLayoutIR` canonical bytes
|
||||
//! *and* bitwise-identical metric vectors across repeated solves);
|
||||
//! 3. a well-formed, diagnostically accurate report (`Minimal` tier claim,
|
||||
//! a metric vector that is valid — finite, in `[0,1]` — and computed,
|
||||
//! never the all-worst placeholder);
|
||||
//! 4. every normative metric at or below the Quality Metric Catalog's
|
||||
//! Minimal-column threshold for its axis (no v0.1 entry overrides them).
|
||||
//!
|
||||
//! Condition 4 carries the budget harness's **Pass/Xfail discipline**
|
||||
//! ([`crate::budget`], DECISIONS F1): an axis listed in
|
||||
//! [`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).
|
||||
//!
|
||||
//! Following the testkit's library-module-per-harness policy (DECISIONS F0),
|
||||
//! this module holds the machinery and `tests/reference_suite.rs` asserts it —
|
||||
//! one test per entry, so a failure names its entry. The module is
|
||||
//! solver-parametric (the crate's dependency on `epiphany-engrave` is
|
||||
//! dev-only); the integration test supplies the real `Engraver`.
|
||||
|
||||
use epiphany_core::Score;
|
||||
use epiphany_layout_ir::{
|
||||
to_constrained, to_logical, ConstraintSolver, QualityMetricKind, QualityMetricVector,
|
||||
SolveStatus, SolverConfig, SolverTier, MINIMAL_THRESHOLDS, QUALITY_METRIC_KINDS,
|
||||
};
|
||||
|
||||
use crate::corpus::corpus;
|
||||
use crate::fixtures;
|
||||
|
||||
/// A documented Minimal-threshold miss (the budget harness's `Xfail` shape):
|
||||
/// the axis and the reason it is expected to exceed its threshold today.
|
||||
#[derive(Copy, Clone, Debug)]
|
||||
pub struct MinimalXfail {
|
||||
pub axis: QualityMetricKind,
|
||||
pub reason: &'static str,
|
||||
}
|
||||
|
||||
/// One v0.1 suite entry (Reference Suite companion, Table "entries").
|
||||
pub struct SuiteEntry {
|
||||
/// The companion's entry id (`RS-1` … `RS-6`).
|
||||
pub id: &'static str,
|
||||
/// The companion's entry title.
|
||||
pub title: &'static str,
|
||||
/// The companion's construction reference (builder + seed / corpus name).
|
||||
pub construction: &'static str,
|
||||
/// Deterministic score construction per that reference.
|
||||
pub build: fn() -> Score,
|
||||
/// Documented, measured Minimal-threshold misses (see the module docs).
|
||||
pub minimal_xfail: &'static [MinimalXfail],
|
||||
}
|
||||
|
||||
fn corpus_score(name: &str) -> Score {
|
||||
let fixture = corpus()
|
||||
.into_iter()
|
||||
.find(|fixture| fixture.name == name)
|
||||
.unwrap_or_else(|| panic!("corpus entry {name} named by the Reference Suite is missing"));
|
||||
(fixture.build)()
|
||||
}
|
||||
|
||||
fn rs1() -> Score {
|
||||
fixtures::ten_measure_single_staff(0x000A_11CE)
|
||||
}
|
||||
fn rs2() -> Score {
|
||||
// The companion cites `generators::valid_score_rich(0xF302)`, "identically
|
||||
// reachable as the test-kit corpus entry `gen_valid_score_rich`" — resolve
|
||||
// through the corpus and pin the identity in `rs2_construction_reproduces`.
|
||||
corpus_score("gen_valid_score_rich")
|
||||
}
|
||||
fn rs3() -> Score {
|
||||
corpus_score("b_flat_major_scale")
|
||||
}
|
||||
fn rs4() -> Score {
|
||||
corpus_score("two_voice_counterpoint")
|
||||
}
|
||||
fn rs5() -> Score {
|
||||
corpus_score("notes_and_rests")
|
||||
}
|
||||
fn rs6() -> Score {
|
||||
corpus_score("meter_three_four")
|
||||
}
|
||||
|
||||
/// The companion's cited construction for RS-2, for the identity pin: the
|
||||
/// corpus entry must reproduce this score graph bit-for-bit.
|
||||
pub fn rs2_cited_builder() -> Score {
|
||||
epiphany_core::generators::valid_score_rich(0xF302)
|
||||
}
|
||||
|
||||
/// The v0.1 entry set, exactly the six entries of the companion's
|
||||
/// Table "entries". Every entry is required at the Minimal tier; the same six
|
||||
/// constitute the Standard subset (not asserted here: no implementation claims
|
||||
/// Standard as of this suite version).
|
||||
pub fn entries() -> Vec<SuiteEntry> {
|
||||
vec![
|
||||
SuiteEntry {
|
||||
id: "RS-1",
|
||||
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",
|
||||
}],
|
||||
},
|
||||
SuiteEntry {
|
||||
id: "RS-2",
|
||||
title: "Rich multi-region score",
|
||||
construction: "generators::valid_score_rich(0xF302) = corpus gen_valid_score_rich",
|
||||
build: rs2,
|
||||
minimal_xfail: &[],
|
||||
},
|
||||
SuiteEntry {
|
||||
id: "RS-3",
|
||||
title: "B-flat major scale",
|
||||
construction: "corpus b_flat_major_scale",
|
||||
build: rs3,
|
||||
minimal_xfail: &[],
|
||||
},
|
||||
SuiteEntry {
|
||||
id: "RS-4",
|
||||
title: "Two-voice counterpoint",
|
||||
construction: "corpus two_voice_counterpoint",
|
||||
build: rs4,
|
||||
minimal_xfail: &[],
|
||||
},
|
||||
SuiteEntry {
|
||||
id: "RS-5",
|
||||
title: "Notes and rests",
|
||||
construction: "corpus notes_and_rests",
|
||||
build: rs5,
|
||||
minimal_xfail: &[],
|
||||
},
|
||||
SuiteEntry {
|
||||
id: "RS-6",
|
||||
title: "Three-four meter line",
|
||||
construction: "corpus meter_three_four",
|
||||
build: rs6,
|
||||
minimal_xfail: &[],
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
/// What evaluating one entry measured, for the report table.
|
||||
pub struct EntryOutcome {
|
||||
pub id: &'static str,
|
||||
pub title: &'static str,
|
||||
pub status: SolveStatus,
|
||||
pub metrics: QualityMetricVector,
|
||||
/// Axes that exceeded their Minimal threshold under a documented xfail row.
|
||||
pub xfailed: Vec<QualityMetricKind>,
|
||||
}
|
||||
|
||||
/// Evaluates one entry's four-condition **Minimal** pass
|
||||
/// (`req:refsuite:pass`), panicking with the entry's id on the first violated
|
||||
/// condition. Returns the measured outcome for the report table.
|
||||
pub fn evaluate_minimal(solver: &dyn ConstraintSolver, entry: &SuiteEntry) -> EntryOutcome {
|
||||
let id = entry.id;
|
||||
// The solve the entry declares: the standard pipeline under the default
|
||||
// solver configuration (`req:refsuite:solve-config`). The page geometry is
|
||||
// the solver's construction-time parameter; the integration test pins the
|
||||
// reference solver's default to the companion's declared A4 numbers.
|
||||
let constrained = to_constrained(&to_logical(&(entry.build)()));
|
||||
let config = SolverConfig::default();
|
||||
let report = solver.solve(&constrained, &config);
|
||||
let again = solver.solve(&constrained, &config);
|
||||
|
||||
// Condition 3 (tier claim): the suite evaluates a Minimal-tier claim.
|
||||
assert_eq!(
|
||||
solver.tier(),
|
||||
SolverTier::Minimal,
|
||||
"{id}: the solver under test must claim the Minimal tier"
|
||||
);
|
||||
|
||||
// Condition 1: every hard constraint satisfied. Unsatisfiable and
|
||||
// budget-exhausted partial solves are failures, not exemptions.
|
||||
assert!(
|
||||
matches!(
|
||||
report.status,
|
||||
SolveStatus::Solved | SolveStatus::SolvedWithWarnings
|
||||
),
|
||||
"{id}: not a fully solved layout: {:?}",
|
||||
report.status
|
||||
);
|
||||
assert!(
|
||||
report.satisfied_hard_constraints,
|
||||
"{id}: hard constraints unsatisfied"
|
||||
);
|
||||
assert!(
|
||||
report.unsatisfied_constraints.is_empty(),
|
||||
"{id}: unsatisfied constraints reported: {:?}",
|
||||
report.unsatisfied_constraints
|
||||
);
|
||||
|
||||
// Condition 2: internal determinism — byte-identical canonical layout and
|
||||
// bitwise-identical metric vectors across repeated identical solves.
|
||||
assert_eq!(
|
||||
report.layout.canonical_bytes(),
|
||||
again.layout.canonical_bytes(),
|
||||
"{id}: repeated solves differ in canonical ResolvedLayoutIR bytes"
|
||||
);
|
||||
for kind in QUALITY_METRIC_KINDS {
|
||||
assert_eq!(
|
||||
report.metric_vector.axis(kind).0.to_bits(),
|
||||
again.metric_vector.axis(kind).0.to_bits(),
|
||||
"{id}: repeated solves differ on {kind:?}"
|
||||
);
|
||||
}
|
||||
|
||||
// Condition 3 (report accuracy): the metric vector is valid and computed
|
||||
// per the Quality Metric Catalog — never a placeholder.
|
||||
for kind in QUALITY_METRIC_KINDS {
|
||||
let value = report.metric_vector.axis(kind).0;
|
||||
assert!(
|
||||
value.is_finite() && (0.0..=1.0).contains(&value),
|
||||
"{id}: {kind:?} = {value} is not a valid NormalizedMetric"
|
||||
);
|
||||
}
|
||||
assert_ne!(
|
||||
report.metric_vector,
|
||||
QualityMetricVector::unmeasured(),
|
||||
"{id}: the metric vector is the unmeasured placeholder"
|
||||
);
|
||||
|
||||
// Condition 4: every normative metric within the Minimal threshold column
|
||||
// (no v0.1 entry declares an override), under the Pass/Xfail discipline.
|
||||
let mut xfailed = Vec::new();
|
||||
for kind in QUALITY_METRIC_KINDS {
|
||||
let value = report.metric_vector.axis(kind).0;
|
||||
let threshold = MINIMAL_THRESHOLDS.axis(kind);
|
||||
match entry.minimal_xfail.iter().find(|row| row.axis == kind) {
|
||||
Some(row) => {
|
||||
assert!(
|
||||
value > threshold,
|
||||
"{id}: XPASS on {kind:?} ({value} <= {threshold}) — the measured miss \
|
||||
was resolved; promote the entry by removing its xfail row ({})",
|
||||
row.reason
|
||||
);
|
||||
xfailed.push(kind);
|
||||
}
|
||||
None => assert!(
|
||||
value <= threshold,
|
||||
"{id}: {kind:?} = {value} exceeds its Minimal threshold {threshold}"
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
EntryOutcome {
|
||||
id: entry.id,
|
||||
title: entry.title,
|
||||
status: report.status,
|
||||
metrics: report.metric_vector,
|
||||
xfailed,
|
||||
}
|
||||
}
|
||||
|
||||
/// One aligned report row per outcome, for the printed metric table (run the
|
||||
/// integration test with `--nocapture` to see it).
|
||||
pub fn table(outcomes: &[EntryOutcome]) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str(&format!(
|
||||
"{:<5} {:<24} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9}\n",
|
||||
"entry",
|
||||
"title",
|
||||
"collision",
|
||||
"spacing",
|
||||
"slur",
|
||||
"beam",
|
||||
"vertical",
|
||||
"sysbreak",
|
||||
"pagefill",
|
||||
"castoff",
|
||||
"density"
|
||||
));
|
||||
for outcome in outcomes {
|
||||
let value = |kind: QualityMetricKind| {
|
||||
let v = outcome.metrics.axis(kind).0;
|
||||
if outcome.xfailed.contains(&kind) {
|
||||
format!("{v:.4}*")
|
||||
} else {
|
||||
format!("{v:.4}")
|
||||
}
|
||||
};
|
||||
out.push_str(&format!(
|
||||
"{:<5} {:<24} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9} {:>9}\n",
|
||||
outcome.id,
|
||||
outcome.title,
|
||||
value(QualityMetricKind::Collision),
|
||||
value(QualityMetricKind::Spacing),
|
||||
value(QualityMetricKind::SlurShape),
|
||||
value(QualityMetricKind::BeamSlope),
|
||||
value(QualityMetricKind::VerticalDensity),
|
||||
value(QualityMetricKind::SystemBreak),
|
||||
value(QualityMetricKind::PageFill),
|
||||
value(QualityMetricKind::CastingOff),
|
||||
value(QualityMetricKind::SymbolDensity),
|
||||
));
|
||||
}
|
||||
out.push_str("(* = documented Minimal xfail row, asserted to still miss)\n");
|
||||
out
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
//! Regression: **multi-system click-to-insert** over the real casting-off
|
||||
//! engraver.
|
||||
//!
|
||||
//! Casting-off wraps the ten-measure QUICKSTART fixture into two stacked
|
||||
//! systems, each baked back to the page's left margin. The editor's click
|
||||
//! resolution predated casting-off and assumed one flat system, which broke in
|
||||
//! two ways: the horizontal inverse gathered a region's anchors across *every*
|
||||
//! system (an x-non-monotonic list, mapping system-2 clicks to system-1 times),
|
||||
//! and the vertical inverse always found the staff's *first* line segment (the
|
||||
//! only one that keeps the manifestation stable id), reading system-2 clicks
|
||||
//! against system 1's origin. These tests pin the system-aware resolution end
|
||||
//! to end — `EditorSession` over `Engraver::default()` — where the editor-core
|
||||
//! unit tests use hand-built geometry.
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use epiphany_core::{
|
||||
CmnNominal, EventPosition, IdentifiedPitch, MusicalDuration, MusicalPosition, PitchId,
|
||||
RationalTime, Score, TypedObjectId,
|
||||
};
|
||||
use epiphany_editor_core::{EditorSession, GridResolution};
|
||||
use epiphany_engrave::Engraver;
|
||||
use epiphany_layout_ir::{Point, Rect, ResolvedSystem};
|
||||
use epiphany_testkit::fixtures::ten_measure_single_staff;
|
||||
|
||||
/// Every pitch's metric onset, from the score graph (the fixture is 40 quarter
|
||||
/// notes at `k/4`, one pitch per event — the ground truth a click must recover).
|
||||
fn pitch_onsets(score: &Score) -> BTreeMap<PitchId, MusicalPosition> {
|
||||
let mut onsets = BTreeMap::new();
|
||||
let mut pitches: Vec<&IdentifiedPitch> = Vec::new();
|
||||
for (_, _, voice) in score.voices() {
|
||||
for eid in &voice.events {
|
||||
let Some(event) = score.events.get(*eid) else {
|
||||
continue;
|
||||
};
|
||||
let EventPosition::Musical(at) = event.position() else {
|
||||
continue;
|
||||
};
|
||||
pitches.clear();
|
||||
event.collect_identified_pitches(&mut pitches);
|
||||
for ip in &pitches {
|
||||
onsets.insert(ip.id, at.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
onsets
|
||||
}
|
||||
|
||||
/// Whether `point` lies within `rect`, edges included.
|
||||
fn rect_contains(rect: &Rect, point: Point) -> bool {
|
||||
point.x.0 >= rect.origin.x.0
|
||||
&& point.x.0 <= rect.origin.x.0 + rect.size.width.0
|
||||
&& point.y.0 >= rect.origin.y.0
|
||||
&& point.y.0 <= rect.origin.y.0 + rect.size.height.0
|
||||
}
|
||||
|
||||
/// The noteheads rendered inside `bounds`, as `(x, pitch)` in ascending x — the
|
||||
/// non-synthesized pitch-sourced glyphs the horizontal inverse anchors on.
|
||||
fn noteheads_within(session: &EditorSession, bounds: &Rect) -> Vec<(f32, PitchId)> {
|
||||
let mut heads: Vec<(f32, PitchId)> = session
|
||||
.resolved()
|
||||
.glyphs
|
||||
.iter()
|
||||
.filter(|g| g.provenance.synthesis.is_none() && rect_contains(bounds, g.position))
|
||||
.filter_map(|g| match g.provenance.source {
|
||||
TypedObjectId::Pitch(pid) => Some((g.position.x.0, pid)),
|
||||
_ => None,
|
||||
})
|
||||
.collect();
|
||||
heads.sort_by(|a, b| a.0.total_cmp(&b.0));
|
||||
heads
|
||||
}
|
||||
|
||||
/// A system's staff step origin: the bottom line of its (single) staff **in this
|
||||
/// system**. The staff record's provenance is that line segment's — the exact
|
||||
/// world y the vertical inverse must measure from.
|
||||
fn system_origin_y(session: &EditorSession, system: &ResolvedSystem) -> f32 {
|
||||
let staff = system
|
||||
.staves
|
||||
.first()
|
||||
.expect("a cast system records its staff");
|
||||
session
|
||||
.resolved()
|
||||
.strokes
|
||||
.iter()
|
||||
.find(|s| s.provenance.stable_id == staff.provenance.stable_id)
|
||||
.map(|s| s.from.y.0)
|
||||
.expect("the staff record's bottom line renders")
|
||||
}
|
||||
|
||||
fn open_two_system_session() -> (EditorSession, BTreeMap<PitchId, MusicalPosition>) {
|
||||
let score = ten_measure_single_staff(1);
|
||||
let onsets = pitch_onsets(&score);
|
||||
let session =
|
||||
EditorSession::open(score, Box::new(Engraver::default())).expect("the fixture renders");
|
||||
(session, onsets)
|
||||
}
|
||||
|
||||
fn quarter() -> GridResolution {
|
||||
GridResolution::quarter()
|
||||
}
|
||||
|
||||
fn eighth() -> GridResolution {
|
||||
GridResolution {
|
||||
step: MusicalDuration(RationalTime::new(1, 8).expect("1/8 is a valid duration")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The two systems the A4 default geometry casts the fixture into (the
|
||||
/// documented `PageGeometry::default` behavior), page 1 top-first.
|
||||
fn two_systems(session: &EditorSession) -> (Rect, Rect) {
|
||||
let page = session
|
||||
.resolved()
|
||||
.pages
|
||||
.first()
|
||||
.expect("the engraver emits a page");
|
||||
assert_eq!(
|
||||
page.systems.len(),
|
||||
2,
|
||||
"A4 default geometry wraps the ten-measure fixture into two systems"
|
||||
);
|
||||
(page.systems[0].bounding_box, page.systems[1].bounding_box)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_system_2_click_resolves_to_its_own_time_and_pitch() {
|
||||
let (session, onsets) = open_two_system_session();
|
||||
let (sys1, sys2) = two_systems(&session);
|
||||
|
||||
let sys1_heads = noteheads_within(&session, &sys1);
|
||||
let sys2_heads = noteheads_within(&session, &sys2);
|
||||
assert!(!sys1_heads.is_empty() && !sys2_heads.is_empty());
|
||||
// Sanity: casting really split the run — system 2 carries strictly later music.
|
||||
let sys1_max = sys1_heads.iter().map(|(_, p)| &onsets[p]).max().unwrap();
|
||||
let sys2_min = sys2_heads.iter().map(|(_, p)| &onsets[p]).min().unwrap();
|
||||
assert!(sys2_min > sys1_max, "system 2 renders later onsets");
|
||||
|
||||
let system2 = &session.resolved().pages[0].systems[1];
|
||||
let origin = system_origin_y(&session, system2);
|
||||
|
||||
// (b)+(c): a known system-2 notehead — click its x, one staff space above the
|
||||
// *system-2* bottom line, and the horizontal inverse must answer that note's
|
||||
// onset (not the system-1 time the flat x-scale would give, since system 2
|
||||
// restarts at the left margin under system 1's x range).
|
||||
let (x, pid) = sys2_heads[0];
|
||||
let expected = onsets[&pid].clone();
|
||||
let click = Point::new(x, origin + 1.0);
|
||||
let gp = session
|
||||
.position_at(click, &quarter())
|
||||
.expect("a metric position under the click");
|
||||
assert_eq!(
|
||||
gp.position, expected,
|
||||
"a system-2 notehead click snaps to that note's onset"
|
||||
);
|
||||
|
||||
// The vertical inverse measures from system 2's own bottom line: one staff
|
||||
// space above it under the fixture's default treble clef is G4. (Against
|
||||
// system 1's origin — the regression — the same point is ~20 staff spaces
|
||||
// below the staff.)
|
||||
let pitch = session
|
||||
.staff_pitch_at(click)
|
||||
.expect("a staff under the click");
|
||||
assert_eq!(
|
||||
(pitch.nominal, pitch.octave),
|
||||
(CmnNominal::G, 4),
|
||||
"one staff space above the system-2 bottom line is G4 under treble"
|
||||
);
|
||||
|
||||
// A click in the inter-system gutter (just above system 2's box) still
|
||||
// resolves — the nearest system by vertical distance — and inverts on
|
||||
// system 2's x-scale.
|
||||
let gutter = Point::new(x, sys2.origin.y.0 + sys2.size.height.0 + 0.25);
|
||||
assert!(
|
||||
gutter.y.0 < sys1.origin.y.0 - 0.25,
|
||||
"the gutter point is outside both boxes, nearer system 2"
|
||||
);
|
||||
let from_gutter = session
|
||||
.position_at(gutter, &quarter())
|
||||
.expect("a between-systems click still resolves");
|
||||
assert_eq!(from_gutter.position, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_system_1_click_still_resolves_as_before() {
|
||||
let (session, onsets) = open_two_system_session();
|
||||
let (sys1, _) = two_systems(&session);
|
||||
let system1 = &session.resolved().pages[0].systems[0];
|
||||
let origin = system_origin_y(&session, system1);
|
||||
|
||||
// (e): every system-1 notehead resolves exactly as in the flat layout — its
|
||||
// own onset, and G4 one staff space above the bottom line.
|
||||
for (x, pid) in noteheads_within(&session, &sys1) {
|
||||
let click = Point::new(x, origin + 1.0);
|
||||
let gp = session
|
||||
.position_at(click, &quarter())
|
||||
.expect("a metric position under the click");
|
||||
assert_eq!(gp.position, onsets[&pid], "the click snaps to the onset");
|
||||
let pitch = session
|
||||
.staff_pitch_at(click)
|
||||
.expect("a staff under the click");
|
||||
assert_eq!((pitch.nominal, pitch.octave), (CmnNominal::G, 4));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn insert_into_system_2_empty_space_lands_on_the_clicked_slot() {
|
||||
let (mut session, onsets) = open_two_system_session();
|
||||
let (_, sys2) = two_systems(&session);
|
||||
|
||||
// (d): the fixture fills every quarter, so the empty space inside system 2 is
|
||||
// the off-beat between two of its noteheads. Click halfway between two
|
||||
// adjacent system-2 anchors on an eighth grid: the inverse interpolates to
|
||||
// the half-beat, and the insert must land there — a system-1 inversion would
|
||||
// put it four-plus measures early.
|
||||
let (click_x, origin, expected) = {
|
||||
let heads = noteheads_within(&session, &sys2);
|
||||
assert!(heads.len() >= 2, "system 2 renders adjacent noteheads");
|
||||
let (ax, a_pid) = heads[0];
|
||||
let (bx, _) = heads[1];
|
||||
let system2 = &session.resolved().pages[0].systems[1];
|
||||
let expected = onsets[&a_pid].clone()
|
||||
+ MusicalDuration(RationalTime::new(1, 8).expect("1/8 is a valid duration"));
|
||||
(
|
||||
(ax + bx) / 2.0,
|
||||
system_origin_y(&session, system2),
|
||||
expected,
|
||||
)
|
||||
};
|
||||
let click = Point::new(click_x, origin + 1.0);
|
||||
let placed = session
|
||||
.position_at(click, &eighth())
|
||||
.expect("a metric position under the click");
|
||||
assert_eq!(
|
||||
placed.position, expected,
|
||||
"the click names the off-beat slot"
|
||||
);
|
||||
|
||||
let outcome = session
|
||||
.insert_note_at(click, &eighth())
|
||||
.expect("the insert applies (make-room splits the covered quarter)");
|
||||
assert!(outcome.graph_changed);
|
||||
|
||||
// The new eighth note exists at the clicked musical position.
|
||||
let eighth_dur = MusicalDuration(RationalTime::new(1, 8).expect("1/8 is a valid duration"));
|
||||
let landed = session.score().voices().any(|(_, _, voice)| {
|
||||
voice.events.iter().any(|eid| {
|
||||
session.score().events.get(*eid).is_some_and(|event| {
|
||||
event.position() == &EventPosition::Musical(expected.clone())
|
||||
&& event.duration()
|
||||
== &epiphany_core::EventDuration::Musical(eighth_dur.clone())
|
||||
})
|
||||
})
|
||||
});
|
||||
assert!(landed, "the inserted eighth note sits at the clicked slot");
|
||||
}
|
||||
|
|
@ -0,0 +1,113 @@
|
|||
//! The Reference Suite companion's v0.1 entry set, asserted against the real
|
||||
//! engraver: one test per entry (so a failure names its entry), the entry-set
|
||||
//! shape, the declared solve configuration, and the printed metric table
|
||||
//! (visible with `--nocapture`).
|
||||
//!
|
||||
//! The harness machinery lives in `epiphany_testkit::reference_suite`
|
||||
//! (library-module-per-harness, DECISIONS F0); this file binds it to
|
||||
//! `epiphany_engrave::Engraver` — the crate's dev-only dependency — under the
|
||||
//! companion's declared default A4 geometry and default solver configuration.
|
||||
|
||||
use epiphany_engrave::Engraver;
|
||||
use epiphany_testkit::reference_suite::{entries, evaluate_minimal, rs2_cited_builder, table};
|
||||
|
||||
/// The suite's solver under test: the reference engraver at its documented
|
||||
/// default geometry — which `default_geometry_is_the_declared_a4_configuration`
|
||||
/// pins to the companion's declared numbers.
|
||||
fn solver() -> Engraver {
|
||||
Engraver::default()
|
||||
}
|
||||
|
||||
fn run(id: &str) {
|
||||
let entry_set = entries();
|
||||
let entry = entry_set
|
||||
.iter()
|
||||
.find(|entry| entry.id == id)
|
||||
.expect("entry id");
|
||||
let outcome = evaluate_minimal(&solver(), entry);
|
||||
print!("{}", table(std::slice::from_ref(&outcome)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_v01_entry_set_is_the_companions_six() {
|
||||
// Reference Suite companion, Table "entries": exactly RS-1..RS-6, all
|
||||
// required at Minimal (the harness evaluates every one; none is optional).
|
||||
let ids: Vec<&str> = entries().iter().map(|entry| entry.id).collect();
|
||||
assert_eq!(ids, ["RS-1", "RS-2", "RS-3", "RS-4", "RS-5", "RS-6"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_geometry_is_the_declared_a4_configuration() {
|
||||
// The companion's solve-configuration requirement declares every v0.1
|
||||
// entry solved at A4 portrait / 8 mm staff: page 105 x 148.5 staff spaces,
|
||||
// 7.5-staff-space margins, hence a 90 x 133.5 content area. The reference
|
||||
// engraver's default *is* that geometry; the suite runs on it.
|
||||
let geometry = solver().geometry();
|
||||
assert_eq!(geometry.size.width.0, 105.0);
|
||||
assert_eq!(geometry.size.height.0, 148.5);
|
||||
for margin in [
|
||||
geometry.margins.top,
|
||||
geometry.margins.right,
|
||||
geometry.margins.bottom,
|
||||
geometry.margins.left,
|
||||
] {
|
||||
assert_eq!(margin.0, 7.5);
|
||||
}
|
||||
assert_eq!(geometry.content_width(), 90.0);
|
||||
assert_eq!(geometry.content_height(), 133.5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rs2_construction_reproduces_the_cited_builder() {
|
||||
// The companion cites `generators::valid_score_rich(0xF302)` and says the
|
||||
// corpus entry `gen_valid_score_rich` pins the same seed: the two must
|
||||
// reproduce the same score graph bit-for-bit (builder-and-seed
|
||||
// referencing, `req:refsuite:referencing`).
|
||||
let via_corpus = (entries()[1].build)();
|
||||
assert_eq!(
|
||||
via_corpus.canonical_bytes(),
|
||||
rs2_cited_builder().canonical_bytes()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rs1_ten_measure_single_staff_passes_minimal() {
|
||||
run("RS-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rs2_rich_multi_region_score_passes_minimal() {
|
||||
run("RS-2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rs3_b_flat_major_scale_passes_minimal() {
|
||||
run("RS-3");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rs4_two_voice_counterpoint_passes_minimal() {
|
||||
run("RS-4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rs5_notes_and_rests_passes_minimal() {
|
||||
run("RS-5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rs6_meter_three_four_passes_minimal() {
|
||||
run("RS-6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn minimal_suite_metric_table() {
|
||||
// The whole suite in one aligned table (run with --nocapture): the
|
||||
// measured per-entry metric values behind the per-entry passes above.
|
||||
let solver = solver();
|
||||
let outcomes: Vec<_> = entries()
|
||||
.iter()
|
||||
.map(|entry| evaluate_minimal(&solver, entry))
|
||||
.collect();
|
||||
print!("{}", table(&outcomes));
|
||||
}
|
||||
|
|
@ -66,6 +66,8 @@ 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-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) |
|
||||
|
||||
|
||||
## Not yet open elsewhere
|
||||
|
|
@ -76,6 +78,9 @@ spec-compliance audit follow-up, alongside K3/K4). The 2026-07 Push-3 wiring
|
|||
work added C1..C4 (re-anchoring), D1 (bundle operation index), and E1..E5
|
||||
(edit barriers). The Phase-3 first tranche (casting-off + K1 schema-fill +
|
||||
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.
|
||||
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
|
||||
|
|
|
|||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
|
@ -0,0 +1,756 @@
|
|||
% !TEX program = xelatex
|
||||
%
|
||||
% Epiphany --- Reference Suite (companion specification)
|
||||
% Companion to the Core Specification. Compile with XeLaTeX.
|
||||
%
|
||||
% This document is versioned independently of the Core Specification
|
||||
% (independent semver; see the Versioning note in the front matter). Its preamble
|
||||
% is intentionally a self-contained copy of the core specification's preamble so
|
||||
% the two documents build independently; factoring a shared preamble file is a
|
||||
% later cleanup, not a v0.1 deliverable.
|
||||
|
||||
\documentclass[11pt,letterpaper]{report}
|
||||
|
||||
% ---------------------------------------------------------------------------
|
||||
% Packages
|
||||
% ---------------------------------------------------------------------------
|
||||
\usepackage{fontspec}
|
||||
\usepackage{geometry}
|
||||
\geometry{
|
||||
letterpaper,
|
||||
top=1.05in,
|
||||
bottom=1.05in,
|
||||
left=1.15in,
|
||||
right=1.15in,
|
||||
headheight=15pt
|
||||
}
|
||||
|
||||
\usepackage[english]{babel}
|
||||
\usepackage{microtype}
|
||||
\usepackage{parskip}
|
||||
\usepackage{xcolor}
|
||||
\usepackage{hyperref}
|
||||
\usepackage{enumitem}
|
||||
\usepackage{titlesec}
|
||||
\usepackage{fancyhdr}
|
||||
\usepackage{booktabs}
|
||||
\usepackage{array}
|
||||
\usepackage{longtable}
|
||||
\usepackage{listings}
|
||||
\usepackage{amsmath}
|
||||
\usepackage{amssymb}
|
||||
\usepackage{tcolorbox}
|
||||
\tcbuselibrary{breakable, skins}
|
||||
|
||||
% ---------------------------------------------------------------------------
|
||||
% Color palette (shared with the core specification)
|
||||
% ---------------------------------------------------------------------------
|
||||
\definecolor{epiphanyteal}{HTML}{1A4044}
|
||||
\definecolor{epiphanygold}{HTML}{8E6E2E}
|
||||
\definecolor{epiphanyink}{HTML}{1F1B16}
|
||||
\definecolor{epiphanyslate}{HTML}{6B6660}
|
||||
\definecolor{epiphanycream}{HTML}{F8F4ED}
|
||||
\definecolor{epiphanymist}{HTML}{ECE8E0}
|
||||
\definecolor{epiphanycode}{HTML}{2A2520}
|
||||
\definecolor{epiphanycrimson}{HTML}{7A2424}
|
||||
|
||||
\hypersetup{
|
||||
colorlinks=true,
|
||||
linkcolor=epiphanyteal,
|
||||
citecolor=epiphanyteal,
|
||||
urlcolor=epiphanygold,
|
||||
pdftitle={Epiphany --- Reference Suite},
|
||||
pdfauthor={The Epiphany Project},
|
||||
pdfsubject={Reference Suite companion for the Epiphany music notation platform},
|
||||
pdfkeywords={music notation, engraving, reference suite, conformance, solver tiers, test scores},
|
||||
bookmarksnumbered=true,
|
||||
bookmarksopen=true
|
||||
}
|
||||
|
||||
% ---------------------------------------------------------------------------
|
||||
% Typography (shared with the core specification)
|
||||
% ---------------------------------------------------------------------------
|
||||
\setmainfont{TeX Gyre Pagella}[Numbers={OldStyle, Proportional}, Ligatures={TeX, Common}]
|
||||
\setsansfont{TeX Gyre Heros}[Scale=0.94, Ligatures={TeX, Common}]
|
||||
\setmonofont{TeX Gyre Cursor}[Scale=0.88, Ligatures={TeX}]
|
||||
\newfontfamily\titlefont{TeX Gyre Pagella}[Numbers={OldStyle}, Ligatures={TeX, Common}]
|
||||
\newcommand{\tablenums}[1]{{\addfontfeatures{Numbers={Lining,Tabular}}#1}}
|
||||
\newcommand{\sectionsc}[1]{{\addfontfeatures{Letters=SmallCaps}#1}}
|
||||
|
||||
% ---------------------------------------------------------------------------
|
||||
% Section styling (shared with the core specification)
|
||||
% ---------------------------------------------------------------------------
|
||||
\titleformat{\chapter}[display]
|
||||
{\normalfont\filright}
|
||||
{\raggedright\color{epiphanygold}\fontsize{14pt}{16pt}\selectfont
|
||||
\scshape Chapter\ \thechapter}
|
||||
{16pt}
|
||||
{\raggedright\color{epiphanyteal}\fontsize{32pt}{36pt}\selectfont\bfseries}
|
||||
[\vspace{4pt}{\color{epiphanygold}\rule{2in}{0.6pt}}]
|
||||
\titlespacing*{\chapter}{0pt}{-20pt}{30pt}
|
||||
\titleformat{\section}
|
||||
{\normalfont\Large\bfseries\color{epiphanyteal}}
|
||||
{\color{epiphanygold}\thesection}{1em}{}
|
||||
\titleformat{\subsection}
|
||||
{\normalfont\large\bfseries\color{epiphanyteal}}
|
||||
{\color{epiphanygold}\thesubsection}{1em}{}
|
||||
\titleformat{\subsubsection}
|
||||
{\normalfont\normalsize\bfseries\color{epiphanyink}}
|
||||
{\thesubsubsection}{1em}{}
|
||||
|
||||
% ---------------------------------------------------------------------------
|
||||
% Headers and footers (shared with the core specification)
|
||||
% ---------------------------------------------------------------------------
|
||||
\pagestyle{fancy}
|
||||
\fancyhf{}
|
||||
\renewcommand{\headrulewidth}{0pt}
|
||||
\renewcommand{\footrulewidth}{0pt}
|
||||
\fancyhead[L]{\small\scshape\color{epiphanyslate}Epiphany --- Reference Suite}
|
||||
\fancyhead[R]{\small\itshape\color{epiphanyslate}\leftmark}
|
||||
\fancyfoot[C]{\small\color{epiphanyslate}\thepage}
|
||||
\renewcommand{\headrule}{
|
||||
\color{epiphanygold!50}\hrule width\headwidth height 0.4pt
|
||||
\vspace{1pt}
|
||||
\color{epiphanygold!30}\hrule width\headwidth height 0.2pt
|
||||
}
|
||||
|
||||
% ---------------------------------------------------------------------------
|
||||
% Code listing style (shared with the core specification)
|
||||
% ---------------------------------------------------------------------------
|
||||
\lstdefinelanguage{Rust}{
|
||||
keywords={fn,let,mut,pub,struct,enum,impl,trait,for,in,if,else,match,return,
|
||||
use,mod,crate,self,Self,as,where,move,async,await,const,static,
|
||||
ref,type,unsafe,extern,dyn,box,break,continue,loop,while},
|
||||
keywordstyle=\color{epiphanyteal}\bfseries,
|
||||
ndkeywords={i8,i16,i32,i64,i128,u8,u16,u32,u64,u128,f32,f64,bool,char,str,
|
||||
String,Vec,Option,Result,Box,Rc,Arc,HashMap,BTreeMap,
|
||||
NonZeroU16,NonZeroU32,NonZeroU64,Duration,Timestamp},
|
||||
ndkeywordstyle=\color{epiphanygold}\bfseries,
|
||||
sensitive=true,
|
||||
comment=[l]{//},
|
||||
morecomment=[s]{/*}{*/},
|
||||
commentstyle=\color{epiphanyslate}\itshape,
|
||||
stringstyle=\color{epiphanycrimson},
|
||||
morestring=[b]",
|
||||
morestring=[b]'
|
||||
}
|
||||
\lstset{
|
||||
basicstyle=\ttfamily\small\color{epiphanycode},
|
||||
backgroundcolor=\color{epiphanycream},
|
||||
frame=leftline,
|
||||
rulecolor=\color{epiphanygold!60},
|
||||
framesep=8pt,
|
||||
framerule=1.5pt,
|
||||
xleftmargin=10pt,
|
||||
xrightmargin=4pt,
|
||||
breaklines=true,
|
||||
showstringspaces=false,
|
||||
numberstyle=\tiny\color{epiphanyslate},
|
||||
numbersep=10pt,
|
||||
captionpos=b,
|
||||
aboveskip=10pt,
|
||||
belowskip=10pt,
|
||||
language=Rust
|
||||
}
|
||||
|
||||
% ---------------------------------------------------------------------------
|
||||
% Custom environments (shared with the core specification)
|
||||
% ---------------------------------------------------------------------------
|
||||
\newtcolorbox{openquestion}[1][]{
|
||||
enhanced, breakable,
|
||||
colback=epiphanymist, colframe=epiphanycrimson,
|
||||
fonttitle=\bfseries\color{white}, title={\scshape\hspace{2pt}Open Question},
|
||||
coltitle=white, colbacktitle=epiphanycrimson,
|
||||
arc=1pt, boxrule=0pt, leftrule=2pt,
|
||||
left=10pt, right=10pt, top=8pt, bottom=8pt,
|
||||
attach boxed title to top left={xshift=0pt, yshift=0pt},
|
||||
boxed title style={arc=0pt, sharp corners, boxrule=0pt, left=6pt, right=8pt, top=2pt, bottom=2pt},
|
||||
#1
|
||||
}
|
||||
\newtcolorbox{rationale}[1][]{
|
||||
enhanced, breakable,
|
||||
colback=epiphanymist, colframe=epiphanyteal,
|
||||
fonttitle=\bfseries\color{white}, title={\scshape\hspace{2pt}Rationale},
|
||||
coltitle=white, colbacktitle=epiphanyteal,
|
||||
arc=1pt, boxrule=0pt, leftrule=2pt,
|
||||
left=10pt, right=10pt, top=8pt, bottom=8pt,
|
||||
attach boxed title to top left={xshift=0pt, yshift=0pt},
|
||||
boxed title style={arc=0pt, sharp corners, boxrule=0pt, left=6pt, right=8pt, top=2pt, bottom=2pt},
|
||||
#1
|
||||
}
|
||||
\newtcolorbox{requirement}[1][]{
|
||||
enhanced, breakable,
|
||||
colback=white, colframe=epiphanygold,
|
||||
fonttitle=\bfseries\color{white}, title={\scshape\hspace{2pt}Requirement},
|
||||
coltitle=white, colbacktitle=epiphanygold,
|
||||
arc=1pt, boxrule=0pt, leftrule=2pt,
|
||||
left=10pt, right=10pt, top=8pt, bottom=8pt,
|
||||
attach boxed title to top left={xshift=0pt, yshift=0pt},
|
||||
boxed title style={arc=0pt, sharp corners, boxrule=0pt, left=6pt, right=8pt, top=2pt, bottom=2pt},
|
||||
#1
|
||||
}
|
||||
\newtcolorbox{nongoal}[1][]{
|
||||
enhanced, breakable,
|
||||
colback=epiphanymist, colframe=epiphanyslate,
|
||||
fonttitle=\bfseries\color{white}, title={\scshape\hspace{2pt}Non-Goal},
|
||||
coltitle=white, colbacktitle=epiphanyslate,
|
||||
arc=1pt, boxrule=0pt, leftrule=2pt,
|
||||
left=10pt, right=10pt, top=8pt, bottom=8pt,
|
||||
attach boxed title to top left={xshift=0pt, yshift=0pt},
|
||||
boxed title style={arc=0pt, sharp corners, boxrule=0pt, left=6pt, right=8pt, top=2pt, bottom=2pt},
|
||||
#1
|
||||
}
|
||||
|
||||
\newcommand{\MUST}{\textbf{MUST}}
|
||||
\newcommand{\MUSTNOT}{\textbf{MUST}\nobreak\ \textbf{NOT}}
|
||||
\newcommand{\SHOULD}{\textbf{SHOULD}}
|
||||
\newcommand{\SHOULDNOT}{\textbf{SHOULD}\nobreak\ \textbf{NOT}}
|
||||
\newcommand{\MAY}{\textbf{MAY}}
|
||||
|
||||
\setlist[itemize]{topsep=2pt, itemsep=3pt, parsep=0pt}
|
||||
\setlist[enumerate]{topsep=2pt, itemsep=3pt, parsep=0pt}
|
||||
\setlist[description]{topsep=2pt, itemsep=5pt, parsep=0pt}
|
||||
\AtBeginDocument{\color{epiphanyink}}
|
||||
|
||||
% ---------------------------------------------------------------------------
|
||||
% Document
|
||||
% ---------------------------------------------------------------------------
|
||||
\begin{document}
|
||||
|
||||
\begin{titlepage}
|
||||
\thispagestyle{empty}
|
||||
\centering
|
||||
\vspace*{2.2in}
|
||||
{\color{epiphanygold}\rule{3in}{0.8pt}}\\[18pt]
|
||||
{\titlefont\fontsize{34pt}{38pt}\selectfont\color{epiphanyteal}\bfseries Epiphany}\\[10pt]
|
||||
{\Large\scshape\color{epiphanyslate}Reference Suite}\\[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 initial entry set: six scores, required at Minimal, declared for Standard)}\\[4pt]
|
||||
{\small\color{epiphanyslate}Normative for solver conformance claims}
|
||||
\vfill
|
||||
\end{titlepage}
|
||||
|
||||
\tableofcontents
|
||||
|
||||
% ===========================================================================
|
||||
\chapter{About This Companion}
|
||||
\label{ch:about}
|
||||
|
||||
The \emph{Reference Suite} is a companion to the Epiphany Core
|
||||
Specification. It fulfils the core specification's delegation in its
|
||||
\sectionsc{Companion Specifications} appendix (the
|
||||
\texttt{sec:deferred:companions} section), which charters this document as
|
||||
``the collection of test scores against which solver conformance is
|
||||
established, with per-tier inclusion, per-tier metric thresholds, and any
|
||||
fixed-expectation tests. Versioned with this specification.''
|
||||
|
||||
This release (v0.1.0) delivers:
|
||||
|
||||
\begin{itemize}
|
||||
\item the \textbf{suite entry model} --- how entries name their test
|
||||
scores, how each solve is configured, what passing an entry and
|
||||
passing the suite mean, and how fixed-expectation tests work
|
||||
(Chapter~\ref{ch:model});
|
||||
\item the \textbf{v0.1 entry set}: six scores, every one required for
|
||||
Minimal-tier conformance and the same six constituting the
|
||||
Standard-tier subset (Chapter~\ref{ch:entries});
|
||||
\item a non-normative note on the reference implementation's suite
|
||||
harness (Chapter~\ref{ch:harness}).
|
||||
\end{itemize}
|
||||
|
||||
This document does \emph{not} cover:
|
||||
|
||||
\begin{itemize}
|
||||
\item the definition, normalization, and default thresholds of the
|
||||
quality metrics --- those are the \emph{Quality Metric Catalog}
|
||||
companion's, and this document consumes them;
|
||||
\item performance conformance (edit traces, frame budgets) --- the
|
||||
\emph{Performance Reference Suite} companion's, per the core
|
||||
specification's explicit boundary;
|
||||
\item the reference solving algorithm --- the non-normative
|
||||
\emph{Reference Algorithm} companion's.
|
||||
\end{itemize}
|
||||
|
||||
\section{Relationship to the Core Specification}
|
||||
\label{sec:about:relationship}
|
||||
|
||||
The suite's charter is the core specification's Chapter~9
|
||||
(\sectionsc{The Constraint Solver Interface}, the \texttt{ch:solver}
|
||||
chapter), \sectionsc{Conformance: The Reference Suite} section
|
||||
(\texttt{sec:solver:conformance}). That section's suite-entry requirement
|
||||
fixes what every entry consists of --- a test score in canonical
|
||||
\texttt{.musc} form; per-tier inclusion; per-tier metric thresholds; and
|
||||
optional fixed-expectation tests, used sparingly --- and fixes the pass
|
||||
rule: ``A solver claiming a given tier \MUST{} pass every entry required
|
||||
at that tier. Failure on any single entry is conformance failure at the
|
||||
claimed tier.''
|
||||
|
||||
Three more core anchors bind this document:
|
||||
|
||||
\begin{itemize}
|
||||
\item The \sectionsc{Cross-Implementation Conformance} requirement (in
|
||||
the core's solver-determinism section) enumerates the four conditions
|
||||
a conforming solver meets across the suite; this document's
|
||||
per-entry evaluation rule (Section~\ref{sec:model:pass}) is those
|
||||
conditions applied entry-by-entry.
|
||||
\item The \sectionsc{Reference Algorithm} section makes
|
||||
fixed-expectation tests the \emph{only} place the suite may force a
|
||||
particular layout; everywhere else, any algorithm within thresholds
|
||||
conforms. Section~\ref{sec:model:fixed} inherits that discipline.
|
||||
\item The \emph{Quality Metric Catalog} companion defines every metric,
|
||||
its normalization, and the default per-tier thresholds this suite's
|
||||
entries reference. This document never restates a threshold; it
|
||||
names the catalog's defaults and records per-entry overrides (v0.1:
|
||||
none).
|
||||
\end{itemize}
|
||||
|
||||
Where this document and a ratified core requirement disagree, \textbf{the
|
||||
core requirement governs} and the discrepancy is a defect in this
|
||||
document. One deliberate v0.1 deviation from the charter's letter ---
|
||||
naming scores by deterministic builder rather than shipping
|
||||
\texttt{.musc} bundles --- is called out as such, with its open question,
|
||||
in Section~\ref{sec:model:referencing}.
|
||||
|
||||
\section{Conformance}
|
||||
\label{sec:about:conformance}
|
||||
|
||||
The entry set, entry construction rules, solve configurations, and tier
|
||||
inclusions in this document are \textbf{normative}. A solver conformance
|
||||
claim at a tier is a claim about \emph{this} suite at \emph{this}
|
||||
version:
|
||||
|
||||
\begin{itemize}
|
||||
\item claiming tier $T$ means passing every entry
|
||||
(Section~\ref{sec:model:pass}) that Chapter~\ref{ch:entries} requires
|
||||
at tier $T$ --- failure on any single entry is conformance failure at
|
||||
the claimed tier;
|
||||
\item per the core specification, suite versions are tied to
|
||||
specification versions, and a conforming implementation \MUST{}
|
||||
declare which suite version it passes
|
||||
(Section~\ref{sec:model:versioning}).
|
||||
\end{itemize}
|
||||
|
||||
% ===========================================================================
|
||||
\chapter{The Suite Entry Model}
|
||||
\label{ch:model}
|
||||
|
||||
\section{Score Referencing}
|
||||
\label{sec:model:referencing}
|
||||
|
||||
\begin{requirement}
|
||||
\label{req:refsuite:referencing}
|
||||
A v0.1 suite entry names its test score by \textbf{reference-implementation
|
||||
builder and seed}: a deterministic constructor exported by the reference
|
||||
implementation's test kit (the \texttt{epiphany-testkit} crate), together
|
||||
with any seed argument, reproduces the score graph bit-for-bit. Two
|
||||
referencing forms are used:
|
||||
|
||||
\begin{itemize}
|
||||
\item a \textbf{seeded builder}: a public function taking a \texttt{u64}
|
||||
seed (e.g.\ \texttt{fixtures::ten\_measure\_single\_staff}, or a
|
||||
generator re-exported through the test kit), with the entry pinning
|
||||
the exact seed value;
|
||||
\item a \textbf{corpus name}: the \texttt{name} string of an entry of
|
||||
the test kit's tagged corpus (\texttt{corpus()}), whose builder takes
|
||||
no arguments and is deterministic by construction.
|
||||
\end{itemize}
|
||||
|
||||
The constructed score graph --- not any serialized artifact of it --- is
|
||||
the entry's test score. Implementations under test \MUST{} evaluate the
|
||||
entry against a score graph identical to the one the named builder
|
||||
produces with the named seed.
|
||||
\end{requirement}
|
||||
|
||||
\begin{rationale}
|
||||
The core charter describes each entry as ``a test score in canonical
|
||||
\texttt{.musc} form.'' v0.1 deliberately references builders instead of
|
||||
shipping bundles, because today \emph{the builders are the canonical
|
||||
definition}: they are versioned, reviewed, deterministic (seeded
|
||||
\texttt{SplitMix64} identity minting), asserted invariant-clean, and
|
||||
reproducible bit-for-bit by anyone building the reference crates --- while
|
||||
the \texttt{.musc} byte format is still absorbing schema-major evolution,
|
||||
so a shipped bundle would rot faster than the builder that made it. A
|
||||
corpus caution: the test kit tags corpus fixtures with an
|
||||
\emph{eligibility-taxonomy} tier (\texttt{Common} / \texttt{Edge} /
|
||||
\texttt{Torture}); that taxonomy is unrelated to solver conformance tiers
|
||||
(Minimal / Standard / Advanced) and carries no normative weight in this
|
||||
suite.
|
||||
\end{rationale}
|
||||
|
||||
\begin{openquestion}
|
||||
\textbf{Builder references versus shipped \texttt{.musc} bundles.} The
|
||||
charter's letter --- entries in canonical \texttt{.musc} form --- is not met
|
||||
by v0.1: builder-plus-seed is implementation-anchored, which makes the
|
||||
suite awkward for an independent implementation that does not link the
|
||||
reference test kit (it must re-derive the score graphs from the builders'
|
||||
sources). A future revision \MAY{} ship canonical \texttt{.musc} bundles
|
||||
for every entry, exactly as the charter describes, once schema-major
|
||||
bytes are stable enough that shipped bundles do not rot; at that point
|
||||
builder references would remain as the bundles' provenance record. Until
|
||||
then, the deviation is deliberate and this open question owns it.
|
||||
\end{openquestion}
|
||||
|
||||
\section{Solve Configuration}
|
||||
\label{sec:model:solve-config}
|
||||
|
||||
A suite entry is only reproducible if the solve it prescribes is fully
|
||||
specified: the same score under a different page geometry casts off into
|
||||
different systems, and several metric axes are defined over the declared
|
||||
content bounds.
|
||||
|
||||
\begin{requirement}
|
||||
\label{req:refsuite:solve-config}
|
||||
Each suite entry declares the full solve configuration it is evaluated
|
||||
under:
|
||||
|
||||
\begin{itemize}
|
||||
\item the \textbf{page geometry}: page size and margins, in staff
|
||||
spaces. In v0.1 every entry uses the reference implementation's
|
||||
documented default --- A4 portrait at an 8\,mm staff height: page
|
||||
$105 \times 148.5$ staff spaces, margins $7.5$ staff spaces on all
|
||||
four sides, hence a content area of $90 \times 133.5$ staff spaces.
|
||||
(The score graph has no home for page geometry yet: the core names
|
||||
\texttt{Canvas.layout\_defaults} without defining a type, tracked as
|
||||
Pass-12 row P12-I7, so geometry is declared per entry as a solver
|
||||
parameter.)
|
||||
\item the \textbf{solver configuration}: the \texttt{SolverConfig}
|
||||
fields. In v0.1 every entry uses the default configuration --- the
|
||||
\texttt{Standard} profile, an unbounded deterministic budget, and
|
||||
the Quality Metric Catalog's default tie-breaking weights.
|
||||
\end{itemize}
|
||||
|
||||
An implementation \MUSTNOT{} substitute its own defaults for a declared
|
||||
configuration when evaluating an entry.
|
||||
\end{requirement}
|
||||
|
||||
\section{Passing an Entry, Passing the Suite}
|
||||
\label{sec:model:pass}
|
||||
|
||||
\begin{requirement}
|
||||
\label{req:refsuite:pass}
|
||||
A solver \textbf{passes an entry at tier $T$} when, solving the entry's
|
||||
score under the entry's declared configuration, all four of the core
|
||||
specification's cross-implementation conformance conditions hold for that
|
||||
solve:
|
||||
|
||||
\begin{enumerate}
|
||||
\item every hard constraint of the solve is satisfied;
|
||||
\item the result is internally deterministic per the core's
|
||||
within-implementation rule (byte-identical
|
||||
\texttt{ResolvedLayoutIR} for repeated identical solves within one
|
||||
implementation version);
|
||||
\item the \texttt{SolveReport} is well-formed and diagnostically
|
||||
accurate --- in particular the metric vector is computed per the
|
||||
Quality Metric Catalog, never a placeholder;
|
||||
\item every normative quality metric is at or below tier $T$'s
|
||||
threshold for its axis --- the Quality Metric Catalog's default
|
||||
per-tier threshold table, unless the entry declares a per-entry
|
||||
override (no v0.1 entry does);
|
||||
\end{enumerate}
|
||||
|
||||
and additionally every fixed-expectation test the entry declares is
|
||||
reproduced exactly (no v0.1 entry declares any).
|
||||
|
||||
A solver \textbf{passes the suite at tier $T$} when it passes every entry
|
||||
required at tier $T$. Per the core specification, failure on any single
|
||||
entry is conformance failure at the claimed tier; a status of
|
||||
\texttt{Unsatisfiable} or a budget-exhausted partial solve on a suite
|
||||
entry is a failure of condition~(1) or~(3), not an exemption.
|
||||
\end{requirement}
|
||||
|
||||
\section{Fixed-Expectation Tests}
|
||||
\label{sec:model:fixed}
|
||||
|
||||
The core specification permits an entry to pin specific layout properties
|
||||
--- a particular bar's width, a specific system-break location --- that all
|
||||
conforming solvers must reproduce, and directs that they be used
|
||||
sparingly. In the resolved layout such expectations are checkable against
|
||||
concrete structures: a bar's width against its \texttt{ResolvedMeasure}
|
||||
bounding box, a system count or break location against the
|
||||
\texttt{ResolvedSystem} list and the slots at which systems begin.
|
||||
|
||||
\begin{requirement}
|
||||
\label{req:refsuite:fixed-expectations}
|
||||
v0.1 declares \textbf{no fixed-expectation tests}. The mechanism is
|
||||
deliberately unused: every v0.1 entry is evaluated by validity,
|
||||
determinism, report accuracy, and thresholds alone. An entry of a future
|
||||
suite revision that adds a fixed expectation \MUST{} state the expected
|
||||
value, the resolved structure it is checked against, and the exact
|
||||
comparison (including any tolerance).
|
||||
\end{requirement}
|
||||
|
||||
\begin{rationale}
|
||||
A fixed expectation binds \emph{every conforming solver} to one layout
|
||||
fact forever after; it is the only place the suite may force a layout,
|
||||
and none is warranted yet --- no cross-implementation ambiguity has
|
||||
surfaced that thresholds fail to resolve. The reference implementation's
|
||||
golden files (SVG snapshots, byte-anchored layouts) are
|
||||
\emph{implementation regression locks}: they pin what \emph{that}
|
||||
implementation produced so its own drift is caught, and they are
|
||||
deliberately not suite conformance --- promoting them to fixed
|
||||
expectations would freeze the ecosystem to the reference algorithm's
|
||||
choices, exactly what the core's threshold-based conformance model
|
||||
exists to avoid.
|
||||
\end{rationale}
|
||||
|
||||
\section{Suite Versioning}
|
||||
\label{sec:model:versioning}
|
||||
|
||||
\begin{requirement}
|
||||
\label{req:refsuite:versioning}
|
||||
Per the core specification, suite versions are tied to specification
|
||||
versions: each release of this companion names the core-specification
|
||||
version whose conformance story it serves, and a revision of the core's
|
||||
solver chapter that changes tier obligations requires a corresponding
|
||||
suite revision. This v0.1.0 suite serves the pre-1.0 core working draft.
|
||||
A conforming implementation \MUST{} declare which suite version it
|
||||
passes; a claim without a suite version is not a conformance claim.
|
||||
\end{requirement}
|
||||
|
||||
This document keeps its own revision history (Chapter~\ref{ch:history}),
|
||||
independent of the Quality Metric Catalog's: entry-set growth and
|
||||
threshold-override changes are suite revisions, while metric definitions
|
||||
and default thresholds revise in the catalog.
|
||||
|
||||
% ===========================================================================
|
||||
\chapter{The v0.1 Entry Set}
|
||||
\label{ch:entries}
|
||||
|
||||
\section{Overview}
|
||||
\label{sec:entries:overview}
|
||||
|
||||
\begin{requirement}
|
||||
\label{req:refsuite:entries}
|
||||
The v0.1 entry set is exactly the six entries of
|
||||
Table~\ref{tab:entries}. Every entry is \textbf{required at the Minimal
|
||||
tier}. The \textbf{Standard-tier subset is the same six entries},
|
||||
evaluated against the Quality Metric Catalog's Standard threshold
|
||||
column. No entry declares a per-entry threshold override; no entry
|
||||
declares a fixed-expectation test. No entry is designated
|
||||
Advanced-only in v0.1 (the Advanced tier adds obligations, not
|
||||
entries, until an extension's layout requirements enter the suite).
|
||||
\end{requirement}
|
||||
|
||||
\begin{table}[h]
|
||||
\centering
|
||||
\small
|
||||
\begin{tabular}{lp{4.2cm}p{6.6cm}}
|
||||
\toprule
|
||||
\textbf{Id} & \textbf{Entry} & \textbf{Construction} \\
|
||||
\midrule
|
||||
RS-1 & Ten-measure single staff &
|
||||
\texttt{fixtures::ten\_measure\_single\_staff} with seed
|
||||
\texttt{0x000A\_11CE} \\
|
||||
RS-2 & Rich multi-region score &
|
||||
\texttt{generators::valid\_score\_rich} with seed \texttt{0xF302}
|
||||
(= corpus entry \texttt{gen\_valid\_score\_rich}) \\
|
||||
RS-3 & B-flat major scale &
|
||||
corpus entry \texttt{b\_flat\_major\_scale} \\
|
||||
RS-4 & Two-voice counterpoint &
|
||||
corpus entry \texttt{two\_voice\_counterpoint} \\
|
||||
RS-5 & Notes and rests &
|
||||
corpus entry \texttt{notes\_and\_rests} \\
|
||||
RS-6 & Three-four meter line &
|
||||
corpus entry \texttt{meter\_three\_four} \\
|
||||
\bottomrule
|
||||
\end{tabular}
|
||||
\caption{The v0.1 entry set. All six entries are required at Minimal;
|
||||
the same six constitute the Standard subset. Solve configuration for
|
||||
every entry: the declared default geometry and solver configuration of
|
||||
Requirement~\ref{req:refsuite:solve-config}.}
|
||||
\label{tab:entries}
|
||||
\end{table}
|
||||
|
||||
No implementation claims Standard-tier conformance as of this suite
|
||||
version; the Standard listing exists so that the first Standard claim is
|
||||
made against a pre-declared bar rather than a bar negotiated after the
|
||||
fact. Where an entry's layout lacks a metric's geometry class (a single
|
||||
system, a single page, no drawn slurs or beams), the affected axes
|
||||
evaluate to $0.0$ under the catalog's vacuous-geometry rule and the
|
||||
entry's threshold on those axes is trivially met; each entry's coverage
|
||||
note below says which axes it exercises non-degenerately.
|
||||
|
||||
\section{RS-1: Ten-Measure Single Staff}
|
||||
\label{sec:entries:rs1}
|
||||
|
||||
\begin{description}
|
||||
\item[Construction.]
|
||||
\texttt{epiphany\_testkit::fixtures::ten\_measure\_single\_staff(0x000A\_11CE)}
|
||||
--- the seed the reference implementation's engraving and rendering
|
||||
acceptance goldens document.
|
||||
\item[Content.] A 10-measure, single-staff, single-voice metric score:
|
||||
40 quarter notes (four per measure, all C4), plus a tie, a spanner, a
|
||||
marker, and a chord symbol. Invariant-clean by construction.
|
||||
\item[Coverage.] The multi-system workhorse. Under the declared
|
||||
geometry the spaced line (about 99 staff spaces) exceeds the content
|
||||
width of 90 staff spaces and casts off into \textbf{two systems},
|
||||
chosen among the barline break candidates --- so the
|
||||
\texttt{SystemBreak}, \texttt{CastingOff}, and
|
||||
\texttt{SymbolDensity} axes are exercised non-degenerately,
|
||||
alongside \texttt{Collision} and \texttt{Spacing} (axis names per
|
||||
the catalog's \texttt{QualityMetricKind} mapping). The
|
||||
cross-cutting objects (tie, spanner, marker, chord symbol) ride
|
||||
through the projection. Single page: the \texttt{PageFill} axis
|
||||
degenerates.
|
||||
\item[Tiers, thresholds, expectations.] Required at Minimal; in the
|
||||
Standard subset. Catalog default thresholds; no overrides; no fixed
|
||||
expectations.
|
||||
\end{description}
|
||||
|
||||
\section{RS-2: Rich Multi-Region Score}
|
||||
\label{sec:entries:rs2}
|
||||
|
||||
\begin{description}
|
||||
\item[Construction.]
|
||||
\texttt{generators::valid\_score\_rich(0xF302)} (the core crate's
|
||||
generator), identically reachable as the test-kit corpus entry
|
||||
\texttt{gen\_valid\_score\_rich} (the corpus pins the same seed).
|
||||
\item[Content.] Three \emph{concurrent} regions on disjoint staves: a
|
||||
metric region (measures, an eighth-note triplet tuplet, a tie, a
|
||||
spanner, a marker, a chord symbol, a decomposition attachment), a
|
||||
proportional region (wall-clock events), and an aleatoric region
|
||||
(musical-discipline events) --- plus tombstoned pitch and event ids
|
||||
and a spelling attachment resolving to a tombstoned pitch. Every
|
||||
core graph invariant holds.
|
||||
\item[Coverage.] Multi-region, multi-staff validity: concurrent
|
||||
regions must each be cast off and placed without cross-region
|
||||
interference. At the Minimal tier this entry demands validity and
|
||||
honest diagnostics on the projection it induces --- the non-metric
|
||||
regions reach the solver as ordinary constrained IR (their
|
||||
decompositions deferred upstream), so the entry does \emph{not}
|
||||
smuggle the Advanced tier's proportional/aleatoric layout obligation
|
||||
into Minimal; it guards that a multi-region document neither breaks
|
||||
hard constraints nor corrupts the report.
|
||||
\item[Tiers, thresholds, expectations.] Required at Minimal; in the
|
||||
Standard subset. Catalog default thresholds; no overrides; no fixed
|
||||
expectations.
|
||||
\end{description}
|
||||
|
||||
\section{RS-3: B-flat Major Scale}
|
||||
\label{sec:entries:rs3}
|
||||
|
||||
\begin{description}
|
||||
\item[Construction.] Corpus entry \texttt{b\_flat\_major\_scale}
|
||||
(zero-argument deterministic builder).
|
||||
\item[Content.] Seven quarter notes ascending through B-flat major
|
||||
(B$\flat$ C D E$\flat$ F G A) in one metric region, one staff, one
|
||||
voice --- accidentals inferred in a flat context.
|
||||
\item[Coverage.] The accidental entry: flats produce glyph clusters
|
||||
with left overhang, exercising overhang-aware spacing, the same-column
|
||||
cluster of accidental and notehead (the collision axis's same-slot
|
||||
exclusion), and the \texttt{Spacing} axis under uneven column
|
||||
ink. Single system and page: the break-family and page axes
|
||||
degenerate.
|
||||
\item[Tiers, thresholds, expectations.] Required at Minimal; in the
|
||||
Standard subset. Catalog default thresholds; no overrides; no fixed
|
||||
expectations.
|
||||
\end{description}
|
||||
|
||||
\section{RS-4: Two-Voice Counterpoint}
|
||||
\label{sec:entries:rs4}
|
||||
|
||||
\begin{description}
|
||||
\item[Construction.] Corpus builder \texttt{two\_voice\_counterpoint}
|
||||
(zero-argument, deterministic).
|
||||
\item[Content.] Two voices in one staff instance: an upper quarter-note
|
||||
line and a lower quarter-note line two octaves beneath it, sounding
|
||||
simultaneously.
|
||||
\item[Coverage.] Simultaneity: both voices' notes share musical time
|
||||
columns, so each spring slot carries a two-voice cluster, and the
|
||||
low line sits below the staff (ledger territory). Exercises
|
||||
same-column stacking, the collision sweep across a vertically spread
|
||||
texture, and column-advance regularity when columns are ink-heavy.
|
||||
Single system and page: the break-family and page axes degenerate.
|
||||
\item[Tiers, thresholds, expectations.] Required at Minimal; in the
|
||||
Standard subset. Catalog default thresholds; no overrides; no fixed
|
||||
expectations.
|
||||
\end{description}
|
||||
|
||||
\section{RS-5: Notes and Rests}
|
||||
\label{sec:entries:rs5}
|
||||
|
||||
\begin{description}
|
||||
\item[Construction.] Corpus entry \texttt{notes\_and\_rests}
|
||||
(zero-argument deterministic builder).
|
||||
\item[Content.] Note, rest, note, rest --- quarter values in one metric
|
||||
region, one staff, one voice.
|
||||
\item[Coverage.] Rest glyphs interleaved with noteheads: rest columns
|
||||
carry different ink boxes than note columns, exercising the spacing
|
||||
pass's treatment of mixed column content and the collision sweep over
|
||||
non-notehead glyphs. Single system and page: the break-family and
|
||||
page axes degenerate.
|
||||
\item[Tiers, thresholds, expectations.] Required at Minimal; in the
|
||||
Standard subset. Catalog default thresholds; no overrides; no fixed
|
||||
expectations.
|
||||
\end{description}
|
||||
|
||||
\section{RS-6: Three-Four Meter Line}
|
||||
\label{sec:entries:rs6}
|
||||
|
||||
\begin{description}
|
||||
\item[Construction.] Corpus entry \texttt{meter\_three\_four}
|
||||
(zero-argument deterministic builder).
|
||||
\item[Content.] Three quarter notes under a declared
|
||||
$\tfrac{3}{4}$ time signature (three quarter-beat groups), in one
|
||||
metric region, one staff, one voice.
|
||||
\item[Coverage.] Meter variety: the declared time signature drives
|
||||
measure length past the whole-note default, exercising the
|
||||
meter-resolution path that feeds the layout projection. Guards that
|
||||
a non-default meter neither breaks hard constraints nor perturbs
|
||||
spacing regularity. Single system and page: the break-family and
|
||||
page axes degenerate.
|
||||
\item[Tiers, thresholds, expectations.] Required at Minimal; in the
|
||||
Standard subset. Catalog default thresholds; no overrides; no fixed
|
||||
expectations.
|
||||
\end{description}
|
||||
|
||||
% ===========================================================================
|
||||
\chapter{Harness Binding (Non-Normative)}
|
||||
\label{ch:harness}
|
||||
|
||||
This chapter is informative. The reference implementation binds this
|
||||
suite to executable checks in its test kit, following the test kit's
|
||||
established library-module-per-harness pattern (one public module per
|
||||
harness, mirroring the corpus and prepass harnesses, with an integration
|
||||
test driving it). The suite harness constructs each Chapter~\ref{ch:entries}
|
||||
entry from its named builder and seed, solves it under the declared
|
||||
configuration, and asserts the Minimal-tier pass of
|
||||
Requirement~\ref{req:refsuite:pass}; it is delivered with the reference
|
||||
implementation.
|
||||
|
||||
\begin{nongoal}
|
||||
The harness is not part of this document's normative surface. Conformance
|
||||
is defined by Chapters~\ref{ch:model} and~\ref{ch:entries} alone; an
|
||||
independent implementation may bind the suite with any machinery that
|
||||
evaluates the same entries under the same configurations. Likewise, the
|
||||
reference implementation's golden files (SVG snapshots, byte-anchored
|
||||
layouts) are that implementation's regression locks, not suite
|
||||
conformance (Section~\ref{sec:model:fixed}).
|
||||
\end{nongoal}
|
||||
|
||||
% ===========================================================================
|
||||
\chapter{Revision History}
|
||||
\label{ch:history}
|
||||
|
||||
\begin{longtable}{p{2cm} p{2.5cm} p{9cm}}
|
||||
\toprule
|
||||
\textbf{Date} & \textbf{Section} & \textbf{Change} \\
|
||||
\midrule
|
||||
\endhead
|
||||
\today & All & 0.1.0 --- Initial companion: pins the suite entry model
|
||||
(builder-and-seed score referencing with the \texttt{.musc}-bundle open
|
||||
question, per-entry solve configuration over the declared A4/8\,mm
|
||||
default geometry, the four-condition per-entry pass rule, the
|
||||
deliberately empty fixed-expectation set, suite-version declaration);
|
||||
delivers the six-entry v0.1 set
|
||||
(\texttt{ten\_measure\_single\_staff} seed \texttt{0x000A\_11CE},
|
||||
\texttt{valid\_score\_rich} seed \texttt{0xF302},
|
||||
\texttt{b\_flat\_major\_scale}, \texttt{two\_voice\_counterpoint},
|
||||
\texttt{notes\_and\_rests}, \texttt{meter\_three\_four}), all required
|
||||
at Minimal and all constituting the Standard subset under the Quality
|
||||
Metric Catalog's default thresholds, with no per-entry overrides. \\
|
||||
\bottomrule
|
||||
\end{longtable}
|
||||
|
||||
\end{document}
|
||||
Loading…
Reference in New Issue