Commit Graph

122 Commits

Author SHA1 Message Date
Levi Neuwirth 86635e43e1 Two-sided inter-staff renegotiation, and the cascade defect it uncovered
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>
2026-07-09 13:32:37 -04:00
Levi Neuwirth 029039bf35 Straighten the QMC version story in the docs that assert it
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>
2026-07-09 11:54:25 -04:00
Levi Neuwirth 5b016f33b7 Two more glyph-members assumptions in the vertical-density axis (QMC 0.3.0)
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>
2026-07-09 11:39:28 -04:00
Levi Neuwirth 43215c7d67 The inter-staff gap band becomes a height model; fix a non-conforming metric
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>
2026-07-09 11:16:31 -04:00
Levi Neuwirth ab411f4cae Reconcile a process claim: park the listing gap, do not fake a Pass-13 row
ced4b72's ratification log said the ConstrainedLayoutIR listing gap was "tracked
as a Pass-13 candidate", but PASS13_CANDIDATES.md declares that batch CLOSED with
no open candidates -- and the house rule opens a pass only at >=3 candidates. The
claim pointed at a ledger that says the opposite.

Filed where the rule wants it instead: layout-ir DECISIONS records the two fields
the listing still elides (break_origins, catalog), why neither blocks an
implementer (both are governed by requirement text elsewhere), and that it is
PARKED awaiting company rather than reopening a pass on its own. The log now says
that too.

No code, no spec text.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 11:02:20 -04:00
Levi Neuwirth ced4b7255e Rescope the band-ownership MUST to the constrained stage; ratify it
Review follow-up on fc411ea. Finding 1 was correct and was mine to own: I wrote
"every primitive -- glyph, stroke, and curve alike -- MUST declare a
VerticalBandId" and placed it in the ResolvedLayoutIR section, where the adjacent
ResolvedGlyph listing has no such field. Under a literal read the implementation
was non-conforming against a requirement I had just added.

The rule belongs to the stage that has the obligation. req:layoutir:primitive-
band-ownership moves to Ch7 §ConstrainedLayoutIR §Vertical Bands and is scoped to
"every primitive the projection presents to the solver". A new companion,
req:layoutir:resolved-band-ownership, says what survives: a resolved Stroke/Curve
MUST retain its band (casting-off and the inter-staff solve both relocate them
and must attribute them), while a ResolvedGlyph carries none -- its ownership is
consumed during the solve and already baked into its resolved position, so the
field would be dead weight in a rendering fingerprint. That requirement also
carries the non-canonical clause: canonical_bytes omits vertical_band even from
the primitives that do retain it.

Finding 2: the ConstrainedLayoutIR listing gains strokes/curves. They have been
in the code since staff lines and were never listed -- so an implementation
following Chapter 7 could not have supplied non-glyph ownership to the solver at
all, which makes this the listing gap that actually blocked the new MUST.

Finding 3: the ratification trail now records the tranche -- a revision-history
row and a PASS12 log section naming both requirements, the three listing
corrections, the two commits, and the fact that adopting it churned no golden
(the declared owner agrees with the inferred one across the corpus, which is what
licensed the swap).

The log also records a pre-existing listing gap I found while checking my own
citations: ConstrainedLayoutIR still elides break_origins and catalog. I had
first written that neither is named anywhere in core_spec, then verified and
found both types ARE specified (req:layoutir:break-origin-attribution; Ch7 §Glyph
Catalog Identity) -- only the struct listing is incomplete, and unlike
strokes/curves neither gap blocks an implementation. Left as a Pass-13 candidate
rather than widened into this tranche.

Spec rebuilds clean: no LaTeX errors, no undefined references, both new labels
registered. Workspace 30/30, conformance 8/8; no Rust changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 11:00:09 -04:00
Levi Neuwirth fc411ea7ac Ratify primitive band ownership in Chapter 7; scope canonical_bytes' doc
Review follow-up. Both findings were about documentation lagging a contract the
code now enforces, and the first is the one that matters: band ownership is the
solver-facing correctness contract, so it belongs in the spec, not only in a
crate's DECISIONS.md.

Chapter 7's ratified Stroke/Curve listings gain `vertical_band`, and a new
requirement (req:layoutir:primitive-band-ownership) states the rule as a MUST:
every primitive declares its owning band, the projection MUST name a band that
exists, and a vertical solver MUST take ownership from that declaration and MUST
NOT infer it from geometry. The rationale is recorded because the requirement
looks like a convenience until you see why it isn't: a stem shares its column
with the staff above, and a slur's endpoints are lifted clear of its own staff
into the zone where the nearest notehead belongs to the neighbour. A solver that
guesses from proximity tears such primitives off their notes when it renegotiates
the staff gaps -- which is exactly what happened, twice. The requirement also
records that only glyphs are band *members*, and that the reference is
non-canonical attribution metadata.

Stroke's stale "the vertical-band model does not contain" gloss is dropped.

canonical_bytes' doc claimed to encode "the full resolved layout". It encodes the
rendering fingerprint and deliberately excludes attribution metadata. Stating
that precisely required checking rather than paraphrasing: ResolvedGlyph drops
its band on the way out of the constrained stage, while Stroke and Curve -- whose
types are shared with that stage -- carry vertical_band through but never encode
it. Two layouts differing only in band ownership draw identically and hash alike.

Spec rebuilds clean: no LaTeX errors, no undefined references, new label
registered. Gate green, no golden churn.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-09 10:50:10 -04:00
Levi Neuwirth efaebb9acd Let the band model carry staff attribution instead of inferring it
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>
2026-07-09 10:37:27 -04:00
Levi Neuwirth f7350362a3 Close the inter-staff residual risk: a three-staff cascade fixture
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>
2026-07-09 10:20:35 -04:00
Levi Neuwirth b1bfe04d6c Inter-staff review fix: attribute a curve by its arc, not its nearest glyph
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>
2026-07-09 10:05:14 -04:00
Levi Neuwirth 4132a7ac05 Inter-staff review fix: make staff attribution y-aware (stems tore off)
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>
2026-07-09 09:42:51 -04:00
Levi Neuwirth 3978225e06 Inter-staff slice 2/2: renegotiate the gaps between a system's staves
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>
2026-07-08 23:04:25 -04:00
Levi Neuwirth f103641013 Inter-staff slice 1/2: a two-staff pressure fixture
Groundwork for inter-staff band-height renegotiation (the last vertical-spring
piece): a fixture whose two staves' content nearly collides at the engraver's
fixed staff pitch, so the pending solve has something meaningful to separate.

