to_constrained took the active clef from the staff instance's clef_sequence and
fell back to Clef::default() -- treble. A staff that declared its clef only on the
Staff, with no ClefChange, therefore drew a treble clef and placed every note
against it. The field was decorative in the projection.
It is the fallback. StaffContent now carries default_clef -- the clef belongs to
the Staff, the sequence to the StaffInstance, and resolving "the clef at time t"
needs both -- and active_clef_or(clefs, at, default) resolves against it.
active_clef remains as that with the treble default, for callers with no staff to
hand, so the public API is intact.
The part worth pausing on: epiphany-editor-core reads the same function for
hit-test pitch resolution. Fixing only the projection would have left a click on a
bass staff resolving its pitch as treble -- the engraved clef and the editor
disagreeing about what note is where. Both now go through active_clef_or.
Removal was rejected: the field is named for its purpose, is encoded on the wire,
and dropping it would be schema-major.
Zero golden churn: every fixture and generator declares treble, which is also
Clef::default(), so nothing that exists today moves. Locked by
a_staff_declaring_only_a_default_clef_engraves_in_it and mutation-verified by
restoring the Clef::default() fallback. Scoping that test by provenance was
necessary -- valid_score_rich has three staves and only one was re-clefed, so the
first assertion I wrote ("no gClef anywhere") failed against a correct fix.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
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>
Review follow-up on 5b016f3. Bumping the catalog to 0.3.0, I ran a mechanical
find-and-replace over the version string and did not reread the sentences around
it. Result: layout-ir's quality.rs credited the spacing_distortion refinement to
v0.3.0 when that was v0.2.0, and engrave's quality.rs still announced v0.2.0
while implementing the v0.3.0 unit set.
Each version now says what it actually did: v0.2.0 narrowed spacing_distortion's
measurement DOMAIN to rhythmic columns; v0.3.0 narrowed vertical_density_penalty's
CONTRIBUTING UNITS to one per realization of a gap band. Both narrowed what is
measured over, not what it is normalized against -- so the transcribed constants
(anchors, thresholds, warning fraction) have not moved since v0.1.0, which is the
invariant worth stating and the reason the two revisions were safe.
Swept for the same rot rather than fixing only the two reported. Three more
present-tense claims pinned a version that will keep going stale: engrave and
layout-ir DECISIONS both said "the companion (v0.2.0) ratifies/pins the nine
axes", and the reference suite claimed RS-1 passes "under engrave v3, QMC v0.2.0
anchors" -- engrave is at 11. All three now cite the current version and name what
is actually invariant across revisions. The remaining "QMC 0.1.0 -> 0.2.0"
mentions are historical records of what that revision did and stay as they are.
Docs only. Gate green; zero golden churn.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Review follow-up on 43215c7. Both findings were right, and the second falsified a
comment I wrote in that very commit.
1. Region staff bands were still identified by glyph `members`. vertical_raw
measured content over all primitives but decided WHICH staff bands belong to a
region by glyph membership -- reintroducing the assumption the change exists to
shed. A staff band is allowed to own no glyphs: to_constrained emits one per
staff of the region regardless, and a percussion-clef staff (no bundled glyph,
so it engraves to a traced anchor stroke) with no notes owns only staff-line
strokes. Membership now comes from content presence in one of the region's
systems, which identifies the band exactly -- a staff band is per-(staff,
region), so its content can land nowhere else.
2. Only the first realizing system was measured, justified by a comment claiming
rigid system translation makes every realization agree. The inter-staff solve
had just falsified that: it sizes each system's gaps from that system's own
content. req:qmc:vertical now counts ONE UNIT PER REALIZATION, matching how
realized inter-system gaps were already counted. That is a contributing-unit
change, so unlike 43215c7's clarification the catalog moves: QMC 0.2.0 -> 0.3.0
(the P12-I12 precedent). Raw formula, anchor, orientation, thresholds unchanged.
Two new fixtures, because an unexercised fix is what I criticised last round:
percussion_placeholder_staff (a valid, invariant-clean score whose lower band owns
zero glyphs and six strokes) and two_staff_wrapping_pressure (one region, two
systems, staff-line gap 15.93 where it collides and 7.87 where it is slack). Both
mutation-verified: the members filter scores 4.8e-7, first-system-only scores
1.3e-7 -- each ~0 where the corrected axis reports real deviation.
What the per-realization count exposes is not comfortable, and is recorded rather
than smoothed over: two_staff_wrapping_pressure now scores 0.739. Its pressured
system solves to the declared gap exactly; its slack system sits at ~5 staff
spaces against a preferred 2.0. The axis is symmetric -- a gap wider than
preferred is sprawl exactly as a narrower one is crowding -- and this solve only
expands, never compresses. The deferral "compressing an OVER-wide fixed gap toward
preferred ... rarely wanted" is promoted to measurably wrong. Named, not fixed:
compression is a layout change (golden churn, ENGRAVER_VERSION move), not a
measurement one.
Adjacent finding, parked: Staff::default_clef is never consulted -- to_constrained
takes the active clef from the instance's clef_sequence and falls back to
Clef::default() (treble), so a staff declaring its clef only on Staff engraves as
treble. Verified (no layout-ir consumer reads the field). Filed in layout-ir
DECISIONS with the ConstrainedLayoutIR listing gap, pending a >=3-candidate batch.
Measurement-only: no layout change, ENGRAVER_VERSION stays 11, zero golden churn.
Gate green; QMC PDF rebuilds clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two items the inter-staff solve deferred turned out to be one thing -- and it was
not the "metric-vs-solver tension" I filed it as. The catalog was right; the
engraver was non-conforming.
req:qmc:vertical has always defined the realized inter-staff gap as the separation
"between the adjacent CONTENT EXTENTS the band separates". vertical_raw measured
the separation between the two bands' glyph `members`, because until primitive
band ownership (efaebb9) a band listed no strokes or curves to own. A staff's
outermost ink is usually not a glyph. On two_staff_close_content the solve cleared
the declared 2.0 gap exactly, while the glyph-ink gap was 5.06 -- so the axis
reported |5.06-2|/2 = 1.53, saturated to 1.0, and fired a Standard-tier floor
warning on a correct layout. The metric was charging the solver for the ledger and
slur ink it had made room for. Axis now reads 2.7e-7; the warning is gone.
Two design calls worth naming:
- The geometry is read back from the BAKED output, not from the solve's own
staff_ext. Reading back solver intent would make the axis circular and blind
to exactly the bug class that bit twice this week; now a shift the bake fails
to apply to some primitive class surfaces as a real deviation. CastLayout
gained stroke_system/curve_system for it -- a stroke carries no spring slot,
so system_of_slot cannot answer for it.
- The solve now targets the preferred_height of the InterStaffGap band
to_constrained emitted for that staff pair, not VerticalBand::inter_staff_gap's
default. That is what makes the band a height model rather than a constant:
solve and metric agree by construction, not by both calling one constructor.
NO version move. Formula, contributing units, anchor, and normalization are
unchanged -- only a wrong measurement was. This is the P12-I11 precedent
(engrave-side resolution), not P12-I12 (which redefined spacing_distortion's unit
and did move the catalog). The catalog gains a clarification of what "content
extent" means, since before band ownership that reading was arguably
unimplementable, which is why the defect survived. Its stale rationale (still
claiming the vertical spring solve is deferred) is refreshed, and the axis's
inter-system half is recorded as a genuine trade-off against page_fill_efficiency
rather than a defect, so it is not re-filed as a bug.
Measurement-only: no layout change, ENGRAVER_VERSION stays 11, zero golden churn.
Mutation-verified: reverting vertical_raw to glyph-only measurement scores 1 and
fails the new assertion. Gate green, QMC PDF rebuilds clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Stroke and Curve gain `vertical_band: VerticalBandId`, the field GlyphObject has
always carried. Curve's own doc comment used to call it "a *free* primitive (no
vertical band, no spring slot)" -- but the projection computed each primitive's
band, used it for the glyphs, and threw it away for the strokes and curves.
The engraver then reconstructed it geometrically, and got it wrong twice: nearest
glyph by x handed a lower-staff stem to the upper staff (4132a7a), and nearest
glyph to a slur's start endpoint handed a bottom-staff slur to the top staff
(b1bfe04). Both tore primitives off their own notes, both reached a committed
golden, both were caught by review rather than by the gate. A slur is the proof
the inference can never be made safe: its endpoints are deliberately lifted clear
of its own staff, into the zone where the nearest notehead belongs to the
neighbour. No distance metric recovers the owner.
Both fixes were correct and both were the wrong shape -- reconstructing by
inference a fact the projection had in hand and discarded. So: the projection
declares it (a slur's staff is its notes' staff), the engraver's attribution
becomes three map lookups, and ~60 lines of geometric rules, epsilons, and
fallbacks are deleted.
Two bands had to become unconditional, since strokes could otherwise name bands
that no glyph had caused to exist -- validation now rejects that as UnknownBand:
- a staff band per staff of the region, in the region's own staff order (the
order y_origin stacks by), not only for staves that emitted a glyph. A staff
whose clef is unbundled engraves to an anchor *stroke* and no glyph.
- the margin band unconditionally, because a region's own traced anchor is a
stroke that names it whether or not a margin glyph puts a member in it.
Both may carry zero members, as an inter-staff gap band already did: membership
realizes the spring solve over glyphs; existence is what attribution needs.
Strokes and curves are deliberately NOT added to VerticalBand::members -- their
band reference is one-way.
vertical_band is not part of ResolvedLayoutIR::canonical_bytes (primitives are
encoded field-by-field), so this is layout metadata outside the canonical
encoding: no companion-version bump, and ENGRAVER_VERSION stays at 11 because the
output is unchanged. Zero goldens churn -- which is the evidence that the declared
owner agrees with the inferred one across the entire corpus.
Locked by every_stroke_and_curve_names_a_band_that_exists, verified by mutation:
making the margin band conditional again fails it on valid_score_rich. The two
tear-off regressions are kept -- they now assert an outcome the data model
guarantees, which is where a dropped declaration would surface.
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>
The stem tear-off repaired in 4132a7a had a twin one layer deeper, live in
the same fixture and baked into the same golden: the bottom staff's slur was
attributed to the TOP staff, kept shift 0, and tore off its own notes.
A distance metric cannot fix this one. A slur's start endpoint is deliberately
lifted off its notes -- staff_top + gap above, staff_bottom - gap below -- into
the inter-staff zone, where the nearest glyph is routinely a note on the
adjacent staff (here, a top-staff ledger note). So attribute a curve the way it
is drawn: the arc's direction picks the side. An upward arc (p1.y >= p0.y) hangs
below a staff -- take the greatest staff-line band bottom at or above p0.y; a
downward arc sits above one -- take the smallest band top at or below p0.y.
Fall back to the nearest band mid-line for a curve inside a staff.
Locked by a_slur_travels_with_its_own_staff, verified to fail without the fix
(d_bottom=8.695 > d_top=7.235 -- the slur riding the wrong staff).
Also assert the two metrics the solve moves rather than leaving them untested:
collision_penalty is 0 (the staves separate cleanly) and vertical_density_penalty
saturates at 1.0 -- the solve targets content extents while the metric scores the
realized gap against the band model's preferred height. That is the same
metric-vs-solver tension as casting_off under justification; DECISIONS.md records
it, plus three further reviewed gaps: staff-less content takes shift 0, the
preferred gap is read from the constructor rather than the region's declared
band, and the 3+-staff cumulative cascade is correct but unexercised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Self-review of the inter-staff solve found a real bug: stroke->staff attribution
reused component_glyph, whose fallback picks the nearest glyph by X ALONE. That
is correct for a SLOT — both staves of a system share their x columns, hence
their spring slots, so the horizontal delta is the same either way — but wrong
for a STAFF: it handed a lower-staff stem to the UPPER staff's notehead. The
stem then kept the wrong vertical shift and tore off its own head (measured
worst stem->notehead distance 5.837 on the two-staff fixture vs 1.150, the stem
x-inset, on the single-staff one), and it polluted the upper staff's content
extent, inflating the computed gap.
Fix: the staff attribution uses a 2-D nearest for that fallback (a ledger still
resolves via owning_glyph's shared Pitch source; a staff line via its Staff
source). component_glyph is unchanged and still serves the horizontal path.
Also corrected: staff-attributed primitives now contribute their y ONLY through
the shifted path (Extent::add_x for x, add_y for the shifted staff extent), so a
lower staff's UNSHIFTED content can no longer inflate a system's max_y. Dead
Extent::add removed.
The corrected attribution yields a smaller, more accurate separation (two-staff
view_box height 36.1 -> 31.1). Regression multi_staff_stems_stay_on_their_own_
staff (verified to fail at 5.837 without the fix). Only the two-staff engrave
golden churned; single-staff goldens byte-stable. 948 tests, clippy 0, docs
-D warnings, conformance 8/8.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The last vertical-spring piece: the gaps BETWEEN a system's staves are
renegotiated so tightly ledgered or slurred adjacent staves — which the
constrained stage stacks at a fixed pitch — separate. ENGRAVER_VERSION 10 -> 11
(a multi-staff score whose staves press together shifts them apart; a
single-staff score, with no inter-staff pair, is byte-identical).
Attribution (vertical_band + owning-glyph, per the chosen approach): a glyph via
its vertical_band (VerticalBandKind::Staff -> StaffId); a stem/ledger via its
notehead (component_glyph); a staff line via its Staff source; a slur via the
notehead nearest its start. Spacing is horizontal-only, so a primitive's y is
unchanged from the source frame the attribution reads.
The solve: per system, per staff, collect the real content y-extent (glyphs,
strokes, curves — ledgers and slurs included); order staves top-to-bottom by
their staff-line reference y (order fixed); shift each staff down by the
cumulative amount needed to bring its gap to the one above up to the band
model's preferred inter-staff gap. staff_shift[(system, staff)] is a per-staff
dy the bake applies (Placement::sunk) atop the per-system dy, so glyphs,
strokes, curves, the staff/measure/system records, content bounds, hit-test, and
quality metrics all read the same shifted geometry. The shifts grow each
system's extent, which the vertical stacking and justification then consume.
Regression inter_staff_solve_separates_colliding_staves (the two-staff fixture's
staff-line gap opens past the fixed pitch; a single-staff score keeps one staff
per system) + the two_staff_close_content render golden updates (slice 1 tight,
slice 2 separated). Only that engrave golden churned; single-staff goldens
byte-stable. 947 tests, clippy 0, docs -D warnings, conformance 8/8.
This completes the Standard-tier layout story end to end.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An adversarial review found a real regression: removing the greedy overflow
re-check left walk_region's has_note skip with no safety net. When a note-less
leading measure precedes a soft (or automatic) break at a barline, walk_region
skips the break — the closing system has no content — but the DP, which treated
that barline as a forced segment boundary, optimized each side independently and
could not foresee the skip. The following optimizer-filled measures then absorb
the furniture measure and silently overflow into a MULTI-measure overfull system
(forbidden by the module's own contract). The review verified the rest of the DP
sound (reachability can't yield a giant overfull system, determinism holds).
Fix: restore the greedy overflow check as a fallback net — walk_region also
breaks before a measure that would overflow the content width
(chunk_hi[i] - current_lo > width_limit, guarded by has_note). In the common
content-full case the DP's break fires first, so the net never triggers and the
geometry is the optimizer's (zero golden churn).
Regression: a_content_less_measure_before_a_soft_break_never_overflows (a wide
note-less M0 + a soft break + narrow-then-wide measures; verified to fail
without the net: "system 0 spans 5 measures at width 60 > 42"). 945 tests,
clippy 0, docs -D warnings, zero golden churn, conformance 8/8.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Casting-off's greedy first-fit + tail-only widow rebalance is replaced by a
deterministic badness-minimizing break search (optimal_breaks, a Knuth-Plass-
style dynamic program over the measure boundaries). ENGRAVER_VERSION 9 -> 10.
Objective: minimize the sum over ALL systems of the squared normalized underfill
((width_limit - w)/width_limit)^2. Squaring evens the systems; including the
FINAL system in the sum subsumes the old widow rebalance (the optimizer won't
leave a narrow final stub if a balanced partition is cheaper). It is the
additive, DP-tractable analog of the retired distribution_cost (max of the
catalog's break penalty and width-CV imbalance). On the ten-measure fixture the
search settles on 5/4 measures where greedy left a fuller-then-shorter split,
filling the final system more and pulling casting_off_quality down (~0.80 ->
~0.61) — the payoff, visible now that horizontal justification drives
system_break to ~0 so casting_off mostly sees the last system's fullness.
Break requirements (hard/soft/page) bound the DP's segments — a system may not
span a forced break — and walk_region still honours them and records skipped
content-less soft breaks as IrOverride, unchanged; optimal_breaks reports only
the automatic breaks. A system may exceed the width only as a single
unsplittable measure. Minimal still makes no optimality claim.
Deterministic: minimizes lexicographic (cost, system_count). Tests:
optimal_breaks_{balances_systems_and_avoids_a_final_widow, never_spans_a_forced_
break, is_deterministic_and_empty_when_unbounded}; the widow test now checks the
balanced measure distribution; wrapping-fixture metrics updated (casting_off
improved). Removed rebalance_widows/rebalance_region/distribution_cost + their
tests. Goldens regenerated (balanced systems; view_box stable — justification
still fills to width). 944 tests, clippy 0, docs -D warnings, conformance 8/8.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An adversarial review confirmed the vertical-justification geometry is sound on
all axes (sign, no-overflow, no-collision, content_bottom, forced/overfull, bake
consistency, determinism, single-page invariance), and found one real gap: the
pass stretches inter-system gaps, which vertical_density_penalty measures — so
justified pages score higher on that axis, undocumented and untested.
This is the same metric-vs-justification tension as the horizontal casting_off
note: page_fill drops to ~0 (the win) while the deviation-from-preferred density
metric charges for the stretch. A sparse justified page (large per-gap stretch)
is charged more — a defensible signal, though the current linear penalty
over-charges a moderate uniform stretch. The catalog refinement (score only
excess stretch, or measure gap uniformity) is the deferred follow-up.
Documented in DECISIONS and pinned by a new test
(vertical_justification_trades_page_fill_for_inter_system_density: a multi-page
solve fills non-final pages, page_fill < 0.1, vertical_density > 0), so the
interaction is no longer silent. No code/geometry change. clippy 0, 53 engrave
tests.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The vertical analog of per-system justification, and the first piece of the
deferred vertical spring solve: the systems of every non-final page spread so
the last system's bottom reaches the content bottom, filling the page height.
A second pass after the top-down stacking loop, once page membership is known:
for each non-final page with >=2 systems it computes the vertical slack (the
last system's natural bottom above the content bottom) and distributes it evenly
across the inter-system gaps — system i (0-based on the page) sinks by i/(n-1) of
the slack, so the first stays at the content top and the last lands on the
content bottom. Only Placement::dy changes, so it composes cleanly with
horizontal justification (independent axes). ENGRAVER_VERSION 8 -> 9.
The last page stays ragged-bottom (top-aligned, engraving convention), so a
single-page score is unchanged — every existing single-page golden is
byte-identical (zero golden churn). A single-system or already-full page has no
slack. Drives page_fill_efficiency to ~0 on justified pages.
Regression: vertical_justification_fills_non_final_pages (a small custom
PageGeometry forces the multi-page path; the non-final page fills, the last
stays ragged; verified to fail without the pass). Inter-staff band-height
renegotiation within a multi-staff system remains the deferred rest of the
vertical spring solve. 942 tests, clippy 0, docs -D warnings, conformance 8/8.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An adversarial review of the justification commit found a SEVERE bug: stems
detach from their noteheads (~0.75 ss, up to ~1.5) in every justified system.
Root cause: the code used is_rigid_width_stroke to select slot-anchored strokes
on the false premise it covered stems. It is LEDGER-ONLY. A stem is an
Event-sourced stroke drawn at notehead_x + 1.15 with no same-source glyph
(noteheads are Pitch-sourced) and no baseline in its x-span, so it fell to the
affine branch and its intra-slot offset was scaled by the justification factor a,
floating it off its head into the gap. The spacing pass shared the same
classification (a smaller latent drift).
Fix: component_glyph classifies a stroke — a Staff (staff line) or
RepeatStructure (volta bracket, whose ending-number glyphs share its source)
source SPANS (affine); else owning_glyph (a ledger over its notehead, same Pitch
source); else the glyph with the greatest baseline <= the stroke's x — a stem's
own in-column notehead (stem_offset 1.15 < column step 1.6, so exactly its slot).
Applied in BOTH the spacing remap and casting, so stems ride their heads through
the whole pipeline. Ledgers are unchanged (owning_glyph path).
Regression: stem_offsets_from_the_notehead_survive_justification (verified to
fail without the fix). Goldens regenerated (stems now on their heads). The minor
slur-inset drift (same root cause, ~0.3 ss on a soft connector) is deferred with
a note. Folded into ENGRAVER_VERSION 8 (unreleased). 941 tests, clippy 0, docs
-D warnings, conformance 8/8.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every non-final system of a multi-system region now stretches its horizontal
slack so its ink fills the content width, instead of sitting at its natural
left-aligned width. ENGRAVER_VERSION 7 → 8 (any wrapping score's baked geometry
differs; a single-system score is unchanged — its only system is ragged-right).
Casting bakes each system by a Placement: a vertical dy plus a horizontal affine
world_x = a·x + b (rigid = a:1, b:dx). A justified system spreads the slack
linearly (a = 1 + extra/span). The map is CLAMPED to the slot-source range: affine
within it, rigid slope-1 beyond it (bearing overhangs, staff lines drawn to the
ink edge), so the mapped ink extremes agree exactly with the per-slot deltas and
the ink spans exactly [left_margin, left_margin + content_width] (no over/under-
shoot). Slot-relative like the E1 remap: glyphs translate by the map at their
SLOT's source (intra-slot offsets survive), spanning strokes map endpoints through
the affine (they stretch), rigid-width strokes (stems/ledgers, via owning_glyph)
track their slot, slur control points map through the affine.
Not justified: a region's last system (ragged-right by convention), a degenerate
span, or a system already at/over width (never compressed into overlap).
Quality consequences (honest): system_break_penalty collapses to ~0 (the point);
the width-uniformity axes rise as the full non-final system contrasts with the
ragged last line (ten-measure casting_off ~0.45 → ~0.80) — a metric-semantics
follow-up noted in DECISIONS. Tests retargeted: the widow rebalance now shows in
measure distribution not baked widths; the floor-column test moved to a synthetic
vector (robust to fixture values). Goldens regenerated (view_box widens to the
content width; same 147 primitives, no collisions). 939 tests, clippy 0, docs
-D warnings, conformance 8/8.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three source-audit findings:
High — ModifyCrossCutting could still introduce dangling spanner anchors.
create_cross_cutting was fixed (P13-D3) to validate anchor_object_refs(), but
modify_cross_cutting still validated only endpoints() — empty for a spanner's
region/measure anchors — so a live event-anchored spanner could be MODIFIED onto
a missing RegionId/MeasureId and written into the graph past the core invariant
that checks spanner anchors at all three kinds. Fixed by mirroring create's
anchor_object_refs() liveness precondition in modify (endpoints() still feeds the
event-only referent index). Regression extends
create_cross_cutting_spanner_preconditions_region_measure_anchors with a modify
case (verified to fail without the fix: the dangling modify reached the graph).
Medium — slur_shape_penalty measured the constrained (pre-remap) curves, not the
drawn shape. The Engraver remaps curves before casting, so an ideal-in-source
slur could read ideal even after horizontal re-spacing visibly flattened or
steepened it; the catalog units are "drawn slurs." Now measured over the SPACED
whole curves (post-remap, pre-split) — threaded into quality::measure — so
re-spacing distortion is honestly captured while a break-spanning slur is still
measured whole (not as flatter fragments).
Low — stale comments: CastLayout.curves and curve_fate said break-spanning
curves draw whole with de Casteljau deferred (they now split); SlurContent.line
said non-solid slurs surface a diagnostic (they now render dashed/dotted); the
system_derived_rewrite doc called the never-minted system-pitch introduction an
unfixed Pass-13 residue (P13-K1 now rejects it).
940 tests, clippy 0, docs -D warnings, conformance 8/8, zero golden churn.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
An adversarial review of the slur-quality tranche found a real interaction bug
between curve splitting (commit 5869691) and slur_shape measurement (7d61271):
slur_shape_raw iterated cast.curves — the per-system SUB-CUBICS of a
break-spanning slur — and measured each fragment as a unit. A slur that is
ideally shaped as a whole (ρ ≈ 0.16, in-band) splits into sub-arcs whose
diagonal chords each read flatter (ρ below 0.08), so the whole slur earned a
spurious "too flat" penalty (confirmed ~0.088) and was double-counted —
contradicting the catalog's "a tier that draws the ideal shallow arc measures 0"
property.
Fix: measure the WHOLE slur curves of the constrained input.curves (one unit per
drawn slur — the engraver's arc-proportion decision), not the cast fragments.
Casting's horizontal re-spacing and system-splitting are spacing/rendering
concerns, not shape ones. The Quality Metric Catalog contributing-units
definition is clarified: the unit is the whole slur, measured once even when
split across a break.
Regression: a break-spanning in-band slur splits (≥2 segments) yet measures 0.
Still measurement-only (no ENGRAVER_VERSION bump, no golden churn, RS suite
unaffected). 936 tests, conformance 8/8.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The slur_shape quality axis was pinned at 0.0 (vacuous, then "by construction").
It now MEASURES, per the Quality Metric Catalog (req:qmc:slur): each drawn slur
Curve's arc ratio ρ = apex height / chord length is penalized by its distance
outside the shallow-arc band [0.08, 0.25] (max(0, 0.08-ρ, ρ-0.25)), meaned over
curves, normalized by R_worst=0.25. Apex is the max perpendicular distance from
the sampled cubic (32 points) to its endpoint chord; translation-invariant, so
the post-cast curves are measured directly.
Honest outcome: the Minimal tier's mid-span slurs sit at ρ = SLUR_HEIGHT_FACTOR
= 0.16 (in band → 0), but the fixed min/max height clamps push short slurs above
the band (bulgy) and very long ones below it (flat) — a genuine non-zero value a
duration-aware Standard-tier height would improve. A curve-free layout measures
0 by the vacuous-geometry rule.
No ENGRAVER_VERSION bump — measurement-only, resolved geometry / canonical bytes
/ render goldens untouched (quality-decision-8 rule); no RS entry carries a slur
so the reference suite is unaffected. Test: an adjacent-event slur is penalized
(>0), a wide-span slur is in-band (0). 935 tests, 8/8.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
E2 drew a slur spanning a system break whole in its start system, with a
floating end control point detached from its end note (the documented Minimal
boundary). Casting now SPLITS such a curve into per-system sub-curves.
curve_system → curve_fate, mirroring stroke_fate: a curve that fits in one
system rides it whole (CurveFate::Rigid, byte-identical to before); a curve
overlapping ≥2 systems' clip intervals splits (CurveFate::Split) into one
sub-cubic per system, cut at each system's content clip edges. The cut uses de
Casteljau subdivision (sub_cubic = two splits: take [0,t1], then its [t0/t1,1]
tail) at the parameters param_at_x finds by bisecting the x-monotonic curve
(a slur's control points are x-ascending by construction; a non-monotonic curve
— not engraver-produced — falls back to riding its start system whole). The
first segment carries the slur's exact provenance (the round-trip surjection
recovers the source once); later segments synthesize continuations under
SYSTEM_CONTINUATION_SYNTHESIS with a (stable_id, ordinal) key, as split strokes
do. Each segment's control hull grows its own system's extent.
ENGRAVER_VERSION 6→7 (a break-spanning slur's baked geometry differs); a slur
that fits in one system is unchanged, so the fixture goldens are byte-identical
(its slurs are short). Tests: sub_cubic reproduces the original curve on its
sub-range + param_at_x inverts x; a whole-score slur splits into ≥2 segments
across distinct system y-bands with correct provenance. 934 tests, 8/8.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
E2 drew every slur solid and surfaced a SlurLineStyleNotRendered diagnostic for
a non-Solid authored line. That deferral is now lifted: the Curve primitive
gains a `line: LineStyle` (from the slur's SpanStyle.line), threaded through the
resolved canonical encode (a per-curve style byte), the engrave remap, and
casting; render-svg emits stroke-dasharray (dashed = "0.5 0.35"; dotted =
round-capped "0 0.28"). The diagnostic variant is removed — the style is
rendered, not deferred, so `req:layoutir:slur-curve`'s "never silently rendered
solid" is satisfied by faithful rendering rather than a surfaced gap.
ENGRAVER_VERSION 5→6 (a dashed/dotted slur's resolved bytes and SVG differ).
Zero churn beyond the one dashed slur: the slur fixture's two solid curves are
byte-identical; only the editorial slur's <path> gained the dasharray. Solid
slurs and slur-free scores are unchanged. 932 tests, conformance 8/8.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three source-audit findings on 81b7f42, fixed:
1. Slur kind and line style were dropped at projection and every slur drew
solid with no signal. SlurContent now carries `kind` (SlurKind) and `line`
(LineStyle) through the projection, and a non-Solid authored line style
emits a `LayoutDiagnosticKind::SlurLineStyleNotRendered` — the curve still
draws (solid, ink + provenance kept), but the ignored dash/dotted intent is
surfaced, not papered over. The Minimal tier still draws one canonical arc
per kind (kind-aware rendering is higher-tier); the kind is now preserved
for it. The slur fixture's editorial slur is authored Dashed to exercise it.
2. Curve hit-testing flattened to a fixed 16 chords and tested capsules at
exactly half_width, so a thin/high-curvature slur's true ink between samples
could miss a click. contains/intersects_rect now inflate the capsule by a
proven flattening-error bound (h²/8·max‖B''‖ = (3/4N²)·max second-difference)
— provably conservative, so a click on the drawn arc never misses. The AABB
(control hull ± half_width) already bounds the true ink, unchanged.
3. The engrave crate-level doc still said "no drawn slur geometry exists yet";
updated to slur_shape 0.0 by construction (Minimal draws the ideal arc),
beam_slope still vacuous.
931 tests, docs clean, conformance 8/8. Zero golden churn (dashed slur draws
solid; layout diagnostics don't reach the render output).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The third pipeline primitive kind, review-hardened (5 verified findings fixed
pre-commit). A `Curve` (four control points, mirroring `Stroke`) threads
through all three IR stages, the canonical-encode fingerprint (a 5th u32 count
prefix; width-lock 4→5), the round-trip provenance chains and count identity,
`to_render`, the stub solver, engrave remap + casting, SVG path emission, and
hit-testing.
Slurs draw as one cubic bézier per slur carrying the slur's exact provenance
(no synthesis). LayoutContent::Slur resolves each endpoint event to a Note
column at to_logical (SlurEndpoint At/Unresolved — E1's honest-placement
discipline); a symmetric arc whose apex sits `height` from the endpoint line;
curvature_override direction+height honored, style.line (dashed) deferred to
Push 3. Honest non-drawing (traced anchor kept) for an unresolved endpoint, a
non-left-to-right span, or a cross-staff slur (no single staff — would float
at yo=0). Authored height/thickness sanitized to defaults when non-positive
(a negative thickness would else fail validation and blank the layout).
Hit-test: HitShape::Curve (Copy) flattens the cubic to a 16-segment capsule
inside contains/intersects (one region per curve); a slur click resolves to
Slur generically (no editor arm; edit ops refuse it). render-svg: stroked
unfilled <path C> after strokes/before glyphs + curve_count + content-hull
bounds. engrave: HorizontalRemap::curves; casting curve_system = Rigid-to-
start-system (no de Casteljau split — deferred; break-spanning slur draws
whole in its start system, kept for the source surjection); ENGRAVER_VERSION
4→5; slur_shape_penalty now 0.0 by construction (Minimal draws the ideal arc).
Existing SVG goldens changed only in the provenance-note comment (curves now
enumerated) — geometry byte-identical; 6 snapshots gained curve_count; new
ten_measure_with_slurs goldens. 929 tests, conformance 8/8.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The spacing pass reserves a slot's full content extent in its advance, but
HorizontalRemap::glyphs moved every glyph independently by piecewise
interpolation — a same-slot companion whose absolute x crossed the next
slot's source was dragged by the wrong interval. E1 made it reachable: a
time signature after a morphed repeatLeft sits TIME_SIG_X + the sign's
right extension (~1.8sp) right of its barline, past the 1.6sp constrained
column step, collapsing the digits into the following note through the
real Engraver.
spacing::space_slots now returns each glyph-bearing slot's (source, target)
beside the interpolation control points; glyphs translate by their own
slot's rigid delta (intra-slot offsets survive verbatim), spanning strokes
keep endpoint interpolation, and rigid ledger strokes use the owning
glyph's slot delta exactly. Folded into ENGRAVER_VERSION 4 (unreleased this
push); scores without same-slot companions are byte-identical — only the
repeat fixture's engrave golden moved (volta digits).
Regression: time_signature_digits_ride_their_barline_slot_past_a_repeat_sign
(unbounded page so x-disjointness compares one line; verified to fail
against the interpolated remap).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The first repeat-structure ink, review-hardened (19 verified findings fixed
pre-commit). Zero golden churn for repeat-free scores; four new goldens for
the ten_measure_with_repeats fixture.
layout-ir: repeats project LayoutContent::Repeat resolved at to_logical
(RepeatPlacement::At/RegionEnd/Unresolved — honest, no origin fallback;
zero-offsets judged by value; bare wall-clock boundaries draw no ink); deps
now from anchor_sites(). Constrained: coinciding measure barlines morph into
precomposed repeatLeft/Right/RightLeft (name-only, exact provenance kept);
standalone signs synthesize under a semantic (site, staff) instance key;
end repeats at the region close draw repeatDots beside a final barline (or
the full sign on continuing staves); start marks there draw nothing;
end-facing signs reserve their left reach via column overhang; time
signatures clear morphed signs; volta brackets = 3 strokes above the top
staff + timeSig digit numerals, kind-independent.
engrave: ENGRAVER_VERSION 3 -> 4; casting classifies barline columns via
is_barline_glyph AND direct Measure source, so standalone signs are never
phantom break candidates or measure records; criterion-6 round-trip covers
the repeat fixture.
render-svg: repeatLeft/Right/RightLeft/Dots outlines + metrics + font subset
regenerated from the SHA-pinned Bravura (fontTools 4.63.0); GlyphClass::Repeat.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Spec/process only, no code. Closes the schema-major-1 track (Phases A-F): the
two data-model batch rows parked behind the frozen-layout rule (P12-I7, P12-K7)
landed via Phases C/D1, and the three engrave/layout-ir dispositions the
implementation already made are ratified into the core spec.
- core_spec: three normative requirements ratifying the implemented behavior --
break-constraint satisfaction (req:layoutir:break-satisfaction, I8: a
SystemBreakAt/PageBreakAt is satisfied iff the final ResolvedLayoutIR starts a
system/page at that slot), break-override attribution via a
ConstrainedLayoutIR.break_origins sidecar declining to widen the constraint
record (req:layoutir:break-origin-attribution, I9), and system-continuation
synthesis Registered(SYSTEM_CONTINUATION_SYNTHESIS) with an (original, ordinal)
instance key (req:layoutir:continuation-synthesis, I10). Revision-history row.
PDF rebuilt (latexmk -xelatex, 0 undefined refs).
- PASS12_RATIFICATION_LOG: schema-major-1 tranche (I7/K7 landed + I8/I9/I10
adopt), with the open cross-region-slur item flagged.
- PASS12_BATCH: struck I7/K7/I8/I9/I10; added P12-K12 (which region governs a
cross-region slur's spanning permission -- implemented as conservative AND).
- engrave/layout-ir DECISIONS: ratification cross-refs; the P12-I7 note reworded
so deferred Phase C' does not read as landed.
- .gitignore: spec/*.xdv (xelatex intermediate; the tracked PDF is committed).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
The first phase of the first-ever binary-format schema-major bump (v0 -> v1),
the machinery-first minimal major. Spec-only: ratifies the contract that
Phases B-F build against; no code changes.
core_spec.tex: defines the two referenced-but-undefined types that were the
P12-I7 / P12-K7 gaps -- CanvasLayoutDefaults (with core geometry primitives
CanvasSize/CanvasMargins in staff spaces via CanonicalF64, A4/8mm default,
since core has no geometry types and must not depend on layout-ir) and
PitchRange (advisory pitch compass used by Instrument.range and
IndeterminacyHints) -- and adds Region.permits_spanning_slurs (default false).
Records schema major 1 as the first data-model expansion major and tightens
the minor-version rule (a field add, even Option, is major; minor = append
discriminants to the companion's append-safe vocabularies only).
binary_format.tex -> 0.3.0: the full "Schema Major 1" section --
- Where the changed fields reach: Canvas.layout_defaults and
Instrument.range are snapshot-only (no CreateCanvas/CreateInstrument op),
but Region.permits_spanning_slurs also reaches the CANONICAL CreateRegion
operation payload (CreateRegion embeds the full Region). The canonical-base
MaterializedState embeds none of these and stays major 0, byte-identical.
- Cross-major reader rules: discard-and-regenerate non-canonical chunks;
parse-or-read-only for canonical ones, so a major-0 reader opens a bundle
carrying v1 CreateRegion ops read-only.
- Accept-set gate [min,max] (rejects majors outside the set); per-payload-
type major assignment; the changed v1 value layouts (the wire form ratifies
the reduced reference-code layout, not the fuller data model); the total
default-filling v0->v1 migration table (including the CreateRegion payload).
- Length-prefix unification NARROWED to the resolved-layout (its own
non-canonical LayoutCache): the barrier/extension blobs stay regime (b) u64
because they ride the canonical manifest, which stays major 0.
Two review passes hardened this checkpoint. The first caught that Region is a
canonical operation payload (not cache-only, as the architecture analysis had
assumed) -- user chose to embrace it and build the canonical op-payload
migration. It also surfaced the barrier-blobs-in-manifest constraint that
narrows the unification. The second refined the minor-version delegation, the
accept-set outside-[min,max] semantics, and stale "no defined type" text in
the reference-suite / quality-metric companions and the engrave DECISIONS.
P12-I7 moved to IN PROGRESS (spec type defined here; code graph home lands in
Phase C). Both companions and the engrave DECISIONS reworded accordingly. All
four affected PDFs rebuild clean (0 undefined references).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
The Standard-tier spacing floor warned on short healthy scores: three
reference-suite entries measured spacing_distortion 0.36-0.41, above the
0.32 warning floor (0.8 x 0.40), a spurious diagnostic (every score still
passed Minimal). Root cause: the CV folded in the clef-to-first-note lead
advance, which is sized by notational-furniture width, not by rhythm.
Unlike P12-I11 (an engrave-side fix, no spec change), the defect here lived
in the metric's own normative definition, so the honest fix is a catalog
change - the mirror of I11: correct the measurement rather than relax the
threshold.
Quality Metric Catalog 0.1.0 -> 0.2.0: spacing_distortion is scoped to the
system's rhythmic columns (spring slots bearing a notehead or rest). The
clef / key-signature / time-signature lead and barlines contribute no
column, so a note-to-note advance spans them. quality::census now builds its
spacing columns only from a precomputed rhythmic-slot set (is_rhythmic: a
notehead*/rest* glyph anywhere in the slot); the CV and the >= 3-column
contributing-unit rule are otherwise unchanged.
Results: RS-3/5/6 drop to 0.2188 / 0.0819 / 0.0856 (all below the floor),
and the axis stays honest on real irregularity (RS-3 keeps 0.2188 from a
mid-line accidental). RS-2/RS-4 go vacuous-0.0 (their systems carry < 3
rhythmic columns - honestly "too little to measure," cleaner than the old
furniture noise). RS-1 0.1341 -> 0.1967 (cross-barline note advances).
The 1.0 anchor, orientation, range, tier thresholds, and the eight other
axes are unchanged. This is measurement-only: the resolved layout, canonical
bytes, render goldens, and ENGRAVER_VERSION are all untouched - only the
reported spacing_distortion value moves. The duration-aware optical-spacing
open question (deviation from duration-proportional spacing) stays open; it
needs the pipeline's deferred duration-aware preferred widths.
- catalog: spacing_distortion requirement (req:qmc:spacing) redefined over
rhythmic columns, rationale + open-question note updated, version 0.2.0,
revision-history row; PDF rebuilt clean (0 undefined refs).
- engrave: quality::census rhythmic-column filter + is_rhythmic; module and
spacing_raw docs; the floor-column contrast test re-pointed from b-flat's
spacing (no longer warns) to RS-1's casting-off (still between the Standard
0.28 and Minimal 0.72 floors); new short_scores_do_not_trip_the_standard_
spacing_floor locks the fix.
- QMC version breadcrumbs bumped to 0.2.0 (engrave + layout-ir quality.rs,
both DECISIONS.md, testkit RS-1 comment); reference suite companion
unchanged (cites the catalog by name, no pinned values).
- process trail: PASS12_BATCH I12 struck; PASS12_RATIFICATION_LOG I12
section (Version movements: QMC 0.1.0 -> 0.2.0); engrave DECISIONS quality
decision 8 + item 7 + candidate.
861 workspace tests pass; clippy -D warnings, fmt --check, rustdoc
-D warnings, and the catalog PDF build all clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
RS-1 honestly failed the Minimal casting_off_quality threshold under the
reference engraver: greedy first-fit left a two-measure stub last system
(width CV 0.6145 -> clamped 1.0 > 0.90). Cleared the honest way — an
engrave-side balance pass, no Quality Metric Catalog or core-spec change.
Casting-off gains a second phase, a widow rebalance
(casting::rebalance_widows, run between the greedy walk and vertical
stacking): it moves whole trailing measures from a region's penultimate
system into its final one, choosing the shift that minimizes the larger of
the two distribution penalties the catalog defines for the break family —
the width imbalance (casting_off_quality, the CV of the region's system
widths) and the non-final break penalty (system_break_penalty, the mean of
|W-w|/W over non-final systems). distribution_cost computes each raw by the
same formula as quality.rs's casting_off_raw / system_break_raw (mean not
worst, abs not clamp), so the rebalance optimizes the values the metric
census will report. The two axes pull against each other, so their min-max
lands on a 6/4 split for RS-1 (casting_off 1.0 -> 0.4463, system_break
0.254 -> 0.677, every axis <= 0.90) — with comfortable margin, over the
fragile full-balance 5/5 (system_break 0.889, a hair under 0.90).
Scope is tight: only a region's last boundary moves, and only when greedy
placed it (an Automatic boundary with no break requirement or page force
pinned to its slot); a user/IR-anchored or page-forced boundary is never
disturbed, the penultimate system keeps >= 1 measure, the final never grows
past its predecessor, and the system count is unchanged — so every
break-count and page-assignment invariant (and all break-constraint tests)
hold untouched.
The casting_off 0.5 anchor and the 0.90 Minimal column were vindicated, not
relaxed: the engraver improved, no anchor rescale / threshold loosening /
RS-1 override. Core spec Chapter 9's "Minimal makes no optimality claim"
already permits the heuristic, so nothing normative changed (no .tex/PDF
rebuild). P12-I12 (the Standard-tier spacing floor on short scores) stays
open.
- engrave: rebalance_widows + distribution_cost + two-phase module docs;
ENGRAVER_VERSION 2 -> 3 (a wrapping score's baked geometry differs from
pure greedy); three new casting tests (even-split preference, the
mean-not-worst break penalty for 3+ systems, and the resolved final
system); the_wrapping_fixture_is_measured_honestly re-pinned to the 6/4
values (both axes floor-warn under Standard, status untouched).
- testkit: RS-1 minimal_xfail row removed (promoted to a plain Pass); the
suite ships no xfail rows.
- render-svg: ten_measure.engrave.{svg,snapshot} goldens regenerated
(view_box width 83.99 -> 64.95; still two systems).
- process trail: PASS12_BATCH I11 struck; PASS12_RATIFICATION_LOG
"no spec change" section; engrave DECISIONS casting-off decision 9 +
quality item 7 + candidate promoted.
860 workspace tests pass; clippy -D warnings, fmt --check, rustdoc
-D warnings all clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
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
The chosen Phase-3 attack, run as two parallel waves. 829 workspace
tests pass; clippy -D warnings, fmt, and rustdoc clean; all three
spec documents build with zero undefined references.
Casting-off (epiphany-engrave/src/casting.rs, wired into the
Engraver):
- Greedy first-fit system breaking per region at measure-start
barline columns; a measure that would overflow the content width
starts a new system. Hard SystemBreakAt/PageBreakAt always
honoured; soft breaks honoured unless pathological (skipped with
the spec's warning + IrOverride-recorded decision).
- Vertical stacking from real content extents with the inter-system
gap read from the vertical band model; page overflow starts the
next page. World frame: pages stacked vertically, coordinates
baked into glyphs/strokes, so the SVG renderer, hit-testing, and
the GUI viewport are unchanged.
- Real ResolvedPage/ResolvedSystem trees (1-based page numbers,
content bounding boxes, staves from staff-line segments, measures
from barline columns); every chosen break appends an
EngravingDecision with MUSCLOID EngravedBreak provenance,
UserOverride-attributed via the new ConstrainedLayoutIR
break_origins sidecar; staff lines split per system with
synthesized continuation provenance.
- Break-constraint evaluation flips: satisfied iff the layout breaks
at the slot. The two single-system tests invert deliberately
(a hard break is now honoured; a user break is honoured and
attributed instead of warned). Geometric constraints evaluate in
the pre-casting spaced frame (documented).
- Page geometry is engraver-side PageGeometry (A4 portrait at an
8 mm staff: page 105 x 148.5 staff spaces, margins 7.5, content
90 x 133.5; arithmetic documented) — Canvas.layout_defaults has no
graph home and is a schema-major addition (P12-I7).
ENGRAVER_VERSION = 2. Goldens regenerated: ten_measure_single_staff
engraves as 2 systems (viewBox 84x20.6, was 103x11);
valid_score_rich as 3 systems; stub goldens byte-identical.
K1 schema-fill (Operation Catalog 0.4.0 -> 0.5.0, ratified first;
wire discriminants strictly appended):
- CreateStaff (24 / tag InsertStaff 24): set-union mint of a global
Staff; CreateStaffInstance now preconditions that its referenced
staff is live.
- SetTimeSignature (25): value-carrying meter-change LWW keyed by
(region, resolved position); the carried TimeSignature mints
set-union; StructuralFieldCollision on meter_sequence.
- SetTempoSegment (26): LWW keyed by (scope, resolved start) over
the score or region tempo map; a write that would malform the map
refuses with the appended PreconditionFailureReason 11
(TempoMapMalformed).
- SetStaffLayout (27): LWW advisory over the staff instance's three
inline layout fields.
- Create score/canvas remain deliberately unavailable slots: the
root and canvas are inline singletons with no addressable object
model (P12-K8), not force-designed.
Value-restoring undo (P11-C8 narrowed; catalog §UndoTransaction
rewritten and per-primitive undo notes updated):
- Canonical-order write chains (base-seeded) across all eleven LWW
families. StrictInverse restores each written key to its
chain-predecessor value iff the transaction's write is still the
key's last writer, else refuses the whole undo with a
TransactionConflict naming the superseder; BestEffort restores the
still-last keys. Clean compensations are Applied; only minted-
object tombstone repairs ride AppliedWithRepair (no new repair
vocabulary). Mixed mint+overwrite transactions compose; strand
guards refuse tombstoning mints still referenced by live
non-members.
- Undo-of-undo pinned and tested: restorations are chain writes, so
undoing the undo's transaction restores the undone value, and a
second undo of the same transaction conflicts (absence-restores
repeat idempotently — documented asymmetry, P12-K11).
- Permutation invariance pinned across five delivery orders; the
convergence generators gain the new ops and a tx-then-undo flow.
- Still deferred in normative text: delete resurrection (needs a
system-derived tag outside the ratified closed set), Transpose
inversion (P12-K2), Cascade dependent closure.
Trackers: Binary Format companion 0.1.0 -> 0.2.0 (appended wire/tag
tables, PreconditionFailureReason 11, payload layouts, history row —
a schema-minor evolution under its own rules); nine new Pass-12 rows
(C5, K8-K11, I7-I10); core-spec OperationKind listing gains the four
kinds; revision-history rows in core spec and companion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
The audit's fourth push: the biggest outstanding Phase-2 item plus the
performance gate. 793 workspace tests pass; clippy -D warnings, fmt,
and rustdoc (deny-warnings) clean; all three spec documents build with
zero undefined references.
Binary Format companion (spec/binary_format.tex, v0.1.0 — Agent J's
deliverable, 43 pages):
- Twelve chapters transcribed from the golden-locked implementation:
encoding conventions (the three prefix/endianness regimes, a
normative no-varint rule, reject-never-normalize decode discipline),
identifiers imported from the core spec's Canonical Byte-Layout
Reference, primitive value encodings, the whole-Score positional
codec ratified as the schema-major-0 wire form, operation wire
forms (envelope field order with the normative id-leads property,
the OperationPayload 0..=3 and OperationKind 0..=23 tables,
effects/conflict/anomaly/MaterializedState vocabulary), the bundle
physical layout (64-byte header, 256-byte superblock, chunk
preimages and framing, ChunkRef, manifest body order), the
operation-index payload, and the extension-blob/edit-barrier byte
forms.
- Ratifies P12-D1 (req:binfmt:opindex), P12-E1 (req:binfmt:ext-blobs),
P12-E2 (req:binfmt:condition-depth, MAX_CONDITION_DEPTH = 64
normative), and P12-E3 (req:binfmt:object-kind-open) — batch rows
struck through; discharges the provisional-codec notes in core
(P11-4), ops, and bundle (P11-D2/D4/D5) DECISIONS with ratification
cross-references.
- Pins the frozen-layout schema-evolution keystone: within schema
major 0 every positional struct layout is frozen; a field-set change
is a schema-major change with migration — formally grounding the
data-model-expansion staging decision. Open questions kept honest
in-document: SnapshotId derivation, index-refresh threshold, u64/u32
prefix unification at the next major.
- Not yet delivered from J's charter: the cross-implementation decoder
test and the wire-format fuzzer (follow-up harnesses).
F1 benches (crates/epiphany-testkit/benches/, per the F0 decision):
- criterion 0.5.1 (workspace dev-dependency; MSRV 1.77 respected with
documented transitive pins: clap 4.5.53, half 2.4.1).
- reduction bench at 1K/10K/50K envelopes with the Chapter-10 budget
(>10,000 envelopes/second cold) written in the bench as a Pass/Xfail
gate; bundle benches for the typical-edit commit (<=50 ms; measured
~14.7 ms on real disk after catching that tmpfs neuters fsync) and
the open/bootstrap read (<=200 ms; measured ~60 us).
- CI: quick budget gates in the conformance job, full gates nightly.
Subquadratic canonical_reduction_order (the F-surfaces/K-fixes
handshake, closing K's 10K-envelope acceptance gate):
- The bench documented the failure (50K at ~1.7K env/s, a 29 s cold
reduction; two O(n^2) loops); the fix replaces pair enumeration with
threshold/frontier readiness per replica plus explicit-dot dependent
lists and a stamp-tuple binary heap — O((n + sum(context)) log n),
never materializing covered pairs.
- Byte-identical order: same edge relation, same ready predicate, same
total order; the old implementation is retained as a test-only
oracle with element-for-element order-equality property tests over
fuzz sets, adversarial sets, and directed shapes (2,000-envelope
full-coverage chains, dot cycles, duplicate-id stamp ties),
mutation-tested for sensitivity.
- Measured: 1K 155K->674K env/s, 10K 12.5K->257K, 50K 1.7K->87K; all
three scale points now pass and the 50K row is promoted from Xfail.
Also: fixed nine rustdoc private/unresolved intra-doc links that had
accumulated across the pushes (the CI deny-doc-warnings job would have
failed on them).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
Two audit pushes whose code edits interleave line-by-line in the same
files (reduce.rs, bundle.rs, the DECISIONS logs), committed together so
the tree at every commit builds. Gate: 784 workspace tests pass, clippy
-D warnings clean, fmt clean.
Push 1 — the true MUST violations, all fixed:
- bundle: zstd read support on both read paths, output bounded by the
declared uncompressed_length, typed decompression errors, explicit
CompressedManifest rejection (zstd 0.13 workspace dep; write path
stays uncompressed per the Phase-3 deferral).
- ops: system-derived counter collision check — mint registry seeded
from the base graph, canonical-order pre-walk, halt via the new
PendingReason::HaltedBySystemCollision (discriminant 4, additive)
with transaction-atomicity and causal-dependent closure; neither
input set occupies a collided counter. canonical_pitch_bytes made
pub in core for the MUSCSPCH preimage.
- ops: Transpose skips tombstoned targets per the catalog; missing
targets still refuse the whole operation.
- ops: marker re-anchoring recorded as a RepairRecord in the
triggering operation's effect; ResolveConflict meta-conflicts name
both resolvers; base-free pitch-id freshness; reserved effect
vocabulary annotated.
- core: decomposition pre-pass honors authored attachments
(resolve_decomposition, spec-default precedence); inversion
tolerance typed as a TempoIntegration-class Tolerance.
- CONFORMANCE.md: the determinism conformance statement required by
Appendix D — all seven declarations.
Push 3 — wiring the types-only machinery:
- layout-ir/engrave: to_constrained emits real constraints (successive
notehead no-collision chains, per-glyph region containment, soft
user-break constraints); ConstraintStrength{Required, Preferred}
with strength-by-rule; Preferred violations surface as warnings, not
failures; StubSolver reworked honest-but-renderable. SVG goldens
byte-identical; snapshot constraint counts regenerated (0->90/15).
- layout-ir: to_logical projects user system/page breaks as anchored
EngravingOverrides with paired UserOverride-sourced decisions
(OverrideKind::SystemBreak/PageBreak carry TimeAnchor, ratified in
the spec alongside).
- layout-ir/ops/editor-core: edit-barrier bridge — decode mirrors for
the whole barrier tree (reject-never-normalize, NFC revalidation,
MAX_CONDITION_DEPTH = 64), golden-locked blob codec for the
ExtensionDeclaration fields, a barrier gate in apply and
apply_transaction backed by a Score oracle and real containment
contexts, and apply_unsafe recording the crossed extensions in
extensions_requiring_tombstone() for the next bundle write.
- ops: ResolveEquivocation meta-operation per the newly ratified
catalog entry — payload discriminant 3 (appended), set-level
earliest-resolve-governs promotion, ResolveConflict-mirrored
meta-conflicts, permutation-invariance fuzz; the missing golden
locks on the OperationKind/OperationPayload wire tables added.
- ops/editor-core: validation modes — ValidationMode + a non-canonical
advisory layer (validate.rs), an authoring gate before minting, and
reduction pinned as replay mode by construction (canonical bytes
untouched).
- bundle: the operation index (opindex.rs) — provisional golden-locked
payload, binary-search locate, staleness defined as full-ChunkRef
set equality against operation_roots, and the reject-and-rebuild
discipline (a defective index is never bundle corruption).
- ops: re-anchoring rule table completed — the four-key "nearest"
ordering computed from base-free ledger indices; markers re-anchor
to the nearest live event in the same staff instance (replacing the
Push-1 region-start stand-in); cue-source cascade; graphic-gesture
Events/Range/Free rows; comment and analytical-annotation orphaning.
Zero appended discriminants.
Spec enablers ratified with Push 3: catalog §ResolveEquivocation
(0.3.0 -> 0.4.0) and anchored break overrides; 16 new Pass-12 rows
filed (C1-C4, K5-K7, I4-I6, D1, E1-E5). The data-model payload
expansion (SlurKind, beam geometry, voltas, instrument bodies,
metadata) is deliberately staged to the Binary Format companion — the
positional graph codec has no value-level versioning, so filling those
structs is a schema-major break that should land once, with J.
Also carries the pre-existing editor-track increment: the atomic
tuplet overwrite (CascadeDeleteTuplets prunes decomposition
attachments naming the cascaded tuplet).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
Notes above or below the staff now get ledger lines — the gap the GUI surfaced
the moment a note is moved off the staff. Each notehead carries its StaffStep;
to_constrained emits one short horizontal stroke per whole step between the
staff (lines at steps 0..=8) and the note, reaching LEDGER_LINE_EXTENSION past
each side of the note's actual bounding box (so a wide whole note gets a wider
ledger), synthesized from the pitch so the strokes are deterministic and
hit-testable. The synthesis key splits component (high 64 bits) from signed step
(low 64) so two components of a very low note can never collide.
Ledger lines are fixed-width marks, not system-spanning lines, so the Engraver's
horizontal spacing must not scale them. is_rigid_width_stroke marks them; the
remap translates such a stroke rigidly by its *owning glyph's* column delta
(found by source, not the stroke midpoint — which for a wide head can fall nearer
a neighbouring column), so it keeps both its length and its offset from the
notehead. The spacing pass folds each ledger's extent into its notehead's slot,
so adjacent off-staff notes' ledgers reserve room and do not overlap. The stub
solver passes ledgers through unchanged.
Tests cover the step geometry, key distinctness (incl. steps below -128), the
bbox span, width preservation and offset (no-drift) through the Engraver, the
adjacent-overlap spacing, and an explicit two-whole-note off-staff drift case.
Render goldens regenerated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
One iteration of an interactive edit, wired end to end so a missing connection
between the crates surfaces here, not in a GUI: render a score to its RenderIR,
resolve a click on a notehead to its graph pitch through the hit-test map, apply a
real operation (sharpen -- a +1-chromatic Transpose) by reducing it onto the score
graph, re-render, and confirm the selection survives the relayout.
- run_edit_loop_with<S: ConstraintSolver>(base, solver) returns an EditLoopReport
(selected_pitch, selection, graph_changed, selection_preserved, render_changed);
run_edit_loop is the stub wrapper. render_with returns None for a
diagnostic-only (non-renderable) solver report, so the loop never hit-tests or
edits a layout the caller must not render.
- The click resolves like a GUI's: aim at each real notehead's centre (a notehead
glyph, not synthesized, pitch-backed) and select whatever the topmost hit there
is, taking the first aim whose topmost hit is a pitch-backed glyph -- faithful
to a click and robust to an occluding unison/chord notehead.
- Selection survival rests on the MUSCLOID layout id, a function of the pitch's
identity (PitchId), not its content: the sharpen changes the pitch's value but
not its id, so its layout object keeps the same stable id and the cursor does
not jump off the edited note. The prepass re-runs inside to_logical, so the
edited pitch is re-spelled and its accidental reflects the new value (why the
re-render reliably differs).
Tests drive the loop on valid_score_rich (one fixture and across 48 seeds, every
seed required), refuse a diagnostic-only solver layout, and -- in epiphany-engrave,
via its existing testkit dev-dep -- run it through the real Engraver across 16
seeds, proving the selection survives even as the Engraver re-spaces every glyph.
Full gate green: build, fmt, clippy, conformance scale 1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A status sweep: the I-series Pass-12 candidates are all closed, but the tracker
and two crate DECISIONS.md files still described them as open.
- PASS12_BATCH.md: P12-I1 (structural-placeholder pipeline) is resolved by I-1
-- to_constrained now builds real notation and the Engraver re-spaces it;
P12-I3 (BRAVURA_METRICS approximations) is resolved by I-4a -- metrics
re-extracted from the same pinned 1.392 font, containment-tested. Both rows
struck through and marked done.
- render-svg/DECISIONS.md: fixed the stale P12-I2 bullet, which still said the
MUSCLOID derivation was unwired and the determinism crate exposed no tag (a
miss from the P12-I2 commit); now marked resolved.
- engrave/DECISIONS.md: its P12 section described P12-I1/I3 as open and omitted
I2; rewritten so all three read resolved.
The batch's top-level Status stays OPEN -- the H-series and K-series candidates
remain. Docs-only; no code change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Criterion 6 (the Chapter 7 layout round-trip) and the render goldens previously
exercised only the verbatim StubSolver, so a regression in the real Engraver's
geometry could land unseen. I-3 drives both through the Engraver.
- round_trip_with<S: ConstraintSolver> factors the solver-agnostic provenance
contract out of round_trip (now a one-line stub wrapper): coverage, the
complete Provenance surviving constrained -> resolved -> render, the source
surjection, and no duplicate stable ids hold for *any* conformant solver. The
Stub tier's verbatim-geometry clause is gated behind solver.tier() == Stub;
every other tier re-spaces. The status gate accepts any renderable status
(Solved / SolvedWithWarnings / PartialBudgetExhausted), not exactly Solved, so
the helper matches its "arbitrary conformant solver" contract while still
rejecting the diagnostic-only statuses that carry no authoritative layout.
- criterion_six_round_trips_through_the_engravers_respacing (epiphany-engrave)
runs the full graph -> logical -> constrained -> *engraved* -> render round
trip over the criterion-6 hand-off fixtures -- ten_measure_single_staff (the
measured fixture) and valid_score_rich (cross-cutting tuplet/tie/spanner),
plus valid_score for breadth -- and asserts the whole provenance contract
survives the Engraver's re-spacing. A non-vacuity check confirms the Engraver
genuinely moved geometry, so provenance is preserved *through* a real geometry
change -- the statement the verbatim stub can never make. This adds an
epiphany-testkit dev-dep (no cycle: testkit does not depend on this crate).
- The render-svg engraver acceptance test is upgraded from invariant-only to
byte-locked: new .engrave.snapshot.txt / .engrave.svg goldens for both
fixtures capture the Engraver's re-spaced output (e.g. ten_measure view_box
width 82.26 vs the stub's 88.88, same glyph/stroke/class counts), so an
Engraver geometry regression is caught at the byte level. A companion test
asserts the engrave goldens genuinely differ from the stub goldens, catching
the degeneracy where the Engraver echoes the stub (which would otherwise pass
both golden checks independently).
Also corrects the epiphany-engrave package description, which still claimed it
reports SolverTier::Stub until it earns Minimal (it earned Minimal in I-2).
Full gate green: build, fmt, clippy, 580 tests, conformance scale 1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Engraver now evaluates the IR's declared hard constraints against the
resolved geometry and reports honestly, so it reports SolverTier::Minimal --
"hard constraints satisfied, no claim about optimality" (Chapter 9) -- instead
of the interface-only Stub.
- evaluate_constraints checks each LayoutConstraint against the resolved glyph
boxes: NoCollision (boxes do not overlap), Align (equal baseline y for the
Horizontal axis, equal x for Vertical), PositionWithin (box inside the
region). A hard SystemBreakAt/PageBreakAt is reported unsatisfied -- a
single-system, single-page Minimal solve casts off nothing (a soft break
imposes no obligation); an unverifiable Registered extension constraint is
conservatively not claimed satisfied.
- Honest report status: a malformed input (invalid structure or forged/unknown
catalog) is InternalError; a valid problem whose hard constraints cannot all
be satisfied is Unsatisfiable, naming the offenders in unsatisfied_constraints;
all satisfied is Solved. satisfied_hard_constraints and
budget_used.constraint_evaluations reflect real work (0 when evaluation is
skipped on a malformed input).
- Minimal makes no normalized-metric claim, so the metric vector stays the
conservative all-worst "no claim" placeholder (the Quality Metric Catalog is
Phase 3 / Standard). Still deferred to a later tier: the vertical spring pass
(glyph y is the constrained natural staff layout, preserved verbatim) and
casting-off.
Tests pin the tier, an empty (vacuously satisfied) constraint set, satisfied vs
violated NoCollision, hard-vs-soft breaks, and the skipped-evaluation count.
Full gate green: build, fmt, clippy, 578 tests, conformance scale 1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turn the Score -> layout-IR -> SVG pipeline from placeholder glyphs into real
music notation, rendered through the stub solver and the real Engraver alike.
to_constrained (the spacing pass) now dispatches each layout object to the
notation primitive that represents it, on a column-based spring model:
- Pitch -> notehead at its clef-relative staff position; chord pitches share
one column slot. StaffInstance -> clef glyph. Staff -> five staff-line
strokes (the bottom line its anchor, four synthesized). Pitched Event ->
stem stroke; Measure -> barline glyph; rest Event -> rest glyph.
- Phase 3 ornaments as synthesized glyphs: a spelling's full accidental stack
left of its notehead, a key signature's clef-relative sharp/flat zigzag in
the lead, and a measure's numerator/denominator time-signature digit pair.
- A tied decomposition draws one notehead/stem/rest per component (offsets
honored, not collapsed). Active clef and key resolve by time, not vector
order. The lead area reserves clef + key-signature width.
- Coverage/surjection preserved so the round-trip holds: each laid-out object
is covered by exactly one exact-provenance primitive, and derived primitives
(staff lines, components, accidentals, key/time glyphs) are synthesized from
a laid-out source. Engraving-coverage gaps (missing spelling, unbundled
glyph) are surfaced as ConstrainedLayoutIR diagnostics, not silently
defaulted. A measure depends on the time signature it displays.
The Engraver re-spaces glyphs AND the strokes that track them through one
collision-aware coordinate map (per-slot left/right bearings, pairwise
advances), so stems / barlines / staff lines stay attached and a note's
accidental never overlaps the previous note. validate() now rejects empty
spring slots -- the contract that map relies on -- and to_constrained never
emits one (slots are realized by glyph occupancy).
Bundle the genuine Bravura outlines and metrics for time-signature digits 0-9
(regenerated from the SHA-pinned font via tools/extract_bravura_outlines.py),
replacing an inconsistent hand-written placeholder.
Regions tile left-to-right (no page casting-off yet). Goldens regenerated into
recognizable notation. Full gate green: build, fmt, clippy, 574 tests,
conformance scale 1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Lands the renderer-against-stub slice of Agent I's visible engraving work
(spec/PHASE2_QUICKSTART.md). Two new crates; prerequisites (G Pass 11, H
spelling/decomposition) are in place. Real engraving + Minimal-tier solver
follow next phase.
epiphany-render-svg (the deliverable this phase):
- Renders a ResolvedLayoutIR to well-formed SVG 1.1, drawing each glyph as a
GENUINE Bravura SMuFL outline <path>. Outlines are extracted reproducibly
from the official OFL Bravura.otf by a committed generator
(tools/extract_bravura_outlines.py, OFL.txt); the font is not vendored, only
the generated Rust (src/outlines_generated.rs). Staff-space/y-up coords with
one global y-flip wrapper; viewBox in staff spaces, px scale on the root.
- Non-overreach: every element traces to a ResolvedGlyph (data-prov) or a
declared wrapper; a glyph lacking an outline is surfaced as a diagnostic and
drawn as a fallback rect, never silently dropped.
- Hand-rolled xml::check_well_formed (no XML dep); acceptance tests cross-check
with system xmllint when present.
- examples/render_fixture.rs demo (fixture name -> SVG stdout, --solver=stub|real).
- Golden-locked machine acceptance snapshot + full-SVG golden for
ten_measure_single_staff and valid_score_rich; deterministic output.
epiphany-engrave (honest scaffold):
- Engraver: a deterministic horizontal-spacing pass (first axis of the planned
two-pass spring layout). Reports SolverTier::Stub — NOT Minimal — until it
evaluates the declared hard constraints, guarded by a regression test. The
demo's --solver=real exercises it end to end.
Honesty notes (recorded as Pass-12 candidates P12-I1..I3 in spec/PASS12_BATCH.md
and the crates' DECISIONS.md): the v0 to_logical/to_constrained pipeline is a
structural placeholder (arbitrary glyph per object, y=0), so stub output is not
yet recognizable notation and the QUICKSTART human visual gate is a next-phase
gate; MUSCLOID layout-id derivation stays unwired; bundled BRAVURA_METRICS are
approximations that disagree with the real outlines.
Gates: cargo fmt + clippy -D warnings clean; cargo test --workspace 504 passed,
0 failed, 0 ignored (criterion 6 layout round-trip still green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>