epiphany/crates/epiphany-engrave
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
..
src Justification review fix: keep stems attached to their noteheads 2026-07-08 19:54:18 -04:00
Cargo.toml Phase 3 tranche 1: casting-off, K1 schema-fill, value-restoring undo 2026-07-02 21:55:26 -04:00
DECISIONS.md Justification review fix: keep stems attached to their noteheads 2026-07-08 19:54:18 -04:00
README.md Phase 3 tranche 1: casting-off, K1 schema-fill, value-restoring undo 2026-07-02 21:55:26 -04:00

README.md

epiphany-engrave

Agent I's engraving constraint solver (spec Chapter 9): turns a ConstrainedLayoutIR into a ResolvedLayoutIR with real geometry. It is the production-side replacement for epiphany-layout-ir's interface-only StubSolver; the two live in separate crates so the spec's core/product boundary stays sharp.

Status: Minimal tier, with casting-off

  • Engraver runs a deterministic horizontal spacing pass (the first axis of the two-pass spring layout): each spring slot is placed left-to-right by a collision-aware advance derived from real glyph bearings.
  • A casting-off pass (Phase 3's layout track) then breaks the spaced line into systems at measure boundaries (greedy first-fit against a PageGeometry — default A4 portrait at an 8 mm staff), stacks systems vertically at the vertical-band model's inter-system gap, assigns pages by content height, and populates the real ResolvedPage/ResolvedSystem tree. Every position is baked into a single y-up world frame (pages stacked vertically), so the SVG renderer and hit-testing consume the flat glyph/stroke lists unchanged.
  • The IR's declared constraints are evaluated — geometric families against the pre-casting spaced frame, break constraints against the final break structure (hard breaks are always honoured; a pathological soft break is skipped and recorded as an IrOverride decision). Chosen breaks are recorded as EngravingDecisions with SynthesisKind::EngravedBreak targets, attributed to the user override that requested them when one did.
  • It reports SolverTier::Minimal: hard constraints (break family included) satisfied, no optimality claim — the quality-metric vector stays the honest all-worst placeholder until the Quality Metric Catalog lands.

Deferred: the vertical soft-spring solve, per-system justification/stretch, optimal break search, widow/orphan control, and casting-off caching. See DECISIONS.md.

use epiphany_engrave::Engraver;
use epiphany_layout_ir::{ConstraintSolver, SolverConfig};

let report = Engraver::default().solve(&constrained_ir, &SolverConfig::default());
assert!(report.satisfied_hard_constraints);
let resolved = report.layout; // real pages/systems; hand to epiphany-render-svg