two_staff_close_content: a one-measure, two-staff (treble/treble) metric score.
The top staff descends into low ledger notes (down to C2); the bottom staff
climbs into high ledger notes (up to C6) under a slur that arcs further above
them. In the constrained frame the top staff's content occupies y ≈ [-8, +4]
and the bottom staff's ≈ [-12, -5], overlapping in [-8, -5] — the C2 ledgers
collide with the C6 + slur. Invariant-clean; 8 notes, 1 slur, 41 strokes
(the heavy ledgering), 1 curve.

Registered in the render-svg acceptance suite; its goldens (this commit) show
the CURRENT overlapping layout — slice 2 (the solve) will update the engrave
golden to show the staves separated. Invariant test added. 946 tests, clippy 0,
docs -D warnings, conformance 8/8.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 22:48:39 -04:00
Levi Neuwirth 177bcdb43b Optimal-break review fix: restore an overflow safety net in walk_region
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>
2026-07-08 21:48:31 -04:00
Levi Neuwirth c818992835 Push 3 Standard-tier: optimal break search (replace greedy first-fit)
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>
2026-07-08 21:29:08 -04:00
Levi Neuwirth 0db005db9a Vertical justification review follow-up: document + test the metric trade-off
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>
2026-07-08 20:50:17 -04:00
Levi Neuwirth 69e0430062 Push 3 Standard-tier: vertical justification (fill non-final pages)
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>
2026-07-08 20:36:18 -04:00
Levi Neuwirth fea446a182 Justification review fix: keep stems attached to their noteheads
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>
2026-07-08 19:54:18 -04:00
Levi Neuwirth 7c038721d7 Push 3 Standard-tier: per-system justification (fill the content width)
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>
2026-07-08 19:21:46 -04:00
Levi Neuwirth ca687d8e7e Push 3 audit follow-up: modify anchor validation, spaced slur_shape, stale docs
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>
2026-07-08 18:54:39 -04:00
Levi Neuwirth 430214d619 Pass 13 — P13-D2: tombstone before the graph delete, closing the cue-cascade trail
delete_event tombstoned the event in objects AFTER materialize_graph_delete —
but that graph pass cascades a cue among the event's referents, running
reanchor_for_tombstone over the cue's referents while the source event is still
Live. A slur bridging {X, cue-of-X} therefore re-anchored onto X
(Reanchored{to: X}) and then cascade-deleted when X's tombstone landed a line
later: a contradictory same-effect trail (candidate was "plausible by code
trace, unexecuted" — now executed; reverting the fix reproduces exactly that
two-record trail).

Fix: tombstone the event in objects BEFORE the graph delete, matching the
conventions cascade_cue and tombstone_undo_targets already follow (both
tombstone before their graph delete — which is why the undo path never had this
bug). The bridging slur now sees X already dead during the cue cascade and
cascades once.

Regression: deleting_a_cue_source_does_not_leave_a_contradictory_repair_for_a_
bridging_slur_p13_d2 (built on a cue-bearing base with a bridging slur; asserts
exactly one CascadeDeleted, no Reanchored). Verified to fail without the fix.
940 tests, convergence/conformance green.

PASS13_CANDIDATES.md: P13-D2 resolved — batch CLOSED (all four candidates done).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:25:37 -04:00
Levi Neuwirth 79c8e1da2e Pass 13 — P13-D1: run the ledger re-anchor for undo-driven event tombstones
An undo of an event-minting transaction tombstoned the events graph-side
(materialize_graph_tombstones → materialize_graph_delete re-anchors/cascades a
structure whose anchor died) but never ran the ledger-side
reanchor_for_tombstone — so a structure orphaned by the undo left the graph
while staying Live in objects, with no RepairRecord. Ch6's same-step
RepairRecord MUST was unmet for undo-driven tombstones (slurs/spanners/repeats).

Fix: tombstone_undo_targets now captures each event target's voice (before the
graph half clears voice_occupancy) and runs reanchor_for_tombstone per event
target after the graph half. The orphaned structure now cascades or re-anchors
in objects with a same-step RepairRecord, agreeing with the already-updated
graph — both use the same min-survivor rule, so they converge on existence and
target. reanchor_for_tombstone gains a liveness guard (skip a non-Live
structure) so the undo's own tombstoned mints — whose stale structures-index
entries linger — aren't re-processed into duplicate repairs; the direct-delete
path already drops tombstoned structures from the index, so the guard is a
no-op there.

canonical_bytes embeds both objects and the effect log, so this corrects the
reduced state (an inconsistency never previously exercised — no existing test
broke). Regression: undo_orphaning_a_pre_existing_slur_cascades_it_in_the_
ledger_p13_d1 (cascade + recorded repair + order-independent convergence).
939 tests, convergence/equivocation/conformance green. PASS13: P13-D1 resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:17:51 -04:00
Levi Neuwirth 382aff23a2 Pass 13 — P13-K1: reject a ModifyEvent introducing a never-minted system pitch
The K3 verdict for a system pitch introduced by a ModifyEvent replacement
(never minted — the collision pre-walk excludes ModifyEvent) differed across a
snapshot cut: in-session it slipped through (system_mints had no entry, so the
P12-K3 identity check saw nothing), yet post-snapshot it read
SystemDerivedContentImmutable once the pitch was re-seeded as a system mint.

Per the user's call ("reject the introduction"): modify_event now refuses a
replacement carrying a never-minted SYSTEM-DERIVED pitch id (replica
SYSTEM_DERIVED, not Live in objects) with TargetMissing, before the identity
check. The verdict no longer depends on the registry — the pitch is not live in
objects in either frame — so both refuse identically, closing the asymmetry.

Scoped to the system namespace, where the asymmetry lives (only system pitches
re-seed as system mints): a user-replica pitch carries no namespace claim and
has no snapshot asymmetry, so ModifyEvent may still introduce user pitch
content (the concurrent-modify tests rely on it).

Regression: a_modify_event_introducing_a_never_minted_system_pitch_is_refused_
p13_k1. 938 tests, convergence/conformance green. PASS13: P13-K1 resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:08:48 -04:00
Levi Neuwirth cd998142f5 Pass 13 — P13-D3: reject spanners anchored to a missing region/measure
A spanner anchored to a missing region/measure minted dangling: the
CreateCrossCutting mint checked only CrossCuttingValue::endpoints() (events),
so a region/measure TimeAnchor with no live target slipped past — the exact
sibling of the repeat mint gap fixed in Phase D.

