The rendered slurs were wrong in three independent ways, all visible in the
two-staff and three-staff goldens.
1. Side. SlurDirection::Auto always arced above. The single-voice rule is
OPPOSITE the stems -- all stems up puts the slur under the noteheads, all
down puts it over them, and a mixed-stem span (which has no notehead side)
goes above. Every Auto slur over a stem-up passage was drawn through its own
stems. This is why stem direction had to land first: with every stem pointing
up, "opposite the stems" means nothing.
2. Endpoints. They sat at staff_top + gap -- a constant offset from the STAFF,
not from the notes -- so a slur between two C6s hung below its own noteheads
and crossed their ledger lines. They now sit a gap outside the endpoint
column's ink, at the notehead's centre. Where the stem points the same way as
the slur, that ink includes the stem, so the endpoint clears the stem tip.
3. Clearance. The apex was span-proportional and blind, so a note between the
endpoints poked straight through the arc. ColumnInk -- per staff, per column:
top, bottom, stem direction, notehead centre -- is the obstacle field. The
control points sit on the chord at thirds, so x is exactly linear in t and
the arc's departure from the chord is 3*lift*t*(1-t); a column at t needing d
more clearance forces an apex of at least d/(4*t*(1-t)).
An authored height is a floor, not a ceiling: clearance may raise it, so obeying
an author cannot draw a slur through a note. An authored direction still wins.
Obstacles are measured at the notehead CENTRE, the same x the endpoints use. The
first cut used the raw column x, which skews t and silently over-lifts: the
two-staff slur cleared its C6 by 4.05 spaces where 3.5 was needed. The clearance
test now asserts an upper bound as well as a lower one.
SLUR_INSET is gone. Endpoints at the notehead centres are what its 0.6-space
"tuck" approximated for the start point -- and got wrong for the end, where it
tucked a full notehead width to the LEFT of the final note.
Four mutations verified: always-above, staff-relative endpoints, no clearance
pass, and obstacles at the column x. The staff-relative-endpoint mutation PASSED
at first -- the tests asserted only "above the staff" / "below the staff", which a
staff-relative endpoint satisfies by construction. The exact-endpoint assertion
exists because that mutation survived.
Projection change, so no version moves; goldens churn.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every stem in the engine pointed up, on the notehead's right, at a constant
octave. A C6 three ledger lines above the staff grew an upward stem shooting past
everything -- visible in the two_staff and three_staff renders. It is also why the
slurs are wrong: an Auto slur is placed OPPOSITE the stems, so no correct side
could be chosen while every stem pointed the same way. Stems are therefore a
prerequisite for the slur fix, not merely adjacent to it.
- Direction: away from the middle line, decided by the head furthest from it,
ties going down (the convention for a note on the line, and for a chord that
straddles it evenly).
- Attachment: the side it points -- an up-stem at the lowest head's right edge,
a down-stem at the highest head's left. Read from the head's own bounding box
rather than the rounded NOTEHEAD_STEM_X: for noteheadBlack those are 1.1807
and 0, and 1.1807 is Bravura's real stemUpSE (1.18). The old 1.15 was a
rounding -- it accounts for the whole of ten_measure's churn, where every stem
moved right by 0.031 and nothing else moved at all.
- Length: an octave from the outer head, but drawn out to the middle line when
the note lies beyond it, so no stem dangles in the ledger field.
No version moves. This is the PROJECTION changing, not a solver: to_constrained
emits different geometry from the same graph, so ENGRAVER_VERSION's promise (same
input => same output) is untouched. Every golden churns, stub and engrave alike,
because stems are constrained-stage geometry.
Locked by a_stem_points_away_from_the_middle_line_and_reaches_it, which re-pitches
a generated score across four octaves -- the corpus generator writes only low
notes, so nothing in the suite had ever exercised a down-stem. Mutation-verified:
restoring always-up plus fixed-octave fails it.
Parked, found on the way: BRAVURA_METRICS' notehead anchors are unusable as
written. They name stemUpNW/stemDownSE -- the corners an up- and down-stem never
attach to -- and their x of 1180 reads like 1.18 staff spaces written in
thousandths rather than the table's 1/1024 units (1.18 sp = 1208). Nothing
consumes them except metrics_hash, so correcting them would move the
GlyphCatalogIdentity every conformance claim declares. That makes three parked
candidates; the house rule opens a batch pass at three.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
ENGRAVER_VERSION 11 -> 12. The inter-staff solve now closes a slack pair as well
as opening a crowded one, realizing the InterStaffGap band's declared height
exactly. SYSTEM_STAFF_PITCH is demoted from a floor to an initial arrangement the
solve fully renegotiates. This is what vertical_density_penalty was reporting: an
un-pressured multi-staff system sat at 0.739, honest sprawl against the declared
gap, because the axis is symmetric and the solve only ever expanded.
The band's height had no agreed meaning, so pin it: it is an INK CLEARANCE -- the
separation between the two staves' outermost content, exactly the unit
req:qmc:vertical measures. preferred 2.0 -> 5.0, min 1.0 -> 2.0. The old 2.0 was
a placeholder reconciled with nothing: neither the 8.0 staff-box gap the fixed
pitch of 12 produces, nor the ~6.4 ink clearance it leaves for plain content.
Realizing it would have crushed a relaxed system to a pitch of ~7.6. At 5.0 plain
ledgered content settles near a pitch of 10.6.
Making the solve two-sided immediately exposed a CASCADE DEFECT latent since v11.
The recurrence subtracted the upper staff's shift from the measured gap and then
added it back through the accumulator, so every pair below the first was
over-separated by exactly the shift above it. Both staves move; the relation is
shift_lower = shift_upper + target - (upper_lo - lower_hi), the UNSHIFTED gap.
three_staff_close_content's lower pair realized 21.06 against a declared 4.0. It
was invisible on two-staff fixtures (shift_upper = 0) and invisible to
inter_staff_shifts_cascade_down_three_staves, which asserted only s2 > s1 -- true
under both the correct and the double-counting recurrence.
What caught it was the metric measuring realized clearance back from the BAKED
output instead of the solve's own extents. Reading back solver intent would have
reported 0 and shipped the over-separation again. That design choice was made one
commit earlier for exactly this reason; the catalog rationale now recommends it to
any conforming implementation.
Once the solve realizes each declared clearance exactly, every inter-staff unit is
0 on a healthy solve -- the axis becomes a solver self-check, and its MEAN can no
longer distinguish "measured every realization" from "measured one". So
vertical_raw is split into vertical_units and the regressions assert the unit SET.
Four mutations verified: the double-counting recurrence, expand-only, the
glyph-members band filter, and first-system-only measurement each fail a named test.
No normative change, no version move: QMC formula, units, anchors, thresholds all
untouched; only its non-normative rationale is refreshed. Churn is the two
multi-staff engrave goldens: two_staff grew by exactly 3.0 (the target change, no
cascade); three_staff SHRANK by 9.06 -- the same +3 per pair, less the 17.06 of
over-separation the defect was adding. Single-staff and every stub golden are
byte-stable.
The 5.0 was the user's call. 4.0 ("one staff height") was chosen first and
withdrawn once its true consequence -- pitch 9.57, not the 11.04 an arithmetic slip
of mine had projected -- was measured rather than inferred. The slip: deriving
plain-content ink clearance from an aggregate metric by assuming two contributing
units when it had three.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The reviewer signed off on the inter-staff slice with one named residual risk:
the 3+-staff cumulative shift cascade was documented and traced correct but had
no fixture behind it -- valid_score_rich's "three staves" are three separate
single-staff regions, so each lands in its own system and the cascade never runs.
three_staff_close_content puts three staves in ONE region with deliberately
asymmetric pressure: the upper pair collides hard (C1 against C7), the lower pair
only gently. That asymmetry is what makes the fixture discriminating. Sizing each
pair independently -- the plausible wrong implementation -- measures the lower
pair against the middle staff's ORIGINAL position, hands the bottom staff only
its own small correction, and drags it back up through the middle staff.
Verified by mutation, not by assertion alone: with the cascade removed the bottom
staff's shift collapses from 34.68 to 4.56 against the middle staff's 15.06, and
both the shift ordering and the staff-line-gap assertions fail. two_staff_close_content
still passes under that same mutation, which is precisely why the new fixture was
needed.
Writing the test also corrected a wrong mental model, now recorded in DECISIONS.md:
a shift INCREMENT generally exceeds the lower pair's own raw correction, because
the upper staff's descent has itself eaten into that pair's gap and must be undone.
The first version of the test asserted the opposite and failed against a correct
solve.
The fixture additionally pins curve attribution against a three-band choice -- the
slur must still find the bottom staff, not merely the nearer of two -- and carries
its own render golden. No existing golden churns.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>