epiphany/crates/epiphany-engrave/DECISIONS.md

1087 lines
70 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# epiphany-engrave — decisions and Pass 12 candidates
This file records (a) the implementation decisions the Phase-2 QUICKSTART asked
Agent I to make once and document, and (b) ambiguities discovered while building
the crate, batched as **Pass 12 candidates** (`spec/PASS12_BATCH.md`) rather than
improvised in code.
## Scope and phase status
`epiphany-engrave` is the production-side **constraint solver** (Chapter 9): it
turns a `ConstrainedLayoutIR` into a `ResolvedLayoutIR` with real geometry. It is
a separate crate from `epiphany-layout-ir` deliberately — `layout-ir` is the
*interface* layer (the graph↔renderer contract); the actual constraint-solving is
on the product side of the spec's core/product boundary, so replacing the
`StubSolver` *inside* `layout-ir` would blur it (`spec/PHASE2_QUICKSTART.md`,
crate topology).
**Phase 2 shipped the renderer-against-stub slice** (QUICKSTART, Agent I,
"Development pattern": build the renderer against the stub solver first, then
grow the real solver): a genuine deterministic *horizontal spacing pass* — the
first axis of the planned two-pass spring layout — later joined by real
hard-constraint evaluation (which earned the `Minimal` tier).
**Phase 3's layout track adds CASTING-OFF** (see "Casting-off (2026-07)" below):
system breaking at measure boundaries, vertical system stacking, page
assignment, a populated `ResolvedPage`/`ResolvedSystem` tree, and full break-
constraint evaluation. **Push 3's Standard-tier track then adds per-system
JUSTIFICATION** (see "Per-system justification (Push 3)" below): every non-final
system stretches to fill the content width; and **vertical justification** (same
section): the systems of a non-final page spread to fill the page height; and
**optimal break search** (see "Optimal break search (Push 3)" below) replaces
greedy first-fit + widow rebalance; and the **inter-staff vertical solve** (see
"Inter-staff vertical solve (Push 3)" below) renegotiates the gaps between a
system's staves. This completes the Standard-tier layout story end to end;
per-system justification (horizontal), vertical justification (inter-system),
optimal breaks, and the inter-staff solve (intra-system vertical) now all
compose.
### Honest tier
By the same rule `layout-ir`'s `StubSolver` follows, a solver that does not
evaluate the declared hard constraints and computes no quality metrics MUST
report `SolverTier::Stub`, never `Minimal` (Chapter 9 §"Conformance Tiers").
`Engraver::tier()` reported `Stub` until real hard-constraint satisfaction
landed and now reports `Minimal` — which it fully earns after casting-off: the
break constraint family is genuinely supported (spec §"Conformance Tiers",
Minimal row), and `Minimal` makes no optimality claim, so the break search
(originally greedy first-fit, now a badness-minimizing search) is legitimate. Since the Quality Metric Catalog companion's
ratification, the solve also reports a **real quality-metric vector**
accurate metric vectors are part of the Minimal claim — computed per the
catalog's formulas (see "Quality metrics (2026-07)" below). The all-worst
placeholder (`QualityMetricVector::unmeasured`) remains only for malformed
inputs the solver cannot measure.
## Implementation decisions (QUICKSTART "Decisions you'll need to make")
1. **Spelling algorithm** — N/A (Agent H, `epiphany-core`).
2. **Solver architecture for engraving — two-pass spring layout (horizontal then
vertical), constraint graph derived from the existing `ConstrainedLayoutIR`.**
This is the QUICKSTART's recommendation: it matches the IR's spring-slot /
vertical-band shape, and the spec's deterministic-output requirement makes a
global optimization solver expensive to validate (hard to make bit-reproducible)
and a rule-based fallback brittle. The horizontal pass implemented here
(`spacing::slot_positions`) is that architecture's first axis. Global
optimization and rule-based fallback are **rejected**.
3. **Renderer SVG dialect** — N/A here (see `epiphany-render-svg/DECISIONS.md`).
4. **Catalog versioning / 5. Binary Format versioning** — N/A (Agents K, J).
### Local decisions
- **`#![forbid(unsafe_code)]`; sync only; MSRV = workspace 1.77.** Same as every
implementation crate.
- **Solver version `1` (`ENGRAVER_VERSION`), distinct from the stub's `0`.**
Chapter 9: within a fixed version, identical input produces identical output.
The horizontal pass is a pure function of the slot sequence and preferred
widths, so this holds; a determinism test asserts byte-identical
`canonical_bytes()` across solves.
- **Well-formedness gate mirrors the stub.** An invalid structure, an unknown
glyph, a forged catalog identity, or an explicit hard constraint this scaffold
cannot yet evaluate yields `SolveStatus::InternalError` (diagnostic-only),
never a panic and never a false `Solved`. When constraints are present the
scaffold additionally attaches a `SolverWarning` naming the limitation, rather
than silently ignoring them.
- **Horizontal spacing preserves provenance, glyph identity, bounds, style, and
layer; it changes only `position`.** It assigns each glyph the `x` of its
spring slot and keeps its baseline `y` (the vertical pass is future work).
## Casting-off (2026-07) — decisions
1. **System breaking at measure boundaries.** Breaks fall only before a
**barline column** (this projection draws each measure's barline at its
*start* column, so breaking before the barline keeps every measure intact;
the region-final barline closes the region and is never a candidate).
*Originally greedy first-fit; replaced in Push 3 by an optimal break search
— see "Optimal break search (Push 3)" below.* Consequences accepted and
documented: a region with no measures never wraps automatically; a single
measure wider than the page yields an overfull system (no mid-measure
emergency break).
2. **Break-constraint semantics: "breaks at slot S" ⇔ S starts a system.** A
`SystemBreakAt`/`PageBreakAt` is satisfied iff the final layout starts a
system/page at that slot (a region's first slot is trivially at a
boundary). Hard breaks are always honoured (mid-measure if necessary — a
`Required` constraint binds absolutely); soft breaks are honoured unless
the closing system would carry **no musical content** (no notehead/rest
column) — the pathological path: the break is skipped, the soft violation
warned, and the unhonoured preference recorded as an
`EngravingDecision` with `DecisionSource::IrOverride` (spec's
override-resolution rule: record, never silently drop).
3. **Frame of constraint evaluation.** Geometric constraints (no-collision,
alignment, position-within) are evaluated against the **pre-casting spaced
geometry** — the frame they are expressed in. Casting-off then relocates
whole systems by per-system rigid motions, which cannot un-satisfy an
intra-system geometric obligation; evaluating post-casting would instead
make *every* `PositionWithin` (whose rect pins the region's source-frame
vertical envelope) unsatisfiable for any casting-off solver, which cannot
be the spec's intent. Break constraints are evaluated against the **final
break structure**.
4. **Page geometry is an engraver parameter (`PageGeometry`), defaulted to A4
at an 8 mm staff.** Adding a `Canvas` graph field is a schema-major change
under the companion's frozen-layout rule, so it was staged to the data-model
schema major. **Schema major 1 now defines the type** (`CanvasLayoutDefaults
{ page_size: CanvasSize, margins: CanvasMargins }`, staff spaces, A4/8mm
default) and ratifies its wire form (core spec + Binary Format 0.3.0,
Phase A); the **code graph home landed in Phase C** (`Canvas` gained the
field). Wiring the engraver to *read* it (Phase C) is a byte-neutral
follow-up **deferred** until a custom-geometry producer exists, so the
engraver still takes the geometry as a constructor parameter (every score's
`layout_defaults` is the A4 default today). Default arithmetic (1 staff space = staff height / 4 = 2.0 mm at
an 8 mm staff): A4 210 × 297 mm → **105 × 148.5** staff spaces; 15 mm
margins → **7.5** staff spaces; content area 180 × 267 mm → **90 × 133.5**
staff spaces. 90 staff spaces wraps the ten-measure hand-off fixture
(≈ 99 staff spaces spaced) into two systems — an honest multi-system
default golden.
5. **World-frame convention: pages stacked vertically in one world.** Page 1's
top-left corner sits at the origin; page *n*'s frame begins a full page
height plus `INTER_PAGE_GAP` (8 staff spaces, a presentation constant)
below page *n 1*'s. Every glyph/stroke position is **baked** into this
single y-up world frame (per-system rigid translation: x back to the left
margin, y to the stacked position), so the SVG renderer and the hit-test
map work unchanged on the flat lists — no per-page transform exists
anywhere downstream. The inter-*system* gap is read from
`VerticalBand::inter_system_gap` (preferred 4.0 staff spaces), so the
casting-off gap and the band model cannot drift.
6. **System-spanning strokes are split; the split is provenance-honest.** The
five staff lines span the whole region; a break cuts them at the systems'
content edges. The first segment keeps the original stroke's exact
provenance (round-trip preservation); each later segment is synthesized
(`SynthesisKind::Registered(SYSTEM_CONTINUATION_SYNTHESIS)`, the codebase's
convention for kinds the normative vocabulary does not name) with a
`continuation_instance_key(original stable id, ordinal)` instance key. The
round-trip contract in `layout-ir` was relaxed accordingly (containment +
declared-synthesis additions; the stub still must add nothing).
7. **Engraved-break decisions.** Every chosen system/page break appends an
`EngravingDecision` whose **target** is the `MUSCLOID` id synthesized from
the owning region's source under `SynthesisKind::EngravedBreak`, keyed by
the breaking slot's (content-derived) identity. Source attribution:
`UserOverride(id)` when the break constraint was projected from a user
break override (the id flows through the new
`ConstrainedLayoutIR.break_origins`), else `Automatic`; a skipped soft
break records `IrOverride`. A boundary that actually opens a page records
`PageBreak`; a later page opening at a region's own first system records
`PageBreak` too; other boundaries record `SystemBreak`.
8. **Inverted tests.** Two tests that pinned the single-system semantics were
deliberately inverted and renamed:
`a_hard_break_cannot_be_honoured_by_single_system_minimal`
`a_hard_break_is_honoured_by_casting_off` (Unsatisfiable → Solved with the
system count increasing), and
`a_users_break_flows_to_a_soft_violation_not_a_failure`
`a_users_break_is_honoured_and_recorded_with_its_override` (soft-violation
warning → clean Solved, break at the anchor's column, decision recorded
with the user's override id). The pathological-soft path keeps the old
warning semantics under a new, honest name
(`a_pathological_soft_break_is_skipped_and_recorded_as_ir_override`).
9. **Widow rebalance (casting-off phase 2) — the honest P12-I11 fix.** Greedy
first-fit (decision 1) is optimal for *page fill* — it packs each non-final
system as full as the width allows — but that leaves a region's **final**
system whatever is left over, often a narrow stub (a "widow") the
`casting_off_quality` axis penalizes as a global casting-off failure. A
second phase (`casting::rebalance_widows`) evens the split: it moves whole
trailing measures from a region's penultimate system into its final one,
choosing the shift that **minimizes the larger of the two distribution
penalties the Quality Metric Catalog defines for the break family** — the
width imbalance (`casting_off_quality`, the CV of the region's system widths)
and the non-final break penalty (`system_break_penalty`, the mean of
`|W w|/W` over non-final systems) — each computed by the same formula as
the axis it stands in for, so the rebalance optimizes the values the metric
census will report, not a proxy. The
two axes pull against each other (filling non-final systems worsens
imbalance; equalizing widths worsens underfill) and both share the catalog's
`0.5` anchor, so the raw quantities compare directly and the minimizer of
their maximum is the width that best satisfies both. Scope: only a region's
**last** boundary moves, and only when greedy placed it (an `Automatic`
boundary with no break requirement or page force pinned to its slot); a
user/IR-anchored or page-forced boundary is never disturbed, the penultimate
system keeps ≥ 1 measure, and the final system never grows past its
predecessor. The **system count is unchanged**, so decision 2's break
structure, decision 5's page assignment, and every break-count test invariant
hold. Result on **P12-I11**: RS-1 casts six/four instead of eight/two
(`casting_off` 1.0 → 0.4463, every axis ≤ 0.90), the suite's asserted Xfail
row is promoted to a plain Pass, with **no Quality Metric Catalog change**
the engraver improved, the `0.5` anchor and the `0.90` Minimal column stood.
`ENGRAVER_VERSION` 2 → 3 (a wrapping score's baked geometry differs from pure
greedy); the `ten_measure` render goldens were regenerated. Still a Minimal
heuristic, not an optimality claim.
10. **Deferred refinements** (named, not implied): ~~per-system justification~~
(**landed in Push 3** — see below); the vertical spring solve (band heights
are carried, not yet renegotiated; systems stack by real content extents);
orphan control and
optimal/lookahead casting-off quality beyond decision 9's tail-only widow
rebalance — a `Standard`-tier concern (full-region rebalancing, and
justification-aware casting-off once systems can stretch); casting-off caching /
incremental re-cast (the spec's incremental-layout section names the
casting-off cache; `solve_incremental` currently re-solves from scratch,
which remains observationally equivalent); per-system clef/key restatement
(cautionary signatures at system starts, `SynthesisKind::Cautionary`);
multi-system-aware x→time inversion for editor click-to-insert
(`epiphany-editor-core`'s `position_at` interpolates one global x axis and
is correct only within the first system of a wrapped region).
## Pass 12 candidates
See `spec/PASS12_BATCH.md` (rows P12-I1, P12-I2, P12-I3) — all three are now
resolved:
- **P12-I1 (resolved by I-1)** — the v0 pipeline was a *structural placeholder*
(one arbitrary glyph per object at `y = 0`). `to_constrained` now builds real
notation (clef-relative noteheads, accidentals, key/time signatures, rests,
barlines, stems) and the Engraver re-spaces it; the Ch 7 engraving boundary
resolved to notation-construction-in-`to_constrained`, spacing-in-the-Engraver.
- **P12-I2 (resolved)** — the `MUSCLOID` layout-object id derivation is wired
(`epiphany-determinism` reserves the built-in tag; `layout-ir` provenance and the
engraving-decision id route through it).
- **P12-I3 (resolved by I-4a)** — `BRAVURA_METRICS` is re-extracted from the same
SHA-pinned `bravura-1.392` font the outlines come from, with bboxes rounded
outward to contain the drawn ink (a `render-svg` test proves containment).
### New candidates from the casting-off slice (proposed rows; spec not edited)
- **P12 (proposed) — `Canvas.layout_defaults` is named but never defined.** The
spec references layout defaults ("paper size, margins") on the canvas, but no
chapter defines the type, its units, or its defaulting rules, and the core
graph does not carry it. Proposal: define `CanvasLayoutDefaults { page_size:
Size2D, margins: Margins }` in staff spaces in the data-model chapter,
staged to the **data-model schema major** (adding the field changes the
canonical graph encoding); until then, page geometry is a solver parameter
(this crate's `PageGeometry`) and the spec should say a solver MAY default
it.
- **P12 (proposed) — break-constraint satisfaction semantics.** Chapter 7
defines `SystemBreakAt { slot }` but not what geometric fact makes it
*satisfied*. This crate pins: satisfied iff the final layout **starts a
system at that slot** (page analog for `PageBreakAt`); a region's first slot
is trivially at a boundary. The spec should ratify (or correct) this
predicate, since `Unsatisfiable`-vs-`Solved` conformance hangs on it.
- **P12 (proposed) — user-override attribution across IR stages.** The decision
record for an honoured break must cite `DecisionSource::UserOverride(id)`,
but the normative `LayoutConstraint` carries no origin, so the override id
has no channel from the logical stage's `EngravingOverride` to the solver.
This implementation carries a non-canonical `break_origins` sidecar on
`ConstrainedLayoutIR`; the spec should bless that channel (or widen the
normalized constraint record).
- **P12 (proposed) — synthesis kind for split continuations.** Casting-off
splits region-spanning strokes (staff lines) at system boundaries; the
segments in later systems are engraver-synthesized objects whose kind the
normative `SynthesisKind` set does not name (`EngravedBreak` is the break
itself, not its artefacts). Carried as
`Registered(SYSTEM_CONTINUATION_SYNTHESIS)`; the spec should either add a
continuation kind or bless the registered id.
## Quality metrics (2026-07) — decisions
The Quality Metric Catalog companion (ratified at v0.1.0; currently v0.3.0 —
the axis set, anchors, and thresholds are unchanged across both minor
revisions, which narrowed *what is measured over*, not what it is normalized
against) ratified the nine normative axes' formal definitions, anchors,
thresholds, and the `QualityFloorApproached` trigger; `Engraver::resolve`
now computes the real
vector (the private `quality` module), replacing the all-worst placeholder.
The catalog's normative constants (anchors, the Minimal/Standard threshold
table, the 0.8 warning fraction, the tier/profile→column mappings) are
transcribed once in `epiphany_layout_ir::quality` and consumed here and by the
testkit's reference-suite harness.
1. **Where each axis's inputs come from.** All nine are pure functions of the
constrained input, the cast layout, and the declared page geometry — data
the pipeline already had (see the `quality` module docs for the per-axis
map). The casting pass exposes its own glyph→system assignment
(`CastLayout::system_of_slot`, `region_of_system`) so the census ranges
over what the solve actually did, never a reconstruction. Slot identity
(the collision axis's same-column exclusion) is the glyph's
`horizontal_slot` in the constrained input, index-parallel to the resolved
glyph list. Widths/columns/densities use glyph **ink boxes** per the
catalog's measurement domain (strokes are not glyphs); page spans use the
resolved page tree's system bounding boxes.
2. **Vacuous axes.** `slur_shape_penalty` and `beam_slope_penalty` are exactly
`0.0`: the pipeline draws no slur or beam geometry (both exist logically,
not as curves/segments), so their contributing-unit sets are empty and the
catalog's vacuous-geometry rule (`req:qmc:vacuous`) applies. The catalog's
"notated-but-unrendered" open question explicitly owns this honesty edge;
the axes are wired so the first slur/beam-drawing release is measured from
day one.
3. **Vertical density's unit set.** `to_constrained` declares `InterStaffGap`
bands but **no** `InterSystemGap` bands (the casting pass reads
`VerticalBand::inter_system_gap` directly). Implemented units: (a) the
input's `InterStaffGap` bands, adjacency reconstructed from
`inter_staff_gap_id(region, g)` (gap *g* separates the region's staves
*g1*/*g*), realized separation measured between the adjacent staff bands'
resolved ink extents within a common system — i.e. what the resolved
geometry actually shows, since constrained `y` is pass-through; (b) the
casting pass's realized inter-system gaps (consecutive systems on a page),
measured from the resolved page tree against the same constructor's
preferred height the stacking consulted. Today (b) measures realized ≡
preferred (raw 0), and (a) is empty for every single-staff-per-region
score; a multi-staff region honestly measures ~1.0 because the constrained
stage's fixed 12-staff-space pitch is far from the band model's preferred
2.0 gap — the metric is truthful, the vertical spring solve that would
negotiate it is the deferred work.
4. **Floor warnings never change the status.** Catalog
`req:qmc:floor-warning`: the `QualityFloorApproached` warning "is
diagnostic: emitting it does not change the solve's status". Implemented
literally: `status` is computed before the metric census, and quality
warnings are appended after — a solve with clean constraints stays
`Solved` even when it carries quality diagnostics. (This is also
load-bearing for downstream regression locks that assert `Solved` on
fixtures whose casting-off quality honestly warns.) The applicable
threshold column is the one the config's profile selects
(`profile_thresholds`: Draft→Minimal, Standard/Publication→Standard;
default profile Standard), so `SolverConfig` is now threaded into
`resolve`.
5. **Malformed inputs stay unmeasured.** A structurally invalid or
forged-catalog input has no trustworthy geometry (the census would sweep
unverified boxes), so it keeps `QualityMetricVector::unmeasured()` and
earns no floor diagnostics. An `Unsatisfiable` solve of a *valid* problem
is measured honestly — its real geometry exists.
6. **No-flip verification.** Existing tests asserting `Solved` on healthy
fixtures were re-run against the real metrics: none flipped (warnings
cannot flip status, and no metric enters the status computation). Two
engrave tests asserting `warnings.is_empty()` after an honoured break were
narrowed to "no `LargeSoftConstraintViolation`": their micro-fixtures
(two-note scores broken at the last note column) honestly cast off into
wildly uneven system widths, so the casting-off axis fires its SHOULD-level
floor diagnostic — the metric is telling the truth about the layout, and
the tests' actual claim (an honoured break is not a *soft violation*) is
preserved exactly.
7. **Measured reality on the reference suite (first real vectors).** The six
v0.1 entries now measure clean on every Minimal axis. RS-1's
`casting_off_quality` was 1.0 under engraver **v2**'s pure greedy first-fit
(the stub last line, above the Minimal 0.90 threshold — carried as a
documented xfail row in the testkit harness, P12-I11); the **v3**
widow-rebalance phase (casting-off decision 9) evens the split to
`casting_off` = 0.4463, clearing the miss with no catalog change, and the
xfail row is promoted to a plain Pass. The second finding (P12-I12): three
short entries measured `spacing_distortion` 0.360.41, above the Standard
0.32 warning floor — a spurious *diagnostic* (never a Minimal failure). It
was resolved by a catalog refinement (QMC 0.1.0 → 0.2.0), see quality
decision 8: `spacing_distortion` is scoped to rhythmic (note/rest) columns,
dropping the three to 0.2188 / 0.0819 / 0.0856 — below the floor, no code
layout change.
8. **Rhythmic-column spacing (`spacing_distortion` scoped) — the honest P12-I12
fix.** The measured false positive was that a short healthy line's wide
clef-to-first-note lead advance (furniture width, not note spacing) inflated
the per-system advance CV above the Standard warning floor. The catalog
(QMC 0.2.0) scopes the axis to **rhythmic columns** — spring slots bearing a
notehead or rest — excluding the clef/key/time lead and treating barlines
transparently (a note-to-note advance spans them). `quality::census` now
builds `columns` only from slots in the precomputed rhythmic set
(`is_rhythmic`: a `notehead*`/`rest*` glyph anywhere in the slot); the CV and
contributing-unit rule (≥ 3 rhythmic columns) are otherwise unchanged. This
is the mirror of the I11 resolution — measure the right thing rather than
relax the threshold — but here the defect lived in the normative metric
definition, so it *is* a catalog change (unlike I11). Measurement-only: the
resolved layout, canonical bytes, render goldens, and `ENGRAVER_VERSION` are
untouched; only the reported `spacing_distortion` value changes (RS-3/5/6
drop below the floor, RS-2/RS-4 go vacuous-0.0 as their systems carry < 3
rhythmic columns honestly "too little to measure"). The floor-column
regression test was re-pointed from b-flat's spacing (which no longer warns)
to RS-1's casting-off (which still sits between the Standard and Minimal
floors); a new `short_scores_do_not_trip_the_standard_spacing_floor` locks
the fix. The duration-aware optical-spacing open question stays open.
### Pass 12 candidates (quality metrics)
- **P12-I11 RESOLVED (engraver v3 widow rebalance).** First measured vectors
(engraver v2) cast the RS-1 fixture into glyph spans ~78.6/18.8 staff spaces
width CV 0.61 the 0.5 anchor clamped 1.0 > the Minimal 0.90 threshold.
Resolved the **honest way (option (a))**: casting-off decision 9's
widow-rebalance phase evens the split to ~59.5/37.8 (`casting_off` = 0.4463),
so every axis passes and the testkit Xfail row is promoted to a Pass. Option
(b) (a QMC anchor/threshold revision — raise the anchor, relax Minimal, or add
an RS-1 override) was deliberately **not** taken: the `0.5` anchor and the
`0.90` Minimal column stood. `ENGRAVER_VERSION` 2 → 3; `ten_measure` render
goldens regenerated.
- **P12-I12 — RESOLVED (QMC 0.2.0, rhythmic-column spacing).** With uniform
preferred widths, few-column systems (38 columns with a wide clef/key lead)
measured spacing CV 0.360.41 — above the Standard column's 0.8 × 0.40 = 0.32
warning floor, so the default profile emitted `QualityFloorApproached(Spacing)`
on tiny, healthy scores. Resolved the **lead-aware way**: the catalog scopes
`spacing_distortion` to rhythmic (note/rest) columns, excluding the
clef/key/time furniture lead and treating barlines transparently (quality
decision 8). The three entries drop to 0.2188 / 0.0819 / 0.0856 (below the
floor). The alternative — a duration-proportional (optical) redefinition —
needs the pipeline's deferred duration-aware preferred widths and stays the
catalog's open question.
## Break-constraint satisfaction predicate (Pass 12 P12-I8, ratified)
The break-constraint satisfaction predicate — a `SystemBreakAt`/`PageBreakAt` at
`slot` is satisfied iff the final `ResolvedLayoutIR` starts a system/page at that
slot (a region-first slot trivially) — was ratified into the core spec by the
schema-major-1 track's Phase F (2026-07-06; core spec
`req:layoutir:break-satisfaction`; `spec/PASS12_RATIFICATION_LOG.md`,
schema-major-1 tranche). Satisfaction is a predicate on the output layout, not
the solver's spring state; casting-off evaluates the declared hard break
constraints as part of its tier claim.
## ENGRAVER_VERSION 3 → 4: repeat barlines + volta brackets (E1, 2026-07-07)
The layout pipeline now draws repeat signs and volta brackets (layout-ir E1
decision), so a repeat-bearing score's baked geometry differs from version 3's
invisible traced anchors — a version bump per the constant's own rule.
Repeat-free scores are byte-identical (the existing `ten_measure` /
`valid_score_rich` render goldens passed unchanged; only the new
`ten_measure_with_repeats` goldens were added). Casting-off changes:
- Barline-column classification routes through layout-ir's
`is_barline_glyph` instead of the `"barline"` name prefix, **and now
requires a directly-manifested `Measure` source** (no synthesis): a morphed
repeat sign remains a break candidate and its measure keeps its record,
while a repeat-synthesized standalone sign (a mid-measure boundary, a
region edge without a final barline) and the `repeatDots` pair classify
nothing — the casting contract breaks systems at measure boundaries, and a
phantom candidate could tear off a degenerate lone-sign trailing system or
split a measure. Locked by
`repeat_signs_keep_measure_records_honest_and_raise_their_system`, and the
criterion-6 round-trip now runs the repeat fixture through the real
Engraver.
- Volta bracket strokes raise their system's extent, so vertical stacking
and page overflow account for them with no engraver change (system height
is computed from every member box and stroke).
- **Same-slot spacing preservation (review follow-up, folded into version
4 before release).** The spacing pass reserves a slot's full content extent
(its companions included) in the slot's advance, but the remap moved every
glyph independently by piecewise interpolation — so a same-slot companion
whose absolute x crossed the next slot's *source* (E1 made this reachable:
a time signature after a morphed `repeatLeft` sits `TIME_SIG_X` + the
sign's right extension ≈ 1.8 staff spaces right of its barline, past the
1.6-space constrained column step) was dragged by the wrong interval and
collapsed into the following note. Glyphs now translate by **their own
slot's rigid delta** (`spacing::space_slots` returns the per-slot
`(source, target)` pairs beside the interpolation control points), which
is what honors the reservation; intra-slot offsets survive verbatim.
Spanning strokes keep endpoint interpolation; rigid (ledger) strokes now
translate by the owning glyph's slot delta exactly. 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).
## ENGRAVER_VERSION 4 → 5: slur curves (E2, 2026-07-08)
The pipeline draws slurs as cubic-bézier `Curve` primitives (layout-ir E2), so
the resolved output carries a third primitive kind and a slur-bearing score's
baked geometry differs from version 4's traced anchors — a version bump per
the constant's own rule. Slur-free scores draw the same ink; only the empty
`curves` count prefix enters their canonical bytes (self-consistent). The
existing render SVG goldens are byte-identical; the six snapshot goldens gained
a `curve_count=0` line (a new tracked primitive kind), and the new
`ten_measure_with_slurs` fixture got its own goldens. Engrave changes:
- `HorizontalRemap::curves` re-maps each curve's four control-point x's through
the same coordinate map as a spanning stroke's endpoints (a slur is never
rigid-width); y preserved.
- Casting: a `curve_fate` (originally `curve_system`) rides a curve WHOLE in one
system when it fits, and — since Push 3 (see below) — **splits** a curve
spanning a system break into per-system sub-cubics by de Casteljau. A curve's
control-point hull grows its system's extent, so a slur above the staff raises
the system height for page overflow, like a volta bracket.
- `slur_shape_penalty` stopped being *vacuous* 0.0 and became *0.0 by
construction* at E2; Push 3 (below) makes it a **real measurement**.
## ENGRAVER_VERSION 6 → 7: curve splitting across systems (Push 3, 2026-07-08)
A slur spanning a system break now **splits** into per-system sub-curves
(`curve_fate` → `CurveFate::Split`) by **de Casteljau** subdivision at the
parameters where the curve crosses each system's content clip edges, replacing
E2's draw-whole-in-start-system (the floating-end Minimal boundary). The first
segment carries the slur's exact provenance (the round-trip source surjection
recovers it once); later segments are synthesized continuations under
`SYSTEM_CONTINUATION_SYNTHESIS` with a `(stable_id, ordinal)` key — exactly a
split stroke's discipline. The split needs an x-monotonic curve to invert
`x → t` (a slur is, its control points x-ascending by construction; `param_at_x`
bisects); a non-monotonic curve (not produced by the engraver) falls back to
riding its start system whole. `ENGRAVER_VERSION` 6 → 7 — but only a
break-spanning slur's baked geometry changes; a slur that fits in one system is
`CurveFate::Rigid`, byte-identical to version 6, so the existing goldens are
unchanged (the fixture's slurs are short).
## slur_shape_penalty measured, not pinned (Push 3, 2026-07-08)
The `slur_shape` quality axis moved off its `0.0` placeholder to a real
measurement per the Quality Metric Catalog (`req:qmc:slur`): for each drawn
slur `Curve` with chord `c > 0` (the segment between its endpoints) and apex
height `h` (max perpendicular distance from the curve to that chord, sampled at
`SLUR_APEX_SAMPLES = 32` points), the arc ratio `ρ = h/c` is penalized by its
distance outside the shallow-arc band `[0.08, 0.25]`
(`max(0, 0.08 ρ, ρ 0.25)`), meaned over units and normalized by
`R_worst = 0.25`.
**The unit is the WHOLE SPACED slur curve** — post-horizontal-remap (the drawn
shape) and pre-cast-split (one unit per slur) — **not the cast output's
per-system fragments** (review fixes). A break-spanning slur casts into
per-system sub-cubics whose *diagonal* chords each read flatter than the whole
arc, so measuring fragments would spuriously penalize (and double-count) a slur
that is ideally shaped as a whole — violating the catalog's "a tier that draws
the ideal shallow arc measures 0" property. The whole arc is the unit. An
earlier take measured the *constrained* `input.curves` (pre-remap); a second
audit noted the catalog's units are "drawn slurs," so measurement moved to the
**spaced** curves (`spaced_curves`, threaded into `measure`) — horizontal
re-spacing that flattens or steepens a drawn slur is now honestly captured
rather than hidden behind the intended (pre-remap) shape.
Honest outcome: the Minimal tier's mid-span slurs sit at `ρ = height/span =
0.16` (the `SLUR_HEIGHT_FACTOR`; in band → 0), but the fixed
`SLUR_MIN_HEIGHT`/`SLUR_MAX_HEIGHT` clamps push a **short** slur above the band
(a tall arc on a tiny chord, too bulgy) and a **very long** one below it (too
flat) — so the axis is genuinely non-zero for clamped spans, which a
duration-aware Standard-tier height would improve. A curve-free layout measures
0 by the vacuous-geometry rule. **No `ENGRAVER_VERSION` bump** — a
measurement-only change that leaves the resolved geometry, canonical bytes, and
render goldens untouched (the same rule quality decision 8 followed); the RS
reference suite is unaffected (no RS entry carries a slur).
## Per-system justification (Push 3, 2026-07-08)
The Standard-tier track's first block: every **non-final** system of a
multi-system region 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 by convention).
**The affine, clamped map.** Casting bakes each system by a `Placement`: a
vertical shift `dy` plus a horizontal map `world_x = a·x + b`. A rigid
(unjustified) system has `a = 1, b = dx` — the old pure translation. A justified
system spreads the slack linearly: `a = 1 + extra/span`, `b = base_dx
extra·x0/span`, where `span = x_last x0` is the slot-source range, `extra =
content_width natural_ink_width`, and `base_dx` puts the leftmost ink at the
left margin. The map is **clamped** to `[x0, x1]`: within the slot range it is
affine; beyond it (a glyph's bearing overhang, a staff line drawn to the ink
edge) it is rigid slope-1 — so the mapped ink extremes agree *exactly* with the
per-slot deltas at the first/last slots, and the justified ink spans exactly
`[left_margin, left_margin + content_width]` (no over/undershoot).
**Slot-relative, like E1.** Glyphs translate by the map evaluated at their
SLOT's source (`Placement::slot_dx`), constant per slot, so intra-slot offsets
(a time signature after its barline, an accidental left of its notehead) survive
verbatim — never scaled by `a`. A spanning stroke (staff line, volta bracket)
maps each endpoint through the affine (it stretches). A per-event **component
stroke** (a stem or ledger) translates by its owning slot's delta, so it stays
attached without stretching its offset.
**Component-stroke classification (`component_glyph`), a review fix.** The first
justification cut used `is_rigid_width_stroke` (LEDGER-only) to pick slot-anchored
strokes, on the false premise that it also covered stems. It does not: a stem is
an `Event`-sourced stroke drawn at `notehead_x + 1.15` — no same-source glyph
(noteheads are `Pitch`-sourced) and no baseline in its x-span — so it fell to the
affine branch and its offset was scaled by `a`, detaching it ~0.75 ss (up to
~1.5) from its head in every justified system (and, latently, a smaller drift in
the spacing pass, which had the same classification). `component_glyph` now
classifies a stroke: a `Staff` (staff line) or `RepeatStructure` (volta bracket,
whose ending-number glyphs share its source) source SPANS → affine; else the
`owning_glyph` (a ledger over its notehead, same `Pitch` source, overlapping in
x); else the glyph with the greatest baseline ≤ the stroke's x — a stem's own
in-column notehead/dot, all in the one slot (`stem_offset 1.15 < column step
1.6`, so this is exactly the stem's slot). Applied in BOTH the spacing remap and
casting, so stems stay on their heads through the whole pipeline. Locked by
`stem_offsets_from_the_notehead_survive_justification`.
**A slur's control points map straight through the affine** its endpoints
follow their anchor notes and the arc stretches with the span. **Known minor gap
(deferred):** the endpoints carry an authored `SLUR_INSET` (0.6 ss) tuck from
their notes; the affine scales that offset by `a`, so a slur in a stretched
system tucks ~`0.6·(a1)` further from its heads (≈0.3 ss at `a≈1.5`) still
reading as "near" the note, same root cause as the stem drift but on a soft
connector. A proper fix would slot-anchor `p0`/`p3` to their event slots while
stretching the interior; deferred until slur fidelity warrants it (the curve
carries no per-endpoint slot today).
**Which systems justify.** Not the last system of a region (ragged-right, as
engraving convention wants); not a system with no finite width target, a
degenerate (single-slot) span, or already at/over width (`extra 0` never
compressed into overlap). Greedy first-fit fills non-final systems near-full, so
the stretch factor `a` stays near 1 in practice.
**Quality-metric consequences (honest, noted).** Justification drives
`system_break_penalty` to near zero (a non-final system now fills the width the
point). As a side effect the width-uniformity axes RISE: the full non-final
system contrasts with the ragged last line, so `casting_off_quality` and
`symbol_density_uniformity` measure that contrast (for the ten-measure fixture,
casting-off ~0.45 ~0.80). This is honest for justified layout (full lines + a
short last line), but the axes arguably penalize the *intentional* raggedness of
the final system. **Follow-up (not this tranche):** score casting-off on natural
(pre-justification) widths, or exclude / fill-fraction-weight the final system
a metric-semantics change needing catalog alignment. **Also noted:**
`slur_shape` measures the *spaced* (pre-justification) whole curves, so
justification's horizontal stretch of a slur is not reflected a second-order
gap in the same family as the spaced-vs-constrained one, a follow-up if slur
fidelity warrants measuring the post-justification whole curve.
## Vertical justification (Push 3, 2026-07-08)
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/(n1)` 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.
**Which pages.** The last page stays ragged-bottom (top-aligned), as engraving
convention wants a single-page score is therefore unchanged (its only page is
the last), so every existing single-page golden is byte-identical. A page with
one system has no inter-system gap to grow; an already-full or overfull page has
no positive slack. Locked by `vertical_justification_fills_non_final_pages` (a
small custom `PageGeometry` forces the multi-page path; the non-final page fills,
the last stays ragged).
**Quality-metric trade-off (honest, tested).** Vertical justification drives
`page_fill_efficiency` to ~0 on justified pages (they fill the height the
point), but the same stretch grows their inter-system gaps beyond the band
model's preferred height, which `vertical_density_penalty` measures directly
(its inter-system-gap term, quality.rs `vertical_raw`). So the two axes TRADE:
filling the page is paid for in inter-system density, and a *sparse* justified
page (few systems, large per-gap stretch) is charged more which is a defensible
signal (a 2-systems-on-a-tall-page layout genuinely reads thin) but over-charges
a *moderate*, uniform stretch that is good justification. Same family as the
horizontal `casting_off` / justified-raggedness note; the catalog refinement
(score only EXCESS stretch, or measure gap UNIFORMITY rather than deviation from
preferred) is the deferred follow-up, needing catalog alignment. Pinned by
`vertical_justification_trades_page_fill_for_inter_system_density` so the
interaction is not silent (review finding).
**Still deferred (the rest of the vertical spring solve).** Inter-staff
band-height renegotiation *within* a multi-staff system (today the constrained
stage's staff stacking is preserved verbatim; `vertical_density_penalty`
measures it but nothing renegotiates it). That needs vertical pressure a
collision or a target to be meaningful, and multi-staff systems to exercise;
a later tranche.
## Optimal break search (Push 3, 2026-07-08)
Casting-off's greedy first-fit + tail-only widow rebalance is replaced by a
deterministic **badness-minimizing break search** (`optimal_breaks`, a
KnuthPlass-style dynamic program). `ENGRAVER_VERSION` 9 10 (a wrapping
score's measures partition into different, more balanced systems, so its breaks
and all flowing geometry differ; a non-wrapping score is unchanged).
**Objective.** Minimize the sum over ALL systems of the squared normalized
underfill `((width_limit w)/width_limit)²`. Squaring evens the systems (a
lopsided split costs more than a balanced one); including the FINAL system in
the sum is what subsumes the old `rebalance_widows` (the optimizer will not
leave a narrow final stub if a more 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): both reward filled, even
systems, but the additive form admits a polynomial DP over the measure
boundaries. On the ten-measure fixture the search settles on **5/4 measures**
where greedy + rebalance left a fuller-then-shorter split, which fills the final
system more and pulls `casting_off_quality` down (~0.80 ~0.61) the payoff,
visible now that horizontal justification has driven `system_break` to ~0 so
the last system's fullness is what `casting_off` mostly sees.
**Requirements and overfull.** The 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 a skipped content-less soft break
as an `IrOverride`, unchanged); `optimal_breaks` reports only the AUTOMATIC
breaks. A system may not exceed the content width unless it is a **single
unsplittable measure** (an overfull lone measure, which greedy also emitted; not
charged, since nothing can be done). `Minimal` still makes **no optimality
claim** this is a deterministic global heuristic, an honest improvement on
first-fit, not a formal guarantee.
**Overflow safety net (review fix).** `walk_region` skips a planned break
a soft requirement, or the DP's own automatic break when the closing system
carries no musical content (the lead-only exception, `has_note`). The DP treats
that boundary as a real system start and optimizes each side independently, so
it cannot foresee the skip: a note-less leading measure would then be absorbed
into the following optimizer-filled system, which could **silently** overflow
into a multi-measure overfull system (a review finding the greedy overflow
check that used to catch this was removed with the greedy pass). The net
restores exactly that check as a fallback: `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). Locked by
`a_content_less_measure_before_a_soft_break_never_overflows`.
**Determinism.** A pure function of the slot extents and requirements; the DP
minimizes lexicographic `(cost, system_count)` (fewer systems — hence fewer
pages — breaks ties), and among equal `(cost, count)` the earliest-considered
predecessor wins. Tests: `optimal_breaks_balances_systems_and_avoids_a_final_
widow`, `optimal_breaks_never_spans_a_forced_break`,
`optimal_breaks_is_deterministic_and_empty_when_unbounded`; the widow test now
checks the balanced measure distribution the DP produces.
## Inter-staff vertical solve (Push 3, 2026-07-08)
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 is DECLARED, not inferred.** Every primitive — glyph, stroke,
curve — carries a `vertical_band`, and the solve reads its owning staff straight
out of it (`VerticalBandKind::Staff` → `StaffId`). Content owned by no staff
names a non-`Staff` band and is attributed to `None`, taking no staff shift.
There is no geometry in the attribution path at all: no distances, no epsilons,
no fallbacks. The projection that emitted the primitive already knew the answer —
a stem's band is its note's, a slur's is its notes' — so it says so.
(The geometric `round(-y / pitch)` alternative was rejected early: an extreme
ledgered note on the top or bottom staff rounds to a non-existent neighbour.)
**Why: inferring the owner from proximity failed twice, in ways a gate missed.**
Both bugs shipped into a committed golden and were caught only by review.
1. *Stems.* The first cut reused `component_glyph`, whose fallback picks the
nearest glyph **by x alone**. That is right 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, so the stem kept the wrong vertical shift
and tore off its own head by several staff spaces (and polluted the upper
staff's content extent, inflating the computed gap). Patched with a 2-D
nearest.
2. *Slurs.* The same class one layer deeper, and unfixable by any distance
metric. 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 frequently a note on the ADJACENT staff (in
`two_staff_close_content`, a top-staff ledger note). The bottom staff's slur
was attributed to the top staff, kept shift 0, and tore off its own notes.
Patched with an arc-direction rule read against the staff-line bands.
Both patches were *correct* and both were the wrong shape: they reconstructed, by
geometric inference, a fact the projection had in hand and discarded. `Stroke`
and `Curve` now declare `vertical_band` exactly as `GlyphObject` always has, and
the two rules above are deleted. The engraver's attribution is three map lookups.
Locked by `multi_staff_stems_stay_on_their_own_staff` and
`a_slur_travels_with_its_own_staff` (both kept — they now assert an outcome the
data model *guarantees*, which is where a regression would surface if the
declaration were ever dropped), and by layout-ir's
`every_stroke_and_curve_names_a_band_that_exists`. Adopting it churned **no
golden**: the declared owner agrees with the inferred one on every fixture.
A related correction from the same review: staff-attributed primitives 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`.
**What the band model had to grow to carry this.** Two bands were previously
emitted only when a *glyph* needed them, which left strokes naming bands that did
not exist (validation now rejects that outright, as `UnknownBand`):
- A **staff band** is emitted for every staff of the region, in the region's own
staff order — the order `y_origin` stacks by — rather than only for staves that
emitted a glyph. A staff whose clef is unbundled engraves to an anchor *stroke*
and no glyph, and would otherwise have had no band.
- The **margin band** is emitted unconditionally. A region's own traced anchor is
a stroke, and it names the margin band whether or not any region-level glyph
puts a member in it.
Both may carry zero members, as an inter-staff gap band already did: band
*membership* drives the spring solve over glyphs; band *existence* is what
attribution needs. Strokes and curves are deliberately NOT added to
`VerticalBand::members` — their band reference is one-way, validated only to name
a real band.
**Known gaps (reviewed, not bugs today).**
- ~~`vertical_density_penalty` **saturates at 1.0** on the two-staff fixture.~~
**RESOLVED** (see "The gap band is a height model" below). It was not the
metric-vs-solver tension I first filed it as, and needed no catalog refinement:
the engraver was simply **non-conforming**. The catalog always defined the
realized gap over the *content extents* the band separates; the implementation
measured the band's glyph `members`, because until primitive band ownership was
ratified a band listed no strokes or curves to own.
- **Staff-less content takes no staff shift** — margin-band glyphs, and a volta
bracket whose repeat spans several staves. It stays put while the staves below
it descend, which is right for content that sits above the top staff (the top
staff's shift is always 0). A volta anchored to a *single* staff declares that
staff's band and moves with it. This used to be an accident of geometry — a
volta near a notehead was dragged onto that notehead's staff by the nearest-
glyph fallback — and is now a declared property; see "Attribution is DECLARED"
above. What remains undecided is genuinely staff-less content placed *between*
two staves: it holds still while the lower staff descends away from it. That
wants a height model for the inter-staff gap band, not an attribution rule.
- ~~The preferred gap is read from `VerticalBand::inter_staff_gap(VerticalBandId(0))`
rather than from the region's *declared* inter-staff band.~~ **RESOLVED**: the
solve now reads the `InterStaffGap` band `to_constrained` emitted for that
staff pair, the same band the metric scores against.
The 3+-staff cascade was the last of these to be closed; see below.
**The solve.** Per system, per staff, the real content y-extent is collected
(glyphs, strokes, curves — ledgers and slurs included, not just noteheads). The
staves are ordered top-to-bottom by their staff-line reference y and that order
is kept fixed; each staff is then shifted DOWN by the cumulative amount needed
to bring its gap to the one above up to the band model's preferred inter-staff
gap (`VerticalBand::inter_staff_gap`). `staff_shift[(system, staff)]` — the top
staff's is 0 — is a per-staff `dy` the bake applies (via `Placement::sunk`) on
top of the per-system `dy`, so glyphs, strokes, curves, the staff/measure/system
records, content bounds, hit-test regions, and the quality metrics all read the
same shifted geometry (they all consume the baked output). The shifts grow each
system's extent, which the vertical stacking and justification then consume — so
the inter-staff (intra-system) and inter-system passes compose.
**Scope.** Enforces the preferred gap (which subsumes non-overlap). Only `dy`
per staff changes; staff order is fixed; a single-staff system has no pair and
is untouched. Locked by `inter_staff_solve_separates_colliding_staves` (the
two-staff pressure fixture's staff-line gap opens past the fixed pitch while a
single-staff score keeps one staff per system) and the `two_staff_close_content`
render golden (the visible before/after: slice 1 tight, slice 2 separated).
~~**Deferred:** compressing an OVER-wide fixed gap toward preferred (the solve
only expands, never pulls staves together — the fixed pitch is generous by
default, so this is rarely wanted).~~ **DONE** — see "Two-sided renegotiation"
below; "rarely wanted" was wrong, and the metric said so.
**Still deferred:** per-staff spring *stretch* to fill spare system height (the
inter-system justification carries the fill for now).
**The cascade, and why it grows faster than the raw corrections.** A pair's gap
is measured against the upper staff's **already shifted** bottom, so staff *i*'s
shift is the sum of every correction above it plus its own. A consequence worth
stating because it is counter-intuitive: an *increment* `shift(i+1) - shift(i)`
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 cascade test asserted the opposite — that a gently-pressed lower
pair's increment would be the small one — and failed against a correct solve.)
Locked by `inter_staff_shifts_cascade_down_three_staves` over the new
`three_staff_close_content` fixture: three staves in ONE region (so all three
land in one system), with **asymmetric** pressure — the upper pair collides hard
(C1 against C7), the lower pair only gently. Sizing each pair independently — the
plausible wrong implementation — measures the lower pair against the middle
staff's ORIGINAL position and hands the bottom staff only its small raw
correction, dragging it back up through the middle staff. **Verified by
mutation:** 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 — while `two_staff_close_content` still
passes, which is exactly why the three-staff fixture was needed. The fixture also
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.
## The gap band is a height model (Push 3 residue, 2026-07-09)
Two items deferred by the inter-staff solve — a saturated
`vertical_density_penalty` and a preferred gap read from a constructor rather
than the region's declared band — turned out to be one thing, and neither was
the "metric-vs-solver tension" I filed them as.
**The solve reads the declared band.** Per system, per adjacent staff pair, the
gap it targets is the `preferred_height` of the `InterStaffGap` band
`to_constrained` emitted for that pair (`inter_staff_gap_id(region, g)`, gap `g`
separating the region's staves `g-1` and `g`), not `VerticalBand::inter_staff_gap`'s
default. That is what makes the band a *height model* rather than a constant: a
region declaring a wider gap gets one, and the metric — which scores against the
same declared band — agrees with the solve by construction rather than because
both call the same constructor. Every staff of a region carries content in every
system of that region (its staff lines are per-staff strokes, split into each
system), so the staves present in a system are the region's full staff order and
the window index is the gap index.
**The metric was non-conforming, and the catalog was right.** `req:qmc:vertical`
has always defined the realized 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
`req:layoutir:primitive-band-ownership` landed, 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 (content
gap 2.0), while the glyph-ink gap was **5.06**, giving `|5.06 - 2|/2 = 1.53`,
clamped to a saturated **1.0** — and a Standard-tier floor warning on a layout
that was correct. The metric was charging the solver for the ledger and slur ink
it had made room for.
`vertical_raw` now measures each staff band's full content — glyphs, strokes, and
curves, each attributed by its declared `vertical_band` — and the axis reads
`2.7e-7` on that fixture. Two consequences worth stating:
- **No catalog version move.** The 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 (a definition defect). The
catalog gains a *clarification* of what "content extent" means, plus a
rationale refresh — the v0.1 rationale still claimed the vertical spring solve
was deferred.
- **The geometry is read back from the BAKED output**, not from the solve's own
`staff_ext`. Reading back the solver's intent would make the axis circular and
blind to exactly the bug class that bit twice: a shift the bake fails to apply
to some primitive class now surfaces as a real deviation. `CastLayout` gained
`stroke_system` / `curve_system` for this (a stroke carries no spring slot, so
`system_of_slot` cannot answer for it).
**Still a real trade-off, not a defect:** the axis's *inter-system* half. Vertical
justification deliberately stretches inter-system gaps past preferred to fill a
non-final page, trading this axis against `page_fill_efficiency`. Both are
reported; neither is wrong. Recorded in the catalog rationale so it is not
re-filed as a bug.
**Still open:** staff-less content placed *between* two staves would hold still
while the lower staff descends. No primitive can name an `InterStaffGap` band
today (`band_of` yields a staff band or the region's margin band), so this is
unreachable rather than latent — building the machinery now would be speculative.
### Review follow-up: two more glyph-members assumptions (2026-07-09)
An adversarial review found the content-extent correction incomplete in two
places. Both were right; the second falsified a comment written in the same
commit that introduced it.
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 — the very 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 its staff-line
strokes. Region membership now comes from **content presence** in one of the
region's systems, which identifies the band exactly, because a staff band is
per-`(staff, region)` manifestation and its content can land nowhere else.
Locked by `percussion_placeholder_staff` + `a_staff_band_owning_no_glyphs_
still_contributes_its_gap`. Mutation-verified: the members filter scores
`4.8e-7` where the fix reports real deviation.
2. **Only the first realizing system was measured.** The code took one system per
gap band, 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** (QMC 0.2.0 → 0.3.0), matching how realized
inter-system gaps were already counted. Locked by `two_staff_wrapping_pressure`
(staff-line gap 15.93 in the pressured system, 7.87 in the slack one) +
`inter_staff_gaps_are_measured_in_every_system_that_realizes_them`.
Mutation-verified: first-system-only scores `1.3e-7`.
**What this exposes, and it is not comfortable.** `two_staff_wrapping_pressure`
scores `vertical_density_penalty` **0.739**. Its pressured system solves to the
declared gap exactly; its slack system sits at ~5 staff spaces of content gap
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 recorded above ("compressing an OVER-wide fixed
gap toward preferred… the fixed pitch is generous by default, so this is rarely
wanted") is therefore promoted from *rarely wanted* to **measurably wrong**: any
un-pressured multi-staff system now reports honest sprawl until the solve can pull
staves together. Named here rather than fixed in the same breath — compression is
a layout change (golden churn, `ENGRAVER_VERSION` move), not a measurement one.
## Two-sided renegotiation, and the cascade defect it uncovered (2026-07-09)
`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. The constrained stage's fixed `SYSTEM_STAFF_PITCH` is demoted
from a floor to an initial arrangement the solve fully renegotiates.
**Why:** the expand-only solve made `vertical_density_penalty` report honest
sprawl on every relaxed multi-staff system (0.739 on `two_staff_wrapping_pressure`).
The axis is symmetric — a gap wider than preferred is sprawl exactly as a
narrower one is crowding — so either the layout or the number was wrong. It was
both, in different ways.
**What the band's height MEANS.** It is an **ink clearance**: the separation
between the two staves' outermost content (ledgers, stems, slurs), which is the
unit `req:qmc:vertical` measures. The old `preferred = 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 that stacking leaves for plain content.
Realizing 2.0 would have crushed a relaxed system to a staff pitch of ~7.6.
`preferred` is now **5.0** (a staff height plus a space) and `min` **2.0** — plain
ledgered content settles near a pitch of 10.6, close to convention, while
ledgered or slurred content pushes the staves apart on its own. The user chose
this value; `4.0` ("one staff height") was rejected once its true consequence —
pitch 9.57, not the 11.04 an arithmetic slip of mine had projected — was measured
rather than inferred.
**The cascade was wrong, and had been since v11.** Making the solve two-sided
made `three_staff_close_content` report `vertical_density_penalty` **1.0**: its
lower pair realized 21.06 against a declared 4.0. The recurrence subtracted the
upper staff's shift from the measured gap and then added it back through the
accumulator:
```text
gap = (upper_lo - shift_upper) - lower_hi // WRONG
shift += target - gap // ⇒ realized = target + shift_upper
```
Both staves move, so the correct relation is
`shift_lower = shift_upper + target - (upper_lo - lower_hi)` — the **unshifted**
gap. Every pair below the first was over-separated by exactly the shift above it.
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 the realized clearance back from the
BAKED output, rather than reading the solver's own `staff_ext`, is what made an
independent check possible; had the axis read back solver intent it would have
reported 0 and the defect would have shipped again. That design choice was made
one commit earlier for exactly this reason, and it paid immediately.
**Locking it.** 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**: `a_glyphless_staff_band_still_contributes_an_
inter_staff_unit` (2 units, one per system) and `each_system_realizing_a_gap_band_
contributes_its_own_unit` (2 units for the wrapping fixture, 2 for the three-staff
cascade), each unit ≈ 0. Four mutations verified: the double-counting recurrence,
expand-only, the glyph-`members` band filter, and first-system-only measurement
each fail a named test.
**Churn:** the two multi-staff engrave goldens only. `two_staff_close_content`
grew by exactly 3.0 (the target change, no cascade); `three_staff_close_content`
*shrank* by 9.06 — the same +3 per pair, less the 17.06 of over-separation the
cascade defect was adding. Single-staff and every stub golden are byte-stable.
**Still open:** genuinely staff-less content placed *between* two staves. No
primitive can name an `InterStaffGap` band today, so it remains unreachable
rather than latent.
## W1: casting-off publishes the per-system partition it already computes (Editor T4-pre, `CONTRACT_EDITOR_T4PRE_IR.md`, 2026-07-24)
`cast_off` already knew which system every glyph, stroke, and curve landed
in (`system_of_slot`/`stroke_system`/`curve_system`, private to `CastLayout`)
— it just never left the module. Three changes make it leave:
1. **`glyph_system: Vec<Option<usize>>` joins `CastLayout`**, computed once
in the same pass that bakes glyph positions (the glyphs-building `map`
that already reads `system_of_slot.get(&glyph.horizontal_slot)` for
`dx`/`dy` now also records that same lookup's result per glyph, via
`.unzip()`, instead of computing it twice). This is the single
glyph→system derivation pin 7 asks for: the quality census (`quality.rs`,
`census`) now reads `cast.glyph_system[index]` and no longer re-derives
the answer from `cast.system_of_slot` itself.
2. **The index partition** (`owned: Vec<PrimitiveIndices>` plus one
`unowned: PrimitiveIndices`) is built from `glyph_system`/`stroke_system`/
`curve_system` right before `resolved_systems` is constructed, and handed
to each `build_system` call (which now takes a `PrimitiveIndices`
parameter — pushing it to 8 arguments, `#[allow(clippy::too_many_arguments)]`
added alongside the crate's existing precedent on `walk_region`) and to
the returned `CastLayout.unowned`. `epiphany-layout-ir`'s solve-report fold
(`lib.rs`) copies `cast.unowned` straight into `ResolvedLayoutIR.unowned`
with no further computation; the `None`-cast branch (structurally invalid
input) gets `PrimitiveIndices::default()`, consistent with its empty
glyphs/strokes/curves/pages.
3. **No primitive changes shape.** The three flat arrays (`glyphs`, `strokes`
after continuation-append, `curves` after continuation-append) are exactly
what casting-off already produced; W1 publishes indices into them and
nothing else — including for the synthesized continuation segments a
system-spanning stroke or curve splits into, which were already being
pushed onto `stroke_system`/`curve_system` keyed by their *own* landing
system (`segments[k].0` inside the `Split` arm), not the source segment's.
W1 did not have to fix this; it only had to publish it faithfully, and the
mutation that deliberately breaks that faithfulness (attribute the
continuation to the source's system instead) kills a dedicated test
(`continuation_segments_are_owned_by_the_system_they_split_into`).
**A second, un-migrated re-derivation was found while doing this packet, and
closed rather than named.** `quality.rs`'s `vertical_raw` (the inter-staff-gap
axis) had its own local closure, `system_of_glyph`, independently re-deriving
a glyph's system from the slot map — the exact second copy of the attribution
rule pin 7 exists to prevent, just not the one m4 tests for. The packet's
first pass migrated only the census (pin 7 names it specifically) and recorded
this as an open finding; coordinator review closed it instead, because a pin
whose stated goal is "two copies drift, so make that impossible" is not met by
migrating one of two copies.
**And closing it made `CastLayout::system_of_slot` dead**, which is the real
lesson: with both consumers on `glyph_system`, nothing outside the casting
pass read the raw slot→system map any more. So the field is **removed** from
the published struct — the map stays a local inside `cast_off`, where the
stroke-fate walk still needs it. Pin 7 is now structural rather than
conventional: a future consumer *cannot* grow a third copy of the glyph→system
rule from `CastLayout`, because the raw material is no longer on it. The
per-glyph answer is published, the derivation is not.
**Byte-neutral:** see the `epiphany-layout-ir` DECISIONS.md entry of the same
date for the full canonical-encoding exclusion rationale (two consequences:
fingerprint fragility, and what a future cross-implementation relayout test
may and may not compare) — `casting.rs`'s `encode_canonical` path
(`epiphany-layout-ir/src/resolved.rs`) is unchanged by this tranche.
**Locked by** `primitive_ownership_partitions_every_flat_array_totally_and_
disjointly`, `attribution_correctness_matches_the_real_per_system_counts`,
and `continuation_segments_are_owned_by_the_system_they_split_into`
(`casting.rs`), each against the real engraver on real fixtures — the real
per-system glyph/stroke counts are `[26, 25]` and `[51, 45]` for the
`ten_measure_single_staff` two-system split, not asserted as `> 0`.