Per the user's call ("fix the mint only"): CrossCuttingValue::anchor_object_refs()
returns the full anchor object set (events + a spanner's measure/region anchors;
wall-clock references nothing), and create_cross_cutting's liveness precondition
now checks it, so such a spanner is refused (TargetMissing) rather than minted
dangling. Deterministic across both reduction modes (the base seed registers
regions/measures in objects). endpoints() stays event-only — it feeds the
re-anchoring referent index, and non-event referent re-anchoring stays deferred,
ratified events-only (the spanner discipline).

Regression: create_cross_cutting_spanner_preconditions_region_measure_anchors
(missing region → refused; live measure/region → mints, invariant-clean).
937 tests, convergence/equivocation/conformance green. PASS13_CANDIDATES.md:
P13-D3 resolved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 18:01:08 -04:00
Levi Neuwirth e6308371d5 Push 3 slur review fix: measure slur_shape on whole slurs, not split fragments
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>
2026-07-08 17:56:11 -04:00
Levi Neuwirth 449dd2ef8e Push 3 ratify: extend the slur-rendering requirement + refresh slur_shape
The three slur refinements (dashed rendering, curve splitting, slur_shape
measured) lifted into the spec — Chapter 7 / Quality Metric Catalog only,
non-canonical, so no wire form and no companion-version move.

core_spec Ch7: req:layoutir:slur-curve extended — an authored non-Solid
SpanStyle line renders faithfully (the LineStyle rides the Curve, whose listing
gains the field); a slur spanning a system break splits into per-system
sub-curves (de Casteljau; first segment keeps the slur's provenance, the rest
synthesized continuations). Only the curvature-computing algorithm stays
forward-referenced out now (dash rendering no longer deferred).

quality_metric_catalog: the §slur_shape rationale and the notated-but-unrendered
open question refreshed to record slurs now render and are measured; the formula
(req:qmc:slur) is unchanged, so no catalog version move.

Revision-history row (Push 3); a Push-3 tranche in PASS12_RATIFICATION_LOG.md;
layout-ir DECISIONS cross-ref extended. Both specs build clean (latexmk
-xelatex, 0 undefined refs). No code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 17:33:11 -04:00
Levi Neuwirth 7d61271069 Push 3 (slur quality) 3/3: measure slur_shape_penalty (off the 0.0 placeholder)
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>
2026-07-08 17:28:28 -04:00
Levi Neuwirth 586969125e Push 3 (slur quality) 2/3: split break-spanning slur curves (de Casteljau)
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>
2026-07-08 17:21:19 -04:00
Levi Neuwirth c5173d275e Push 3 (slur quality) 1/3: render dashed/dotted slurs faithfully
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>
2026-07-08 17:13:32 -04:00
Levi Neuwirth 693150e43e Schema major 2 Phase F: ratify repeat + slur rendering into Chapter 7
The schema-major-2 push's rendering consumers (E1 repeat barlines/volta
brackets, E2 slur cubic-béziers) lifted into core spec Chapter 7 — layout-IR
only, non-canonical, so no wire form and no companion-version bump (Operation
Catalog stays 0.7.0, Binary Format 0.6.0), mirroring the schema-major-1 Phase-F
engrave-ratify-only precedent.

Three req:layoutir:* requirements added at §ResolvedLayoutIR:
- resolved-primitives: ResolvedLayoutIR carries non-glyph line Strokes (staff
  lines, stems, barlines, volta brackets — present since staff lines, never
  previously ratified) and cubic-bézier Curves (slurs, E2); each provenance-
  traced (the hit-test basis), re-spaced like a glyph, non-canonical. The
  struct listing gains strokes/curves and the Stroke/Curve shapes; the RenderIR
  provenance requirement widened from "originating ResolvedGlyph" to
  "ResolvedGlyph, Stroke, or Curve".
- repeat-render (E1): a barline-drawing RepeatStructure renders a repeat barline
  at each resolved boundary, each Volta a bracket with ending numbers;
  unresolvable boundaries draw no ink (honest placement); jump-kind marks and
  cross-region repeats deferred.
- slur-curve (E2): a Slur renders as a cubic bézier honoring CurvatureOverride;
  an endpoint not on a single staff of one region draws no curve; a non-Solid
  line style MUST be surfaced (diagnostic), not silently rendered solid.

The curvature-computing algorithm and the RenderIR encoding stay forward-
referenced out. Ch5 §Slurs / §Repeat Structures and the Ch7 Forward References
gain labels for the cross-references. Revision-history row + a schema-major-2
rendering tranche in PASS12_RATIFICATION_LOG.md; layout-ir DECISIONS carry the
Phase-F cross-refs. Spec builds clean (latexmk -xelatex, 0 undefined refs).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-08 16:23:12 -04:00
Levi Neuwirth 28f210e16f E2 follow-up: surface slur style deferral, conservative curve hit capsule
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>
2026-07-08 16:08:05 -04:00
Levi Neuwirth 81b7f42f0a Schema major 2 Phase E2: slur curves + cubic-bézier primitive
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>
2026-07-08 15:33:00 -04:00
Levi Neuwirth 6651ae5fce E1 follow-up: slot-relative glyph remap (same-slot spacing preserved)
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>
2026-07-07 22:16:54 -04:00
Levi Neuwirth 7a9bf42119 Schema major 2 Phase E1: repeat barlines + volta brackets render
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>
2026-07-07 21:51:11 -04:00
Levi Neuwirth 58eef41c7e Phase D follow-up: anchor-site liveness and containment, all object kinds
User-review findings on 9b5339f — the Phase-D site-set unification was
incomplete in exactly two consumers that still collapsed anchor_sites()
to events:

- High: the mint precondition validated only TimeAnchor::Event targets,
  so CreateRepeatStructure with start naming a missing REGION (or a
  volta span a missing MEASURE) minted a dangling repeat straight past
  CrossCuttingRefsResolve. Fixed: anchor_object_refs (events + measures
  + regions; wall-clock references nothing) drives the precondition —
  deterministic across reduce()/reduce_onto(), since the base seed
  registers regions and measures in `objects`. Regression covers a
  missing region in start (base-free), a ghost measure inside a volta
  span (graph-aware), and the positive measure/region-anchored mint
  with invariants green. The referent INDEX stays event-only by design
  (the rule table repairs event tombstones — the spanner discipline).

- Medium: editor barrier containment derived only from event locations,
  so a repeat anchored solely to a protected region carried a default
  context and bypassed a region-scoped barrier. Fixed: repeat_context
  walks all anchor objects in anchor_sites order — event/measure sites
  bind (region, staff instance) via event_location/measure_location, a
  bare region anchor binds the region — used by both Create and Delete
  subject arms. Regression: a region-scoped barrier fires for a
  region-anchored repeat create and stays quiet for another region.

P13-D3 filed (spec/PASS13_CANDIDATES.md + ops DECISIONS): the SPANNER
family has the same mint-time shape (CrossCuttingValue::endpoints() is
events-only while anchor_target_exists checks all three kinds) plus the
non-event-referent-tombstone gap — pre-existing,
ratified-as-implemented; a catalog-semantics decision, not a Phase-D
fix.

Full gate: fmt, clippy -D warnings, rustdoc -D warnings, 30 workspace
suites, conformance scale 1 (8/8).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
2026-07-07 20:18:29 -04:00
Levi Neuwirth 9b5339f8f3 Schema major 2 Phase D: the repeat-authoring pair (code tranche)
CreateRepeatStructure / DeleteRepeatStructure land end-to-end,
review-hardened (five-dimension workflow review; 19 findings triaged —
12 verifier agents lost to a session limit were verified by hand).

epiphany-ops:
- CreateRepeatStructureOp { repeat } (lp(RepeatStructure), wire 28,
  BORN AT V2: schema_major always 2 — kind/voltas are unconditional
  fields) and DeleteRepeatStructureOp { repeat } (bare id, wire 29,
  major 0: minimal stamping over a major-0 layout; the discriminant a
  schema-minor append). OperationKindTag 28/29 name-verbatim with
  validating decode; golden tables grown to 30; migrate/v0 identity
  arms; lib re-exports.
- Reduction: set-union mint (live-id => AlreadyApplied without value
  comparison — the cross-cutting discipline; RecreateContentMismatch
  scope unchanged) with the ALL-anchor-sites-live precondition;
  delete-wins tombstone; the "Repeat structure / Anchor" rule-table
  row wired into BOTH the ledger (reanchor_for_tombstone arm, min-id
  survivor, cascade when dry) and the graph (every dead site rewrites
  to the same survivor), agreement asserted by tests incl. the
  DalSegno jump-target case.
- Undo: materialize_graph_tombstones gains RepeatStructure — and the
  pre-existing SPANNER GHOST fix (an undone spanner mint never left
  the graph), regression-locked.
- validate.rs: VoltaEndingsIllFormed advisory (non-empty, 1-based,
  strictly ascending; interactive-only).
- Canonical-base honesty: blake3 re-pinned for the corpus stream
  shift, its comment corrected (the seeded repeat creates all no-op),
  and the property covered surgically by
  the_canonical_base_embeds_no_repeat_values (two reductions
  differing only in repeat v2 content => byte-identical bases).

epiphany-core:
- RepeatStructure::anchor_sites()/anchor_sites_mut(): THE single
  site-set walk (start/end, jump targets, volta spans), consumed by
  reduction, the editor barrier seam, the invariant walk, and the
  cross-reference index — replacing five hand-rolled copies and
  fixing a SIXTH, silently-stale one (indexes.rs had never learned
  the Phase-B kind/volta anchors; regression-locked).
- RepeatStructure joins the CanonicalValue seam (strict per-value
  decode); decode-fuzz corpus gains valid_score_rich_with_repeats
  (DalSegno + voltas) CORPUS-LOCAL — shared render fixtures stay
  repeat-free until E1 (zero golden churn).

editor-core / gui / testkit / layout-ir:
- Barrier seam: repeat_event_refs + subjects_of arms +
  graph_structure_endpoints arm, gated end-to-end by
  repeat_authoring_kinds_derive_subjects_and_gate_on_barriers.
- Generators: operation_payload -> below(30); graph-session gen ->
  below(14); ops fuzz gen_payload -> below(27); layout_stub
  gen_operation_kind_tag unstaled 24..=29 (the Phase-3 tags were
  already missing); GUI name table.

Spec touch-ups (review findings): catalog says "strictly ascending";
the survivor tie-break is now explicit in the core rule-table row and
catalog (identifier-order minimum among multiple survivors;
proximity-aware four-key selection deferred, as for spanners).

Trail: ops + core DECISIONS entries; spec/PASS13_CANDIDATES.md opens
the Pass-13 ledger at three candidates (P13-K1; P13-D1 undo-driven
tombstones bypass ledger re-anchoring — pre-existing class; P13-D2
cue-cascade repair-trail ordering).

Full gate: fmt, clippy -D warnings, rustdoc -D warnings, 30 workspace
suites, conformance scale 1 (8/8).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
2026-07-07 20:06:18 -04:00
Levi Neuwirth b816144a43 Schema major 2 Phase D: ratify the repeat-authoring pair (spec tranche)
CreateRepeatStructure / DeleteRepeatStructure enter the ratified
operation set — the dedicated pair for RepeatStructure (repeats live in
the cross-cutting registry but are deliberately NOT CrossCuttingValue
wire kinds; the reconciliation note follows Ch6's CrossCuttingStructure
listing).

operation_catalog (0.6.0 -> 0.7.0):
- New K0 §"Repeat Structures": the full six-part schema for the pair.
  Set-union mint with the ALL-anchors-live precondition (start/end, the
  kind's jump targets, each volta's span — the mint must leave the graph
  satisfying reference-resolution invariants; dead anchor =>
  TargetMissing no-op); live-id re-create reads AlreadyApplied without
  value comparison (cross-cutting discipline; RecreateContentMismatch
  scope unchanged); delete-wins tombstone; create-undo tombstones the
  mint, delete-undo does not restore (P11-C8); volta well-formedness
  stays advisory.
- K1 gains the "Added in the schema-major-2 revision" entry (net-new
  primitives, never drafted as slots).

binary_format (0.5.0 -> 0.6.0):
- OperationKind wire table appends 28 (lp(RepeatStructure)) / 29 (bare
  identifier); OperationKindTag 28/29, name-verbatim projection;
  requirement bounds move past 29.
- Honest per-op stamping ratified: the CREATE is born at v2 (kind/voltas
  are unconditional fields — no lower-major payload layout exists, so
  every block carrying one stamps major 2 under minimal stamping); the
  DELETE's bare-id payload is a major-0 layout, so minimal stamping
  gives its blocks major 0 — the kind discriminant itself being a
  schema-minor vocabulary append (mechanism claim only; the stamp always
  follows minimal stamping, as discriminant 24's always-2 CreateStaff
  shows).
- §Schema Major 2 restructured: RepeatStructure moves from the
  snapshot-only bullet to the canonical operation layer (eight embedding
  payloads now); the CanonicalValue seam grows to twenty-three
  (RepeatStructure joins — CreateRepeatStructure embeds it per
  req:catalog:value-encoding).

core_spec:
- Ch6 OperationKind + Ch8 OperationKindTag listings gain the variants;
  the CrossCuttingStructure/wire-vocabulary reconciliation note; the
  re-anchoring rule table gains the "Repeat structure / Anchor" row
  (re-anchor to the nearest surviving anchor across EVERY
  event-referencing anchor site; cascade-delete only when none
  survives — the spanner discipline); Ch8's schema-versioning paragraph
  now enumerates eight embedding payloads with the born-at-v2/major-0
  split; revision-history row.

Review-hardened: five-dimension workflow review, 22 findings, 9
confirmed + 1 recovered from a failed verifier (an API-error casualty,
assessed by hand) — all fixed (the load-bearing ones: Ch8's stale
seven-payload enumeration; the mis-attributed "Phase-3 precedent" gloss
that would have taught a false stamping rule; the pair filed under
"Snapshot-only"; the CanonicalValue seam omission). All three documents
compile clean, zero undefined references.

Code tranche follows this commit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
2026-07-07 17:31:51 -04:00
Levi Neuwirth 437e0758a0 Phase B follow-up: real acceleration-snapshot path + base role bound
Review findings on 794400c. The criterion-4 harness stored current
(v2) Score bytes stamped V0 in the canonical_base slot and decoded
them with the unversioned decoder — bypassing the schema-major
snapshot contract Phase B built, and misusing the base role. Fixed
substantively: the harness stages a properly-roled ACCELERATION
snapshot (ChunkKind::Snapshot stamped for_major(2), referenced from
Manifest::acceleration_snapshots) and decodes through
Score::decode_canonical_versioned keyed by the stamped major — the
versioned seam now exercised end-to-end through the bundle, giving
the snapshot role its first real producer/consumer.

Consequences: max_supported_major(Snapshot) -> 2; and since the
per-kind gate no longer implies it, the canonical-base-stays-major-0
rule is enforced per ROLE (mis_stamped_canonical_base, consulted at
open and commit -> read-only + UnsupportedCanonicalChunkMajor;
regression test covers commit and reopen).

Also: three stale-doc fixes (decode_canonical now "current (schema
major 2)"; CreateRegionOp + StagedChunk::operation_block +
stage_operation_block docs describe minimal stamping, not just "v1
CreateRegion").

Full gate green: fmt, clippy -D warnings, rustdoc -D warnings, 30
workspace suites, conformance scale 1 (8/8).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
2026-07-07 16:54:06 -04:00
Levi Neuwirth 794400c4c7 Schema major 2 Phase B: snapshot side + honest stamps (data-model fills)
The nine type bodies fill to the ratified Ch5 shapes and the wire moves
to the Binary Format §Schema Major 2 layouts, review-hardened (high-
effort review; 8 findings, 7 fixed + 1 deferral sharpened).

epiphany-core:
- 19 new leaf types in graph.rs (SlurKind/CurveDirection/
  CurvatureOverride/SpanStyle/LineStyle/SpaceUnit/SubBeam/
  BeamGeometryOverride/SpannerKind+5 payload types/RepeatKind/Volta/
  StaffBracketKind/MetadataEntry/MetadataValue/Timestamp/
  SoundConfiguration/TranspositionInterval/UnpitchedMember) with the
  ratified discriminants (cstyle_enum_codec! reused; tagged unions
  hand-written); nine struct fills appended in wire order.
- The frozen wire forms generalized to a SHARED sub-codec layer
  (enc_/dec_*_v1, v0==v1 for every type major 2 changed; vec framing
  through enc_/dec_vec_v1) used by the new decode_v1_score/
  encode_v1_score AND the rerouted v0 pair — major 2 touched types the
  v0 walk had treated as unchanged (metadata, staves, cross_cutting,
  staff instances transitively). Strict-canonical guards on every
  versioned path; decode_canonical_versioned dispatches {0,1,2} with
  composed default-fill migration.
- Invariants extended to the new reference-bearing fields (REVIEW
  FIX): Beam.sub_beams events, RepeatKind DaCapo/DalSegno anchors,
  Volta spans now covered by CrossCuttingRefsResolve + the anchor
  model walk, with negative tests.
- Tests: v1 migration size-anchor (v1 omits exactly the appended
  default bytes — the frozen encoder cannot drift), a non-default
  round-trip covering every new field and every SpannerKind/RepeatKind
  wire arm, fuzzer corpus gains genuine-v1 forms + the major-2 seam
  with enforced must-decode-Ok on unmutated frozen forms.

epiphany-ops + epiphany-bundle (Phase C's semantic core, landed here
deliberately — the live codec flip makes CrossCutting/Staff/Metadata
payload bytes v2 immediately; shimming nine transitively-embedded
types the major-1 D1 way was throwaway):
- Minimal-stamping OperationKind::schema_major per the ratified rule
  (CrossCutting/CreateStaff/SetMetadata always 2; CreateRegion 2 iff a
  carried instance bears Some(staff_lines_override) else 1;
  CreateStaffInstance/SetStaffLayout 2 iff Some else 0), unit-locked.
- Bundle op-block accept-set [0,2]; SchemaVersion::V2;
  beyond-accept-set tests moved to major 3; testkit V2
  stamp-derivation test.
- the_canonical_base_is_byte_identical_across_data_model_majors:
  pinned blake3 of a seeded reduction — the companion's SHOULD that
  the canonical base never moves across data-model majors.
- The op-payload migrate-on-read deferral restated precisely in
  DECISIONS (no consumer byte-reconstructs op payloads today; the
  first one must bring per-type frozen payload decoders).

Zero golden churn (fixtures deliberately carry v2 defaults).
Instrument::new consolidates the sweep's default fills. Full gate:
fmt, clippy -D warnings, rustdoc -D warnings, 30 workspace suites,
conformance scale 1 (8/8).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
2026-07-07 16:30:07 -04:00
Levi Neuwirth 7c9d1c40aa Schema major 2 Phase A: ratify the v2 data-model wire form (spec only)
Push 2's spec-first phase. core_spec (schema-major-2 revision): defines
the nineteen leaf types Ch5's ratified shapes referenced but never
pinned (SlurKind, CurveDirection, CurvatureOverride, the shared
SpanStyle record consolidating the identical SlurStyle/TieStyle/
SpannerStyle triplet, SubBeam, BeamGeometryOverride, SpannerKind's five
payload types + a leading Generic migration default, Volta,
MetadataValue, Timestamp, SoundConfiguration, TranspositionInterval
(structural, advisory until Ch4 per the P12-K2 discipline),
UnpitchedMember + member-resolution semantics (first-match, no-match
tolerated; StaffPosition vertical convention explicitly deferred),
SpaceUnit, LineStyle, StaffBracketKind); ratifies the visible-slice
clef/key content model into Ch5 (Clef/ClefShape/KeySignature/
ClefChange/KeySignatureChange) and retires the ClefId sketch;
strictly-authored metadata timestamps (req:graph:metadata-timestamps);
metadata `additional` = ordered list, duplicates permitted; Ch8 gains
the major-2 paragraph; Ch6 OperationKind listing gains its seven
missing variants (staleness fix).

binary_format 0.4.0 -> 0.5.0: new "Schema Major 2" section — per-
payload-type major assignment (snapshot-only Instrument/
RepeatStructure; SEVEN canonical embedding payloads incl. CreateRegion
transitively and SetStaffLayout's direct staff_lines_override field);
the MINIMAL-STAMPING rule (a block stamps the lowest major whose
layouts decode its bytes — deterministic, per-payload table given;
extends the major-1 no-restamp principle); append-after-prior-major
wire layouts with field-order divergences called out; new leaf layouts
with framing pinned (SubBeam EventIds = framed leaves; UnpitchedMember
fields = bare primitives; SpaceUnit = framed 12); total default-fill
v1->v2 migration table (complete StaffLineConfiguration defaults at
all four embed sites; RepeatKind default SimpleRepeat{count:2});
accept-set {0,1,2} with per-role staging note; CanonicalValue seam
list corrected 18 -> 22 (staleness fix).

Two review rounds (8 + 9 findings, all fixed pre-commit — the
stamping-rule determinism contradiction, the CreateRegion transitive
embedding, SubBeam leaf framing, and the SetStaffLayout direct-field
site being the load-bearing ones). Both PDFs build clean, zero
undefined references. No code changes; Phase B (snapshot side) next.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
2026-07-07 15:05:48 -04:00
Levi Neuwirth 93f3dfde93 G-pass follow-up: unsupported pre-pass algorithm ids error
Post-commit review finding: req:pitch:spelling-algorithm /
req:time:decomposition-algorithm ratified MUST-error for unregistered
algorithm ids, but derive_annotations kept the pre-ratification
derive-nothing-under-honest-profile behavior (and a test locked it).
The spec text stands; the code moves: derive_annotations returns
Result<DerivedAnnotations, PrePassError>, rejecting unregistered ids
up front — a silently-empty derivation is indistinguishable from a
legitimately empty score, and would silently disagree with an
implementation that does support the requested id. All production
callers use the default profile (.expect); the stale lock test is
rewritten as unknown_algorithm_ids_error; PrePassError re-exported.

Full gate green: fmt, clippy -D warnings, rustdoc -D warnings, 30
workspace suites, conformance scale 1 (8/8).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
2026-07-07 11:32:05 -04:00
Levi Neuwirth e4edea6a3e Pass 12 G-pass: code tranche (K3/K9/C4/H7) + review fixes
The decided code work behind aff1809, review-hardened (high-effort
multi-agent review; 3 correctness findings fixed pre-commit, all
regression-locked).

epiphany-ops:
- K3: SystemDerivedContentImmutable (12) — modify_event /
  modify_identified_pitch refuse intrinsic-content rewrites of
  SYSTEM_DERIVED pitches, checked against the system_mints registry
  (base-seeded + op mints, so reduce()/reduce_onto() agree).
  REVIEW FIX: Transpose now *skips* system-derived targets like
  tombstoned ones (an in-place alteration shift desynchronized content
  from the derivation registry -> checkpoint-dependent verdicts); an
  all-system transpose reduces as the K3 no-op. Catalog Transpose
  updated.
- K9: RecreateContentMismatch (13) replaces the TargetMissing misnomer
  at both value-retaining re-create sites (CreateStaff, carried
  TimeSignature). Catalog corrected: container creates are plain
  set-union (no value comparison), not K9 sites.
- C4: ReanchorReason::SameCanvasNearer (6); rank-4 recording routes
  through rank_reason. REVIEW FIX: an *unestablished* rank 4
  (unresolvable placement fallthrough in containment_rank) keeps the
  honest ExplicitFallback — selection order unchanged.
- Filed P13-K1 (pre-existing checkpoint asymmetry for ModifyEvent-
  introduced system pitches) in DECISIONS rather than improvising.

epiphany-core (H7, req:pitch:authored-uninferred):
- Authored attachments surface for inference-ineligible targets in
  both pre-passes (best_authored_spelling/_decomposition refactor,
  candidates-first liveness via the existing Score::live_pitch_ids);
  two new taxonomy buckets enter the derivation fingerprint. The old
  test locking the pre-decision mirror is flipped to the ratified
  behavior.

epiphany-testkit (review fixes):
- prepass_harness: spelling-unavailable pitches may carry *authored*
  surfacings (inferred still forbidden); decomposition reconstruction
  invariants scoped to algorithm output (authored entries are
  invariant-15's graph-level jurisdiction); accounting identity gains
  the authored-uninferred bucket.
- generators: the three appended discriminants (12/13/6) now generate.

Full gate green: fmt, clippy -D warnings, rustdoc -D warnings, 30
workspace suites, conformance scale 1 (8/8). Wire goldens + decode
bounds for the appended discriminants; 12 new regression tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
2026-07-07 11:21:22 -04:00
Levi Neuwirth aff1809421 Pass 12 G-pass: ratify the full batch (28 rows) — spec tranche
The G-ratification pass over the accumulated PASS12_BATCH backlog
(worklist: spec/PASS12_WORKLIST.md; dispositions:
PASS12_RATIFICATION_LOG "G-pass tranche"). Four project-lead
decisions: K12 slur permission = AND, H7 authored-uninferred
annotations SURFACE, K4 ResolveConflict = no supersede, K8 genesis
outside the operation set. Three named deferrals: H2 (narrowed;
spelling-v2 + notation refinement), K2 (tuning catalog), K5 (Profile
Conformance companion).

core_spec: spelling + decomposition move to profile-declared with
ratified v1 defaults (req:pitch:spelling-algorithm,
req:time:decomposition-algorithm — two open-question boxes closed);
authored-uninferred surfacing (req:pitch:authored-uninferred);
decomposition precedence pinned FIXED; system-derived content
immutability (K3); genesis note (K8); slur AND advisory (K12);
re-anchoring table C1/C2/C3 + SameCanvasNearer variant (C4); barrier
matching + unsafe-tombstone semantics (req:format:barrier-matching,
req:format:unsafe-tombstone); solver kind-strength /
sub-conformant-report / Minimal-floor requirements (I4/I5/I6); stale
OperationKindTag listing gains the eleven appended tags.

operation_catalog 0.5.0 -> 0.6.0: K1 migration fallback long-term;
K4 no-supersede; K6 edge semantics (single-pass, quarantine excluded,
pending governs); K5 deferral pinned; K8 slots RETIRED; K10 reuse
blessed; K11 asymmetry normative; K12 AND; K2 prototype pin;
K3/K9 preconditions.

binary_format 0.3.0 -> 0.4.0: appended vocab discriminants
SystemDerivedContentImmutable(12), RecreateContentMismatch(13),
SameCanvasNearer(6); E5 tombstone-encoding open question.

All three PDFs rebuilt clean, zero undefined references. Batch rows
struck 28 -> 0 (tracker CLOSED); CONFORMANCE.md caveat dropped;
DECISIONS cross-refs in core/ops/layout-ir/editor-core (new file).

Code tranche (H7 surfacing, K3/K9/C4 discriminants) follows.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
2026-07-07 10:23:30 -04:00
Levi Neuwirth 7e7a43b050 Fuzzer P1: adversarial decode fuzz + strict-canonical decode + bounded counts
Stands up the Binary Format companion's wire-format-fuzzer charter item as a
core adversarial byte-decode harness, and lands the three robustness fixes it
drove out. The canonical decoders are a trust boundary (a hostile bundle, a
bit-rot chunk, a mismatched implementation), so every byte string must decode
to a clean Err -- never panic, over-allocate, or loop unboundedly -- and any
accepted string must re-encode to itself (canonical decode is injective).

- epiphany-core/src/fuzz.rs (new): run_decode_fuzz mutates a once-built corpus
  (random, substitution, truncation, trailing garbage, length-prefix
  corruption, wrong-type payload, genuine-v0-form) against Score::decode_canonical,
  the versioned seam (v1 + the frozen v0 migration), and a per-value decoder;
  asserts no-panic + injective decode over ~40K inputs/run. Two seeds, plus
  deterministic prefix-rejection sanity tests.

- Strict-canonical decode (the fuzzer's first finding): decode reconstructed via
  normalizing constructors (RationalTime reduces, BTreeSet/BTreeMap re-sort, a
  CanonicalF64/ReferencePitch/Tempo normalizes via new), so distinct byte
  strings could map to one value. Fixed complete-by-construction: Score::
  decode_canonical and the CanonicalValue macro re-encode and reject any input
  not already its canonical form; decode_v0_score does the same against the
  frozen v0 wire form (encode_v0_score promoted to production), so major-0
  snapshots are injective too.

- Bounded collection count (Reader::count): reject any count/length exceeding
  the bytes remaining. A garbage u32 count was a soft-DoS -- decoders looped
  element-by-element toward EOF (e.g. misparsing v1 bytes as v0), ~100ms per
  adversarial input; the bound also caps Vec/set allocation and gives a ~2600x
  fuzz speedup (422s -> 0.16s). Codec round-trips confirm no valid data has
  zero-byte-element collections, so the bound never rejects a real encoding.

Full gate green (workspace tests, clippy -D warnings, fmt, rustdoc -D warnings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
2026-07-07 09:42:04 -04:00
Levi Neuwirth 7045a13a18 Schema major 1 Phase F: ratify engrave I8/I9/I10 + process trail
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
2026-07-06 17:40:38 -04:00
Levi Neuwirth 64de92605f Schema major 1 Phase E: resolved-layout length prefixes u64 -> u32
Unifies the resolved-layout's 13 length/count prefixes to u32 (schema major 1),
matching the core codec's put_len; the manifest-embedded barrier blobs stay u64
(regime (b), canonical). The resolved layout is an encode-only, non-persisted
determinism fingerprint, so there is no migrate-on-read and no bundle
LayoutCache machinery (that would be ahead of a producer) — a cross-major layout
cache is regenerated, never decoded.

- resolved.rs: push_u64 length helper -> push_len (u32 LE, debug_assert
  n <= u32::MAX). Data fields (rgba, layer, page.number, smufl_version) untouched.
- Byte-shape lock: count_prefixes_are_u32_width_locked asserts an empty layout's
  four counts occupy 4x4 bytes after the 32-byte ScoreVersion (catalog length
  recomputed independently), so a revert to u64 fails (verified: 128 vs 112).

Zero golden churn (every existing resolved test is self-comparison). Full gate
green (workspace tests, clippy -D warnings, fmt, rustdoc -D warnings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
2026-07-06 13:20:40 -04:00
Levi Neuwirth 4598f30ddd Schema major 1 Phase D2: CreateRegion op-block major + cross-major read-only
The canonical op-block side of Region.permits_spanning_slurs: CreateRegion now
encodes at schema major 1, blocks carrying one are stamped major 1, the reader
admits them per-role, and a bundle whose op history is beyond this reader's
accept-set opens read-only. The migrate-on-read primitive is deferred (op
payloads are never reconstructed-to-values from bytes, so it has no consumer).

- ops: CreateRegionOp::encode_canonical embeds the region's full (v1) canonical
  bytes; OperationKind/OperationPayload/OperationEnvelope::schema_major report
  the payload's binary-format major (CreateRegion => 1, else 0). Removed D1's
  transitional Region::canonical_bytes_v0 (dec_region_v0 stays for snapshots).
- bundle: max_supported_major(kind) raises the OperationEnvelopeBlock role to
  [0,1] (every other role stays exact-0); the read gate is now
  major > max_supported_major(r.kind). StagedChunk::operation_block_versioned +
  SchemaVersion::for_major project a derived block major to a version.
- bundle: commit-time canonical-root validation checks structure without the
  accept-set (a newer writer's higher-major root is publishable); the accept-set
  is a read concern. Both open and commit consult
  unsupported_operation_root_major and go read-only (+ the new
  IntegrityAnomaly::UnsupportedCanonicalChunkMajor) when a canonical op root
  exceeds the accept-set, so the live bundle refuses further commits at once.
- testkit: stage_operation_block derives a block's schema version from its
  operations (max schema_major); staged_envelope_blocks routes through it so a
  generated CreateRegion stream is never mis-stamped v0.

Tests: CreateRegion payload is v1 and carries the flag; the op reports major 1;
a derived CreateRegion block stamps V1 and reopens read-write; a major-2 block
opens read-only (open and post-commit); the per-role accept-set shape. Full gate
green (workspace tests, clippy -D warnings, fmt, rustdoc -D warnings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
2026-07-06 12:09:30 -04:00
Levi Neuwirth 03758bbae0 Schema major 1 Phase D1: Instrument.range + Region flag (snapshot side)
The full-Score snapshot side of the three schema-major-1 data-model fields,
on a struct-level frozen v0 decoder. Snapshot-only: the CreateRegion op
payload stays byte-v0 (D2 owns the op-block migration + read-only).

Data model (epiphany-core):
- PitchRange { lowest, highest: Pitch } in pitch.rs; contains() is frame-guarded
  (decides only within a shared PitchSpaceId) and treats a reversed range as
  undecidable (None), not "everything out of range".
- Instrument.range: Option<PitchRange>; Region.permits_spanning_slurs: bool.

Codec:
- struct_codec! for PitchRange; Instrument -> {id,name,range}; Region appends
  permits_spanning_slurs.
- Replaced the Phase-C byte-splice with a struct-level decode_v0_score: a
  hand-written 19-field Score walk using dec_canvas_v0/dec_region_v0/
  dec_instruments_v0 for the two changed fields (nested in Vecs) and the current
  Codec for the other 17. Removed the now-unused Reader::pos().
- Region.canonical_bytes_v0() (+ enc_region_v0) is the frozen v0 op-payload
  surface: CreateRegionOp embeds it so the op-envelope block stays byte-v0.

Advisory preconditions (epiphany-ops/validate.rs):
- PitchOutsideInstrumentRange: pitch-in-range via voice->instance->staff->
  instrument (honoring instrument_override); "if any"/indeterminate-frame pass.
- Slur-spanning suppressed only when BOTH endpoint regions permit (AND
  semantics; documented pending spec ratification of which region governs).

Tests: the three frozen-decoder fixtures (non-default v1 round-trip; a mirror
v0 encoder synthesizing genuine v0 bytes that migrate default-filling all three
fields, anchored by an independent byte-length check; the nested-Vec multi-
region case); the byte-v0 CreateRegion payload; the frame-aware/reversed-range
contains(); and 5 advisory tests. ~29 construction sites updated. Full gate
green (workspace tests, clippy -D warnings, fmt, rustdoc -D warnings).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
2026-07-06 11:14:52 -04:00
Levi Neuwirth e199fabe3f Schema major 1 Phase C: Canvas.layout_defaults + real migrate-on-read
The first real data-model field of the schema-major-1 bump, and the point
where the dispatch seam flips from the Phase-B identity to a genuine
migration. Zero golden churn: CanvasLayoutDefaults::default() equals the
engraver's existing A4/8mm default, so no resolved geometry moves.

- Data model (core/graph.rs): Canvas gains `layout_defaults:
  CanvasLayoutDefaults`, with new core geometry primitives CanvasSize /
  CanvasMargins (staff-space CanonicalF64, A4/8mm Default -- core has no
  geometry types of its own and must not depend on layout-ir). Exported from
  the crate root; ~7 Canvas construction sites updated with
  `..Default::default()`.
- Codec (core/codec.rs): struct_codec! for the three new types; Canvas v1
  encodes `regions` then `layout_defaults`.
- The frozen migrate-on-read (decode_v0_score): a byte-level splice. v0 Score
  bytes are the v1 layout minus Canvas.layout_defaults; Canvas is Score field 2
  and its v0 layout was just `regions`. Read the v0 prefix (metadata,
  canvas.regions) to find the split, insert the default CanvasLayoutDefaults
  encoding, then decode the resulting v1 bytes. Total and default-filling (no
  score context), frozen by value. A `pos()` accessor was added to Reader for
  the splice.
- Test (v0_score_migrates_by_default_filling_layout_defaults): derives REAL v0
  bytes by stripping the field from a v1 encoding, then migrates them back and
  checks the original score is reconstructed with the default refilled. A wrong
  splice offset corrupts the bytes and fails the decode, so it guards the
  frozen v0 assumptions.

Deferred: routing the migrate through a bundle acceleration_snapshots slot (a
Phase-B Finding-3 item). The migrate logic is proven by the core unit test and
the bundle read path by the canonical-base roundtrip; combining them hits
cross-crate friction (constructing v0 bytes needs core-internal Reader, which
testkit can't reach and core-below-bundle can't stage) for marginal coverage.
The forward scenario (v1 reader migrating a v0 bundle's major-0 acceleration
snapshot) needs no new bundle code -- the v0 snapshot passes the exact gate.

863 workspace tests pass; clippy -D warnings, fmt --check, rustdoc -D warnings
all clean; no render/snapshot goldens changed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
2026-07-05 20:37:36 -04:00
Levi Neuwirth 9f688cc592 Schema major 1 Phase B: the dispatch seam + version infrastructure (no-op)
Stands up the schema-evolution machinery as a tested behavioral no-op, the
load-bearing one-way-door piece the later phases build on.

- SchemaVersion::V1 (bundle/ids.rs) -- infrastructure, an identity today.
- The core dispatch seam Score::decode_canonical_versioned(bytes, major)
  (core/codec.rs), with the decode_v0_score / migrate_v0_score scaffold. It is
  the identity at major 1's introduction (v0 layout == v1 layout), with
  explicit "Phase C/D freeze this by value + default-fill the new field"
  contracts baked into the doc comments so the freeze is a clean edit later.
  Unit-tested by versioned_decode_is_identity_across_majors.

A first-pass review caught that the initial gate widening over-reached: it
admitted major 1 for every chunk kind, but the bundle's own op-block decoder
(block::decode_block) and manifest decoder are unversioned, so a spec-valid
major-1 op block would have passed the gate and then been mis-read rather than
migrated / opened read-only. The accept-set ran ahead of the decoders.

Corrected: the gates stay EXACT to major 0 in this phase -- the manifest gate
to Manifest::SCHEMA.major (the manifest never grows a v1 layout in this bump),
the generic-chunk gate to SUPPORTED_SCHEMA_MAJOR = 0. Admission of major 1 is
raised PER CHUNK ROLE by the phase that adds that role's versioned decode or
discard path (snapshot -> C, op block -> D, layout cache -> E), never as a
blanket accept-set ahead of a decoder that can read it. The roundtrip
seam-exercise was reverted too (it conflated the canonical-base MaterializedState
role with the acceleration-snapshot Score role); the acceleration-snapshot read
path + the first usable_* wrapper land in Phase C.

So Phase B is version infrastructure + the dispatch seam only; the gate
widening, usable_* wrappers, and ops symmetry move to the phases that exercise
them.

863 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
2026-07-05 19:21:11 -04:00