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
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
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
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
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
The first phase of the first-ever binary-format schema-major bump (v0 -> v1),
the machinery-first minimal major. Spec-only: ratifies the contract that
Phases B-F build against; no code changes.
core_spec.tex: defines the two referenced-but-undefined types that were the
P12-I7 / P12-K7 gaps -- CanvasLayoutDefaults (with core geometry primitives
CanvasSize/CanvasMargins in staff spaces via CanonicalF64, A4/8mm default,
since core has no geometry types and must not depend on layout-ir) and
PitchRange (advisory pitch compass used by Instrument.range and
IndeterminacyHints) -- and adds Region.permits_spanning_slurs (default false).
Records schema major 1 as the first data-model expansion major and tightens
the minor-version rule (a field add, even Option, is major; minor = append
discriminants to the companion's append-safe vocabularies only).
binary_format.tex -> 0.3.0: the full "Schema Major 1" section --
- Where the changed fields reach: Canvas.layout_defaults and
Instrument.range are snapshot-only (no CreateCanvas/CreateInstrument op),
but Region.permits_spanning_slurs also reaches the CANONICAL CreateRegion
operation payload (CreateRegion embeds the full Region). The canonical-base
MaterializedState embeds none of these and stays major 0, byte-identical.
- Cross-major reader rules: discard-and-regenerate non-canonical chunks;
parse-or-read-only for canonical ones, so a major-0 reader opens a bundle
carrying v1 CreateRegion ops read-only.
- Accept-set gate [min,max] (rejects majors outside the set); per-payload-
type major assignment; the changed v1 value layouts (the wire form ratifies
the reduced reference-code layout, not the fuller data model); the total
default-filling v0->v1 migration table (including the CreateRegion payload).
- Length-prefix unification NARROWED to the resolved-layout (its own
non-canonical LayoutCache): the barrier/extension blobs stay regime (b) u64
because they ride the canonical manifest, which stays major 0.
Two review passes hardened this checkpoint. The first caught that Region is a
canonical operation payload (not cache-only, as the architecture analysis had
assumed) -- user chose to embrace it and build the canonical op-payload
migration. It also surfaced the barrier-blobs-in-manifest constraint that
narrows the unification. The second refined the minor-version delegation, the
accept-set outside-[min,max] semantics, and stale "no defined type" text in
the reference-suite / quality-metric companions and the engrave DECISIONS.
P12-I7 moved to IN PROGRESS (spec type defined here; code graph home lands in
Phase C). Both companions and the engrave DECISIONS reworded accordingly. All
four affected PDFs rebuild clean (0 undefined references).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
The Standard-tier spacing floor warned on short healthy scores: three
reference-suite entries measured spacing_distortion 0.36-0.41, above the
0.32 warning floor (0.8 x 0.40), a spurious diagnostic (every score still
passed Minimal). Root cause: the CV folded in the clef-to-first-note lead
advance, which is sized by notational-furniture width, not by rhythm.
Unlike P12-I11 (an engrave-side fix, no spec change), the defect here lived
in the metric's own normative definition, so the honest fix is a catalog
change - the mirror of I11: correct the measurement rather than relax the
threshold.
Quality Metric Catalog 0.1.0 -> 0.2.0: spacing_distortion is scoped to the
system's rhythmic columns (spring slots bearing a notehead or rest). The
clef / key-signature / time-signature lead and barlines contribute no
column, so a note-to-note advance spans them. quality::census now builds its
spacing columns only from a precomputed rhythmic-slot set (is_rhythmic: a
notehead*/rest* glyph anywhere in the slot); the CV and the >= 3-column
contributing-unit rule are otherwise unchanged.
Results: RS-3/5/6 drop to 0.2188 / 0.0819 / 0.0856 (all below the floor),
and the axis stays honest on real irregularity (RS-3 keeps 0.2188 from a
mid-line accidental). RS-2/RS-4 go vacuous-0.0 (their systems carry < 3
rhythmic columns - honestly "too little to measure," cleaner than the old
furniture noise). RS-1 0.1341 -> 0.1967 (cross-barline note advances).
The 1.0 anchor, orientation, range, tier thresholds, and the eight other
axes are unchanged. This is measurement-only: the resolved layout, canonical
bytes, render goldens, and ENGRAVER_VERSION are all untouched - only the
reported spacing_distortion value moves. The duration-aware optical-spacing
open question (deviation from duration-proportional spacing) stays open; it
needs the pipeline's deferred duration-aware preferred widths.
- catalog: spacing_distortion requirement (req:qmc:spacing) redefined over
rhythmic columns, rationale + open-question note updated, version 0.2.0,
revision-history row; PDF rebuilt clean (0 undefined refs).
- engrave: quality::census rhythmic-column filter + is_rhythmic; module and
spacing_raw docs; the floor-column contrast test re-pointed from b-flat's
spacing (no longer warns) to RS-1's casting-off (still between the Standard
0.28 and Minimal 0.72 floors); new short_scores_do_not_trip_the_standard_
spacing_floor locks the fix.
- QMC version breadcrumbs bumped to 0.2.0 (engrave + layout-ir quality.rs,
both DECISIONS.md, testkit RS-1 comment); reference suite companion
unchanged (cites the catalog by name, no pinned values).
- process trail: PASS12_BATCH I12 struck; PASS12_RATIFICATION_LOG I12
section (Version movements: QMC 0.1.0 -> 0.2.0); engrave DECISIONS quality
decision 8 + item 7 + candidate.
861 workspace tests pass; clippy -D warnings, fmt --check, rustdoc
-D warnings, and the catalog PDF build all clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
RS-1 honestly failed the Minimal casting_off_quality threshold under the
reference engraver: greedy first-fit left a two-measure stub last system
(width CV 0.6145 -> clamped 1.0 > 0.90). Cleared the honest way — an
engrave-side balance pass, no Quality Metric Catalog or core-spec change.
Casting-off gains a second phase, a widow rebalance
(casting::rebalance_widows, run between the greedy walk and vertical
stacking): it moves whole trailing measures from a region's penultimate
system into its final one, choosing the shift that minimizes the larger of
the two distribution penalties the catalog defines for the break family —
the width imbalance (casting_off_quality, the CV of the region's system
widths) and the non-final break penalty (system_break_penalty, the mean of
|W-w|/W over non-final systems). distribution_cost computes each raw by the
same formula as quality.rs's casting_off_raw / system_break_raw (mean not
worst, abs not clamp), so the rebalance optimizes the values the metric
census will report. The two axes pull against each other, so their min-max
lands on a 6/4 split for RS-1 (casting_off 1.0 -> 0.4463, system_break
0.254 -> 0.677, every axis <= 0.90) — with comfortable margin, over the
fragile full-balance 5/5 (system_break 0.889, a hair under 0.90).
Scope is tight: only a region's last boundary moves, and only when greedy
placed it (an Automatic boundary with no break requirement or page force
pinned to its slot); a user/IR-anchored or page-forced boundary is never
disturbed, the penultimate system keeps >= 1 measure, the final never grows
past its predecessor, and the system count is unchanged — so every
break-count and page-assignment invariant (and all break-constraint tests)
hold untouched.
The casting_off 0.5 anchor and the 0.90 Minimal column were vindicated, not
relaxed: the engraver improved, no anchor rescale / threshold loosening /
RS-1 override. Core spec Chapter 9's "Minimal makes no optimality claim"
already permits the heuristic, so nothing normative changed (no .tex/PDF
rebuild). P12-I12 (the Standard-tier spacing floor on short scores) stays
open.
- engrave: rebalance_widows + distribution_cost + two-phase module docs;
ENGRAVER_VERSION 2 -> 3 (a wrapping score's baked geometry differs from
pure greedy); three new casting tests (even-split preference, the
mean-not-worst break penalty for 3+ systems, and the resolved final
system); the_wrapping_fixture_is_measured_honestly re-pinned to the 6/4
values (both axes floor-warn under Standard, status untouched).
- testkit: RS-1 minimal_xfail row removed (promoted to a plain Pass); the
suite ships no xfail rows.
- render-svg: ten_measure.engrave.{svg,snapshot} goldens regenerated
(view_box width 83.99 -> 64.95; still two systems).
- process trail: PASS12_BATCH I11 struck; PASS12_RATIFICATION_LOG
"no spec change" section; engrave DECISIONS casting-off decision 9 +
quality item 7 + candidate promoted.
860 workspace tests pass; clippy -D warnings, fmt --check, rustdoc
-D warnings all clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
857 workspace tests pass; clippy -D warnings, fmt, and rustdoc clean;
both new companions build with zero undefined references.
Quality Metric Catalog v0.1.0 (spec/quality_metric_catalog.tex, new):
- Formal definitions for all nine normative quality metrics, each with
a raw measurement over resolved geometry and a clamped-linear
normalization n = min(1, raw/R_worst) with pinned anchors.
- The vacuous-geometry rule (a metric over absent geometry evaluates
to 0.0; the notated-but-unrendered honesty edge is an open
question), all-1.0 default tie-breaking weights, and the per-tier
threshold table — Minimal's uniform 0.90 deliberately fails the
all-worst placeholder, forcing real measurement.
- Pins QualityMetricKind (referenced but never defined by the core
spec) and the registered SolverProfile catalog (Draft selects the
Minimal threshold column; Standard/Publication select Standard).
- QualityFloorApproached fires at 0.8x the applicable threshold and
is status-neutral by requirement.
Reference Suite v0.1.0 (spec/reference_suite.tex, new):
- Six entries referenced by deterministic builder + seed (RS-1
ten_measure_single_staff, RS-2 valid_score_rich, RS-3..6 corpus
fixtures), each with the declared A4-at-8mm-staff solve geometry
(Canvas.layout_defaults has no graph home yet, P12-I7).
- All entries required at Minimal; the same set is the pre-declared
Standard bar (no implementation claims Standard yet). Fixed-
expectation tests deliberately unused in v0.1.
Real metrics in the engraver (engrave/src/quality.rs, new;
layout-ir/src/quality.rs = the catalog constants transcribed):
- QualityMetricVector::unmeasured() replaced with computed values:
collision sweep with the catalog's same-slot-cluster and stroke
exclusions, per-system spacing CV, vertical gap deviations,
system-break slack, page fill, casting-off width CV, symbol
density; slur/beam vacuously 0.0 (no drawn geometry exists).
- Bit-identical across repeated solves (tested); floor warnings never
change solve status; malformed inputs keep unmeasured(). The two
all-worst test pins now assert real values; the StubSolver's
unmeasured() stays (Stub genuinely computes nothing).
Reference-suite harness (testkit reference_suite module + tests):
- Each RS entry asserts the four-condition Minimal pass (hard
constraints, byte/bit determinism, well-formed Minimal report,
every axis within threshold) under the F1 Pass/Xfail discipline,
with the measured table printed per run.
- HONEST FINDING, day one: RS-1 fails Minimal casting-off (measured
1.0 vs 0.90) — greedy first-fit leaves a two-measure stub last
system (width CV 0.6145). Encoded as an asserted Xfail row (fails
on XPASS) and filed as P12-I11 (engrave balance pass, or catalog
revision). P12-I12: the Standard spacing floor warns on short
scores with wide lead measures.
Multi-system click-to-insert fix (editor-core):
- Casting-off exposed two inversion breaks: position_anchors fed a
non-monotonic cross-system anchor list into a monotonic inverter
(system-2 clicks resolved to system-1 times), and
nearest_manifestation found only system 1's staff-line segment
(system-2 clicks got system-1 pitch geometry).
- Fixed with a containing-system lookup over the resolved pages tree
(containment, else nearest by vertical distance), per-system staff
resolution, and per-system anchor filtering; degenerate-geometry
fallback preserves the flat path, so all 84 pre-existing
editor-core tests pass unmodified.
- Five regression tests through the real Engraver over the wrapped
ten-measure fixture, each shown to fail without the fix; testkit
gains dev-only dependencies on editor-core and engrave.
Trackers: P12-I11/I12 filed; DECISIONS entries in engrave, layout-ir,
and testkit; Phase-3 memory updated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
The chosen Phase-3 attack, run as two parallel waves. 829 workspace
tests pass; clippy -D warnings, fmt, and rustdoc clean; all three
spec documents build with zero undefined references.
Casting-off (epiphany-engrave/src/casting.rs, wired into the
Engraver):
- Greedy first-fit system breaking per region at measure-start
barline columns; a measure that would overflow the content width
starts a new system. Hard SystemBreakAt/PageBreakAt always
honoured; soft breaks honoured unless pathological (skipped with
the spec's warning + IrOverride-recorded decision).
- Vertical stacking from real content extents with the inter-system
gap read from the vertical band model; page overflow starts the
next page. World frame: pages stacked vertically, coordinates
baked into glyphs/strokes, so the SVG renderer, hit-testing, and
the GUI viewport are unchanged.
- Real ResolvedPage/ResolvedSystem trees (1-based page numbers,
content bounding boxes, staves from staff-line segments, measures
from barline columns); every chosen break appends an
EngravingDecision with MUSCLOID EngravedBreak provenance,
UserOverride-attributed via the new ConstrainedLayoutIR
break_origins sidecar; staff lines split per system with
synthesized continuation provenance.
- Break-constraint evaluation flips: satisfied iff the layout breaks
at the slot. The two single-system tests invert deliberately
(a hard break is now honoured; a user break is honoured and
attributed instead of warned). Geometric constraints evaluate in
the pre-casting spaced frame (documented).
- Page geometry is engraver-side PageGeometry (A4 portrait at an
8 mm staff: page 105 x 148.5 staff spaces, margins 7.5, content
90 x 133.5; arithmetic documented) — Canvas.layout_defaults has no
graph home and is a schema-major addition (P12-I7).
ENGRAVER_VERSION = 2. Goldens regenerated: ten_measure_single_staff
engraves as 2 systems (viewBox 84x20.6, was 103x11);
valid_score_rich as 3 systems; stub goldens byte-identical.
K1 schema-fill (Operation Catalog 0.4.0 -> 0.5.0, ratified first;
wire discriminants strictly appended):
- CreateStaff (24 / tag InsertStaff 24): set-union mint of a global
Staff; CreateStaffInstance now preconditions that its referenced
staff is live.
- SetTimeSignature (25): value-carrying meter-change LWW keyed by
(region, resolved position); the carried TimeSignature mints
set-union; StructuralFieldCollision on meter_sequence.
- SetTempoSegment (26): LWW keyed by (scope, resolved start) over
the score or region tempo map; a write that would malform the map
refuses with the appended PreconditionFailureReason 11
(TempoMapMalformed).
- SetStaffLayout (27): LWW advisory over the staff instance's three
inline layout fields.
- Create score/canvas remain deliberately unavailable slots: the
root and canvas are inline singletons with no addressable object
model (P12-K8), not force-designed.
Value-restoring undo (P11-C8 narrowed; catalog §UndoTransaction
rewritten and per-primitive undo notes updated):
- Canonical-order write chains (base-seeded) across all eleven LWW
families. StrictInverse restores each written key to its
chain-predecessor value iff the transaction's write is still the
key's last writer, else refuses the whole undo with a
TransactionConflict naming the superseder; BestEffort restores the
still-last keys. Clean compensations are Applied; only minted-
object tombstone repairs ride AppliedWithRepair (no new repair
vocabulary). Mixed mint+overwrite transactions compose; strand
guards refuse tombstoning mints still referenced by live
non-members.
- Undo-of-undo pinned and tested: restorations are chain writes, so
undoing the undo's transaction restores the undone value, and a
second undo of the same transaction conflicts (absence-restores
repeat idempotently — documented asymmetry, P12-K11).
- Permutation invariance pinned across five delivery orders; the
convergence generators gain the new ops and a tx-then-undo flow.
- Still deferred in normative text: delete resurrection (needs a
system-derived tag outside the ratified closed set), Transpose
inversion (P12-K2), Cascade dependent closure.
Trackers: Binary Format companion 0.1.0 -> 0.2.0 (appended wire/tag
tables, PreconditionFailureReason 11, payload layouts, history row —
a schema-minor evolution under its own rules); nine new Pass-12 rows
(C5, K8-K11, I7-I10); core-spec OperationKind listing gains the four
kinds; revision-history rows in core spec and companion.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
The audit's fourth push: the biggest outstanding Phase-2 item plus the
performance gate. 793 workspace tests pass; clippy -D warnings, fmt,
and rustdoc (deny-warnings) clean; all three spec documents build with
zero undefined references.
Binary Format companion (spec/binary_format.tex, v0.1.0 — Agent J's
deliverable, 43 pages):
- Twelve chapters transcribed from the golden-locked implementation:
encoding conventions (the three prefix/endianness regimes, a
normative no-varint rule, reject-never-normalize decode discipline),
identifiers imported from the core spec's Canonical Byte-Layout
Reference, primitive value encodings, the whole-Score positional
codec ratified as the schema-major-0 wire form, operation wire
forms (envelope field order with the normative id-leads property,
the OperationPayload 0..=3 and OperationKind 0..=23 tables,
effects/conflict/anomaly/MaterializedState vocabulary), the bundle
physical layout (64-byte header, 256-byte superblock, chunk
preimages and framing, ChunkRef, manifest body order), the
operation-index payload, and the extension-blob/edit-barrier byte
forms.
- Ratifies P12-D1 (req:binfmt:opindex), P12-E1 (req:binfmt:ext-blobs),
P12-E2 (req:binfmt:condition-depth, MAX_CONDITION_DEPTH = 64
normative), and P12-E3 (req:binfmt:object-kind-open) — batch rows
struck through; discharges the provisional-codec notes in core
(P11-4), ops, and bundle (P11-D2/D4/D5) DECISIONS with ratification
cross-references.
- Pins the frozen-layout schema-evolution keystone: within schema
major 0 every positional struct layout is frozen; a field-set change
is a schema-major change with migration — formally grounding the
data-model-expansion staging decision. Open questions kept honest
in-document: SnapshotId derivation, index-refresh threshold, u64/u32
prefix unification at the next major.
- Not yet delivered from J's charter: the cross-implementation decoder
test and the wire-format fuzzer (follow-up harnesses).
F1 benches (crates/epiphany-testkit/benches/, per the F0 decision):
- criterion 0.5.1 (workspace dev-dependency; MSRV 1.77 respected with
documented transitive pins: clap 4.5.53, half 2.4.1).
- reduction bench at 1K/10K/50K envelopes with the Chapter-10 budget
(>10,000 envelopes/second cold) written in the bench as a Pass/Xfail
gate; bundle benches for the typical-edit commit (<=50 ms; measured
~14.7 ms on real disk after catching that tmpfs neuters fsync) and
the open/bootstrap read (<=200 ms; measured ~60 us).
- CI: quick budget gates in the conformance job, full gates nightly.
Subquadratic canonical_reduction_order (the F-surfaces/K-fixes
handshake, closing K's 10K-envelope acceptance gate):
- The bench documented the failure (50K at ~1.7K env/s, a 29 s cold
reduction; two O(n^2) loops); the fix replaces pair enumeration with
threshold/frontier readiness per replica plus explicit-dot dependent
lists and a stamp-tuple binary heap — O((n + sum(context)) log n),
never materializing covered pairs.
- Byte-identical order: same edge relation, same ready predicate, same
total order; the old implementation is retained as a test-only
oracle with element-for-element order-equality property tests over
fuzz sets, adversarial sets, and directed shapes (2,000-envelope
full-coverage chains, dot cycles, duplicate-id stamp ties),
mutation-tested for sensitivity.
- Measured: 1K 155K->674K env/s, 10K 12.5K->257K, 50K 1.7K->87K; all
three scale points now pass and the 50K row is promoted from Xfail.
Also: fixed nine rustdoc private/unresolved intra-doc links that had
accumulated across the pushes (the CI deny-doc-warnings job would have
failed on them).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
Two audit pushes whose code edits interleave line-by-line in the same
files (reduce.rs, bundle.rs, the DECISIONS logs), committed together so
the tree at every commit builds. Gate: 784 workspace tests pass, clippy
-D warnings clean, fmt clean.
Push 1 — the true MUST violations, all fixed:
- bundle: zstd read support on both read paths, output bounded by the
declared uncompressed_length, typed decompression errors, explicit
CompressedManifest rejection (zstd 0.13 workspace dep; write path
stays uncompressed per the Phase-3 deferral).
- ops: system-derived counter collision check — mint registry seeded
from the base graph, canonical-order pre-walk, halt via the new
PendingReason::HaltedBySystemCollision (discriminant 4, additive)
with transaction-atomicity and causal-dependent closure; neither
input set occupies a collided counter. canonical_pitch_bytes made
pub in core for the MUSCSPCH preimage.
- ops: Transpose skips tombstoned targets per the catalog; missing
targets still refuse the whole operation.
- ops: marker re-anchoring recorded as a RepairRecord in the
triggering operation's effect; ResolveConflict meta-conflicts name
both resolvers; base-free pitch-id freshness; reserved effect
vocabulary annotated.
- core: decomposition pre-pass honors authored attachments
(resolve_decomposition, spec-default precedence); inversion
tolerance typed as a TempoIntegration-class Tolerance.
- CONFORMANCE.md: the determinism conformance statement required by
Appendix D — all seven declarations.
Push 3 — wiring the types-only machinery:
- layout-ir/engrave: to_constrained emits real constraints (successive
notehead no-collision chains, per-glyph region containment, soft
user-break constraints); ConstraintStrength{Required, Preferred}
with strength-by-rule; Preferred violations surface as warnings, not
failures; StubSolver reworked honest-but-renderable. SVG goldens
byte-identical; snapshot constraint counts regenerated (0->90/15).
- layout-ir: to_logical projects user system/page breaks as anchored
EngravingOverrides with paired UserOverride-sourced decisions
(OverrideKind::SystemBreak/PageBreak carry TimeAnchor, ratified in
the spec alongside).
- layout-ir/ops/editor-core: edit-barrier bridge — decode mirrors for
the whole barrier tree (reject-never-normalize, NFC revalidation,
MAX_CONDITION_DEPTH = 64), golden-locked blob codec for the
ExtensionDeclaration fields, a barrier gate in apply and
apply_transaction backed by a Score oracle and real containment
contexts, and apply_unsafe recording the crossed extensions in
extensions_requiring_tombstone() for the next bundle write.
- ops: ResolveEquivocation meta-operation per the newly ratified
catalog entry — payload discriminant 3 (appended), set-level
earliest-resolve-governs promotion, ResolveConflict-mirrored
meta-conflicts, permutation-invariance fuzz; the missing golden
locks on the OperationKind/OperationPayload wire tables added.
- ops/editor-core: validation modes — ValidationMode + a non-canonical
advisory layer (validate.rs), an authoring gate before minting, and
reduction pinned as replay mode by construction (canonical bytes
untouched).
- bundle: the operation index (opindex.rs) — provisional golden-locked
payload, binary-search locate, staleness defined as full-ChunkRef
set equality against operation_roots, and the reject-and-rebuild
discipline (a defective index is never bundle corruption).
- ops: re-anchoring rule table completed — the four-key "nearest"
ordering computed from base-free ledger indices; markers re-anchor
to the nearest live event in the same staff instance (replacing the
Push-1 region-start stand-in); cue-source cascade; graphic-gesture
Events/Range/Free rows; comment and analytical-annotation orphaning.
Zero appended discriminants.
Spec enablers ratified with Push 3: catalog §ResolveEquivocation
(0.3.0 -> 0.4.0) and anchored break overrides; 16 new Pass-12 rows
filed (C1-C4, K5-K7, I4-I6, D1, E1-E5). The data-model payload
expansion (SlurKind, beam geometry, voltas, instrument bodies,
metadata) is deliberately staged to the Binary Format companion — the
positional graph codec has no value-level versioning, so filling those
structs is a schema-major break that should land once, with J.
Also carries the pre-existing editor-track increment: the atomic
tuplet overwrite (CascadeDeleteTuplets prunes decomposition
attachments naming the cascaded tuplet).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NEs4aYiu8MXjdYdMxw8PTd
default_grid_at(point) gives the GridResolution the pencil should snap to,
derived from the meter of the region under the cursor instead of a fixed
quarter. region_default_grid resolves the governing time signature the way the
prepass's resolve_measure_units does (a region measure's, else the score's
first), and time_signature_beat reads its beat — 1/denominator from the display
(4/4 -> quarter, 6/8 -> eighth, 2/2 -> half) — defaulting to a quarter when the
meter has no single denominator or none is declared. None only when the click
resolves to no staff.
This is additive: position_at / insert_note_at still take an explicit
GridResolution, so their signatures and tests are untouched. The GUI's pencil
click (and the empty-staff preview) now pick the grid via
default_grid_at(world).unwrap_or_else(GridResolution::quarter).
Tests: the beat is 1/denominator (4/4, 2/2; mixed-denominator -> none); a
declared 6/8 yields an eighth-note grid; a meter-less score defaults to a
quarter and a non-finite click yields no grid.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
Wires the session's undo/redo into the GUI: ↶ Undo / ↷ Redo toolbar buttons,
add_enabled-gated by can_undo()/can_redo() so they grey out when there is
nothing to do, and Ctrl/Cmd+Z (undo) / Ctrl/Cmd+Shift+Z or Ctrl/Cmd+Y (redo)
shortcuts read with modifier checks (first in handle_keys, so a modified Z is
not also taken as a plain key). A run_history helper handles the
Option-returning steps, reporting "nothing to undo/redo" when the stack is
empty. These run in the toolbar/key path, before the frame's rerender slot, so
there is no stale-texture concern. Module doc, help text, and the crate
description are updated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
The CRDT is delete-wins (a tombstone is permanent), so undo cannot invert an
edit — it re-reduces the log without it. The materialized score is always
reduce(applied onto base); undo drops the last unit (a primitive, or a whole
transaction) so even a delete is undone — its tombstone is simply never
produced. One user action is one unit; a transaction's descriptor and members
undo together. redo re-appends the unit; a new edit forks history (clears the
redo stack). undo/redo/can_undo/can_redo on EditorSession; a shared
materialize/install path keeps the score, render, hit-test, and clefs in step.
Undo must not let ids be reused (a streamed op would equivocate; an entity id
would collide), so the session keeps a permanent append-only `authored` log
beside the active `applied` prefix. New op-id counters come from authored.len()
(monotonic), and event/pitch/transaction minting scans authored — so a unit's
ids stay reserved after it is undone or forked away. The causal context is
derived from the active head (active_prior_context + extend_context): it covers
the whole active prefix, compact while contiguous and dot-based after a fork, so
it never asserts coverage of a removed counter (which would strand a later op
pending). with_identity now guards on authored history, and applied_operations
is documented as the active prefix with authored_operations the full record.
authored history is a local high-water source, not streaming-consistent undo.
Tests: undo/redo a transpose; undo restores a deleted note; undo a split insert
and a make-room overwrite as one unit; undo an override-aware move (value +
spelling); a new edit forks history; a fork mints fresh op and entity ids
(not the undone ones); edits after a fork re-reduce cleanly; with_identity is
refused after an undone edit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
A toolbar palette — 1 / 1/2 / 1/4 / 1/8 / 1/16 — sets the selected note or
rest's written value via set_selection_duration(NoteValue::whole_note_fraction),
with the editor's make-room overwrite when lengthening; refusals (tuplet member,
decomposed event, non-note selection) surface in the status line. The palette
lives in the toolbar, which runs before the frame's rerender slot, so it has no
stale-texture concern. Adds epiphany-core as a direct dependency for NoteValue
(already a transitive workspace member, so no MSRV/CI impact). The module doc and
crate description are updated; only an undo UI remains unbuilt.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
The duration-palette gesture: resize the selected note/rest. Shrinking frees the
space after it; lengthening makes room over the events it grows into (trim,
delete, or split), atomically with the resize — the same overwrite policy as
insert. Built on phase 3's metric placement materialization.
Extracts the make-room logic shared with insert_note_at: make_room classifies a
voice's events overlapping a span (excluding the event being inserted/resized)
into deletes/trims/split-tails; make_room_ops turns that into ops (cloning a
split tail's shape with fresh ids, carrying authored spellings); a Minter struct
replaces the inline id closures. insert_note_at now calls these (its 7 tests
confirm behaviour is preserved).
Refuses cleanly: InvalidDuration (non-positive), WrongSelection (nothing apt /
non-metric / not a note or rest — replace_span only resizes Pitched/Rest),
OverlapsTuplet (a tuplet member, whose duration is ratio-governed), and
DecomposedEvent — a resize/trim/split would change the duration of an event with
a persistent decomposition attachment, leaving its notated components no longer
summing to it (invariant 15); a full-cover delete is allowed, since the
tombstoned target's decomposition is no longer checked. make_room also skips
non-positive existing spans, matching the reducer's overlap rule.
Tests: shrink, lengthen-into-empty, lengthen-deletes-a-covered-note,
lengthen-trims-a-partial-overlap, refuse non-positive/tuplet, refuse a decomposed
event, refuse a non-note/rest event, requires a selection.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
add_note_to_selection no longer refuses when a note carries an authored
spelling override. It ranks the chord's notes by their *resolved* staff
position — the spelling the layout draws, from derive_annotations (the same
prepass to_logical uses), so an authored override or an inferred respelling is
honored — and steps one staff step above the rendered-highest note. The new
note's raw position lands at rendered_top + 1: stepping the top's pitch by
rendered_top + 1 - raw_top carries its alteration and places it there (for an
un-respelled note this is the old +1 behavior); the acoustic realization still
resets to Implicit. A note with no resolved CMN spelling falls back to its raw
pitch position.
rendered_top_of_event replaces highest_pitch_in_event; the event-level override
refusal, the has_authored_spelling_override helper, and the now-unraised
EditorError::PitchSpellingOverridden variant are removed; note_above becomes
note_stepped(_, 1).
The refusal test is replaced by one that pins a note three staff steps above its
raw position and asserts the add stacks at rendered + 1 (raw + 4), not raw + 1 —
verified to fail under raw-position ranking. This closes the last
override-refusal in the editing track.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
Wires the click-to-insert into the GUI: a pencil ("insert") mode, toggled by a
toolbar button or the P key, turns a staff click into insert_note_at on a
default quarter-note grid (make-room overwrite), instead of selecting. A
non-pencil click still selects, and its empty-staff status now reports what a
pencil click would insert (pitch + grid-snapped beat).
The pencil edit runs in score_view, after the frame's rerender slot, so it
requests a repaint and returns before the selection overlay would paint the new
hit map over the not-yet-rerendered texture. Non-pencil selection keeps the
immediate overlay path (it does not mutate the rendered score).
This completes the beat-grid click-to-insert feature (vertical inverse →
horizontal inverse → trim/move reducer semantics → insert_note_at → this GUI
wiring). The GUI's rendering/interaction is still not visually verifiable in
this environment; only the ViewMap coordinate logic is unit-tested.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
The pencil: a click-to-insert that composes the two inverses — staff_pitch_at
(the natural pitch at the height) and position_at (the grid-snapped onset) —
into a fresh CMN note (duration = grid step) in the staff's primary voice, and
makes room under the overwrite policy as one atomic transaction.
Make-room over the overlapping notes/rests of the voice: a fully-covered event
is deleted; one the new note partially overlaps is trimmed (a phase-3
ModifyEvent placement change); one the new note lands inside is split — the
original is trimmed to its head and its tail re-inserted. A split tail is a
clone of the original event's whole shape (a rest's visibility, a note's
articulations/dynamics/stem/grace) with fresh event and pitch ids, and any
authored spelling is carried onto the fresh pitches via RespellPitch (an
inferred spelling re-derives) — the same atomic copy insert_note_after_selection
does. The several ids minted in one intent advance local checked counters, since
the session high-water mark does not move until commit.
Refuses cleanly rather than corrupt: an overlapped tuplet member raises
OverlapsTuplet (ratio compensation is out of scope); an off-staff or non-metric
click, or a non-note/rest overlap, raises NoInsertTarget.
Tests (against valid_score's tuplet-free metric region; the rich fixture's only
metric region is a triplet): empty-space insert, full-cover overwrite, the split
structure, the split tail's shape fidelity and authored-spelling carry, the
tuplet refusal, and the non-metric refusal. A click_for_position helper inverts
position_at so a test targets an exact onset.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
The make-room enabler: a ModifyEvent that moves a metric event's span
(different Musical position or duration) is now applied to the graph and the
owning voice re-sorted by ascending position (id-tiebroken, the order an
insert maintains), so invariant 3 (VoiceEventsSortedNonOverlap) holds. The
deferral documented in DECISIONS.md is lifted.
modify_event computes one sanction — the move is a valid metric move
(PlacementVerdict::Moved) and the replacement is well-formed — and uses it to
gate both the graph mutation (graph_replace_event) and the voice_occupancy
update, so the canonical index and the graph never diverge. The verdict is
read from voice_occupancy, the graph-independent placement index, so reduce()
and reduce_onto() agree on it; a move with a non-positive span or one that
would overlap a live sibling is refused as a clean NoOp(EventDurationInvalid)
rather than skipped silently. A non-metric move stays deferred, and a
malformed (empty) pitched replacement is neither materialized nor allowed to
move occupancy.
Tests: trim frees the voice slot (a later insert fits); a move onto a sibling
is refused; a trim materializes in the graph (reduce_onto, invariants hold);
a non-metric event is not rewritten onto the musical grid; a malformed move
does not free the slot.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
The horizontal half of click-to-insert: resolve a world point to the metric
region under the cursor and the grid-snapped musical position to insert at.
Together with staff_pitch_at it gives the (pitch, position) an insert needs.
The region and staff are picked through a shared nearest_manifestation helper
(extracted from staff_pitch_at, the non-finite guard moved into it), so the
vertical and horizontal halves agree on which staff the click is over.
The x→time inverse is solver-agnostic: position_anchors reads resolved glyph
x-positions and maps each to its event's onset through the glyph's Pitch/Event
provenance, so the samples come from the actual rendered layout. Only directly
-manifested onset glyphs anchor — synthesized glyphs (accidentals sit left of
the notehead, ledgers, …) are skipped so they cannot pull the time column off
the notehead. invert_x is piecewise-linear through those anchors, extrapolating
the end segment past the last note — the make-room case of clicking the empty
staff after the last note. snap_to_grid rounds to the nearest multiple of the
grid step, rebuilt by exact rational arithmetic so it lands on the grid.
GridResolution is the caller-supplied beat grid (a musical-time step, also the
default duration of a note entered there); a meter-derived default is deferred.
position_at refuses a non-positive grid, a non-metric region (no musical onset),
and a region with fewer than two rendered events (no scale to extrapolate from).
The GUI shows the resolved pitch and snapped position on an empty-staff click,
on a default quarter-note grid.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
The vertical half of click-to-insert: resolve a world point to the staff
under the cursor and the natural diatonic pitch at that height under the
staff's clef.
Staff selection is manifestation-aware. Each rendered bottom staff line
carries its staff's manifestation id as the stroke's stable_id, so a click
maps back to a concrete (region, staff_instance) by matching that id, then
chooses the nearest staff by 2D proximity — horizontal span first (which
region, since one staff tiles across regions that can share a y band), then
the vertical band. The earlier StaffId-collapsing helpers are gone.
The clef is resolved by time, not vector order: render_score builds a
per-staff start-clef table from the logical layout via active_clef (now
exported from epiphany-layout-ir) and threads it through open/commit, so a
mid-vector [bass@1, treble@0] still spells the staff start as treble.
A non-finite click is rejected up front — NaN would slip through
dist_to_band's comparisons as distance 0 and saturate the round() as i32
step into a bogus pitch.
Tests cover finite resolution, x-aware manifestation selection across staves
sharing a y band, non-finite rejection, and time-ordered clef resolution.
The GUI surfaces the resolved pitch on an empty-staff click.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
The inverse of staff_position: a staff step (a clicked staff height) back to the
diatonic pitch (nominal, octave) written there under a clef. This is the vertical
half of the click-to-insert inverse the GUI will use — turning where you click on
a staff into the pitch to place.
It mirrors the forward map's clef and octave-shift convention exactly, computes
the diatonic index in i64 so an extreme step cannot overflow the intermediate,
and returns None for a percussion clef (no diatonic mapping) or a position whose
octave falls outside i8 (rather than wrapping to a wrong-looking octave). A
round-trip test covers treble/bass/alto/tenor and octave-shifted (8va/8vb)
trebles across octaves and nominals, plus the out-of-range refusals.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
Notes above or below the staff now get ledger lines — the gap the GUI surfaced
the moment a note is moved off the staff. Each notehead carries its StaffStep;
to_constrained emits one short horizontal stroke per whole step between the
staff (lines at steps 0..=8) and the note, reaching LEDGER_LINE_EXTENSION past
each side of the note's actual bounding box (so a wide whole note gets a wider
ledger), synthesized from the pitch so the strokes are deterministic and
hit-testable. The synthesis key splits component (high 64 bits) from signed step
(low 64) so two components of a very low note can never collide.
Ledger lines are fixed-width marks, not system-spanning lines, so the Engraver's
horizontal spacing must not scale them. is_rigid_width_stroke marks them; the
remap translates such a stroke rigidly by its *owning glyph's* column delta
(found by source, not the stroke midpoint — which for a wide head can fall nearer
a neighbouring column), so it keeps both its length and its offset from the
notehead. The spacing pass folds each ledger's extent into its notehead's slot,
so adjacent off-staff notes' ledgers reserve room and do not overlap. The stub
solver passes ledgers through unchanged.
Tests cover the step geometry, key distinctness (incl. steps below -128), the
bbox span, width preservation and offset (no-drift) through the Engraver, the
adjacent-overlap spacing, and an explicit two-whole-note off-staff drift case.
Render goldens regenerated.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
A new demo binary crate that proves the EditorSession seam end to end. It renders
the session's resolved layout with epiphany-render-svg, rasterizes that SVG with
resvg into an egui texture, resolves clicks back to staff-space world coordinates
to select, and drives all six note-editing intents from a toolbar and keyboard
(delete, chromatic transpose, staff-step move, add chord note, insert-after). A
debug panel shows the selection and the last applied op, proving the op log is
usable. Intentionally narrow: no duration editing, empty-space insert, or undo UI.
The one piece of nontrivial logic — the screen<->world map a click depends on — is
a pure ViewMap that inverts render-svg's translate(-min_x, max_y) scale(1 -1)
transform, unit-tested for round-trip, the y-up corners, and the logical-vs-ceiled
size mapping (the texture is displayed at the SVG's logical size with a uv crop, so
the click plane is not stretched by the pixmap's rounded-up dimensions). rerender
updates view box, logical size, and texture together on success and clears the
texture on a rasterization failure, so the pixels never disagree with the click
plane.
eframe + resvg are the workspace's first heavy external deps. eframe is pinned to
0.29 for the classic App::update(ctx) + panel API (0.35 redesigned App around
ui()). The crate needs a newer toolchain than the workspace MSRV, so CI excludes
it from the pinned-1.77 workspace commands and checks it in a separate
current-stable editor-gui job; the rest of the workspace is unaffected.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
EditorSession kept only the RenderIR (its hit-test projection) and discarded the
ResolvedLayoutIR the solver produced — but a renderer like epiphany-render-svg
consumes the ResolvedLayoutIR, not the RenderIR. Keep it on the session and add
resolved(), so a GUI can draw the score without re-running the solve. render_score
now threads all three (resolved, render, map); they are updated together on open
and on every committed edit.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
An insert after a pitch that carries an authored spelling override no longer
refuses — it carries the spelling onto the copy. With no override the insert is
still a plain InsertEvent (the copied value takes the inferred spelling); with
one, it emits a transaction of InsertEvent plus a RespellPitch on the new note's
fresh PitchId set to the anchor's authored spelling. The members apply in counter
order, so the insert mints the pitch before the respell spells it, and the copy
renders like the original. The spelling is copied verbatim, not stepped: an
insert-after is a same-pitch time copy, so it keeps the same staff position.
This completes the four-step transaction plan: foundation, override-aware move,
override-carrying insert. The only remaining override refusal is the chord add
(PitchSpellingOverridden), where picking the visual top note needs
resolved-spelling-aware stacking — a separate follow-up.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
A staff-step move of a pitch that carries an authored spelling override no longer
refuses — it rebases the override atomically. With no override the move is still a
plain ModifyIdentifiedPitch (the inferred spelling follows the new value); with
one, it emits a transaction of ModifyIdentifiedPitch (step the value) plus
RespellPitch (step the spelling by the same staff step), so the pinned notehead
and the sound move together as one undoable unit.
authored_spelling returns the winning override spelling, mirroring
resolve_spelling's selection (lowest precedence rank, then highest priority, then
first in canonical order); has_authored_spelling_override now delegates to it.
Because RespellPitch materializes a UserChosen attachment (top precedence),
rebasing the winning spelling works even when the original override was Imported
or Propagated — the new UserChosen shadows it. staff_step_spelling is the spelling
analogue of staff_step: it moves the CMN spelling nominal and octave (B-C carry)
while preserving the accidental stack and render hints, so pitch and spelling move
the same number of staff positions and stay enharmonically consistent.
This is the first intent built on transaction-aware apply. The chord add and the
copy-insert still refuse on an override (PitchSpellingOverridden); insert gets its
override-carrying version next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
apply_transaction(label, category, kinds) commits a sequence of primitives as
one atomic transaction: a DeclareTransaction descriptor plus one member envelope
per kind, all minted under the session identity and committed together. The
session's contiguous zero-based causal context gives every member
descriptor-precedence over the descriptor for free (members come after it in
counter order), so the reducer's well-formedness rule holds without special
handling. A fresh TransactionId is minted log-only (transaction ids live only in
the op stream).
apply and apply_transaction now share one commit(Vec<OperationEnvelope>) engine:
accept the prior log plus the new envelopes, reduce the whole set onto the
pristine base, and commit only if every envelope is accepted, the reduction is
clean, and the layout renders. The is_clean gate is the key correctness point —
a transaction whose member preconditions fail rolls back as a conflict yet the
reducer still returns a score, so without it a rolled-back (dead) transaction
would be logged as successful.
Guards keep the log meaningful: an empty member list (EmptyTransaction) and a
DeclareTransaction submitted as an edit, directly or as a member
(DeclareTransactionNotAllowed), are both refused before minting. This is the
foundation only; no intent uses it yet. Override-aware move/insert land next.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
Insert a new note in the selected pitch's voice, immediately after its event: a
fresh single-note event (InsertEvent) at the next musical position, in the same
voice, copying the selected pitch and its rhythmic value. The selection stays on
the anchor. This completes the insert work begun by the chord-add — both anchor
on the selection, since the click-empty-space-to-position inverse is not in the
hit-test seam yet.
Fresh EventId minting reuses the three-source high-water-mark basis of pitch
minting (base, current score, op log), so event ids are never reused either.
Every reducer rejection is pre-checked so the edit refuses cleanly instead of
appending a dead, non-materializing op: voice overlap (InsertSlotOccupied,
mirroring the reducer's interval test), a non-metric region or non-positive
duration (resolved together with the staff instance, requiring
RegionTimeModel::Metric), and an authored spelling override on the copied pitch
(PitchSpellingOverridden — the raw-value copy would drop the override and render
differently). Refusals are early returns before minting/apply, so the op log and
id counters are untouched; tests assert the log does not grow across a refusal.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
The first insert intent, and the one that introduces fresh object-id minting
into editor-core (delete/move only referenced existing ids). It adds a note to
the selected pitch's event via InsertIdentifiedPitch — a new identified pitch a
diatonic staff step above the event's highest note, so repeated calls build a
rising chord rather than stacking duplicates on one staff position. The
selection stays on the anchor (which survives), and the new note's acoustic
realization is reset to Implicit so it sounds at its written position rather
than inheriting an explicit absolute frequency from the note it stacks above.
Fresh PitchId minting takes the high-water mark over three sources — the
pristine open-time base, the current score (each live or tombstoned), and this
session's op log — because a pitch deleted via DeleteIdentifiedPitch leaves no
trace in the materialized score (the reducer tombstones only its own state), so
reusing its id would make a later insert no-op against a tombstone under
whole-log reduction. Increment is checked; the log scan covers both insert ops.
Adding is refused (PitchSpellingOverridden) when any note in the event carries
an authored spelling override, since the rendered staff order then cannot be
read off the raw pitch positions — resolved-spelling-aware stacking, and the
new-event insert_note_after_selection, are follow-ups.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
Move the selected pitch by N diatonic staff positions (+1 up, -1 down): a
nominal move that carries the octave at the B-C boundary and preserves the
accidental — the "diatonic move" a vertical drag performs. It emits
ModifyIdentifiedPitch, which keeps the note's id, so the selection survives the
relayout. The staff step is the same data path the per-edit causal context was
built for: two sequential moves to one pitch reduce as intentional overwrites,
not a StructuralFieldCollision (proven by replaying the op log).
An authored spelling override (user-chosen, imported, or propagated) resolves
ahead of the inferred spelling and pins the rendered staff position, so a
value-only move would change the sound without moving the notehead. The intent
refuses that case (PitchSpellingOverridden) rather than mislead — the predicate
mirrors resolve_spelling's precedence ranking. A respelling-aware move that
atomically rebases the override is a follow-up (it needs a transaction-aware
apply). The diatonic index is computed in i64 so an extreme step count cannot
overflow before the octave range-check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
Expose the envelopes a session has applied — applied_operations() (oldest
first) and last_applied() — the append-only record undo, history, and sync
build on. Each intent feeds it automatically through apply().
Minted envelopes now carry a real causal context: the session's edits form one
replica's contiguous, zero-based history, so the op at counter n covers the
range [0, n-1]. Two sequential edits to the same target therefore reduce as
intentional overwrites (the later covering the earlier), not as concurrent
StructuralFieldCollision conflicts — both when the session re-reduces its own
log and when a peer replays it.
To keep a covering context satisfiable, apply() reduces the whole accumulated
log onto the pristine open-time score rather than the new op alone onto the
running materialization: the predecessors are present in the set, so the
missing-predecessor rule does not hold the new op pending, and the session's
render is now exactly the canonical reduction of the op log it emits. The
counter is derived from applied.len() (a failed apply consumes no id), and
with_identity is enforced as pre-edit-only so the contiguous history stays
hole-free.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NAtEiJtt9yKVV1zjKYmZhS
EditorSession::delete_selection() deletes the selected object through the right
operation: a selected pitch (a notehead) -> DeleteIdentifiedPitch (the reducer
degrades a last-pitch event to a rest of the same duration, so the rhythm
survives; a chord note simply drops), a selected event (a rest, a stem) ->
DeleteEvent. The deleted object's layout id no longer exists, so the selection is
cleared. Errors (NoSelection / WrongSelection) leave the session untouched, via the
atomic apply().
This exercises a different reducer path from transpose (tombstone-with-rest-degrade
vs. in-place pitch mutation) and stays small. Tests: deleting a note changes the
graph and the render and drops the selection; the intent requires a selection.
(Duration editing is intentionally not added here: a ModifyEvent duration change is
not materialized into the graph -- graph_replace_event defers position/duration
edits pending the voice non-overlap semantics -- which is an epiphany-ops increment,
not editor-core code.)
Full gate green: build, fmt, clippy, 608 tests, conformance scale 1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Packages the proven editing-loop vertical slice as the API a GUI calls -- no UI,
no rendering backend of its own (it produces a RenderIR). EditorSession owns:
- selection state (Selection { source, layout_object }): click(point) selects the
topmost hit, select(id) restores a selection, selection()/clear_selection();
- render/hit-test query: render() and hit_test() for the GUI to draw and resolve
clicks/drags;
- operation minting -- the ergonomics gap the harness exposed, closed before UI
depends on it: the caller passes an OperationKind to apply() (or an intent like
transpose_selection(+1)) and the session assembles the OperationEnvelope (id,
author, stamp, causal context). A GUI never hand-rolls envelope bookkeeping;
- apply/re-render -- ATOMIC: a minted op the reducer rejects (e.g. a reserved
replica identity) returns Err(RejectedOperation), not a silent no-op, and a
diagnostic-only layout returns Err(NotRenderable); on any error nothing mutates,
operation counter included (the candidate id is committed only on success);
- selection preservation: the selection is re-resolved against the new layout,
kept when its layout object survives and cleared when it is gone.
The session is solver-agnostic (Box<dyn ConstraintSolver>), so a GUI plugs in the
Engraver, the stub, or any conformant solver. EditorError implements Display/Error.
epiphany-ops now re-exports AcceptOutcome (accept()'s return type, previously
unreachable) so a caller can inspect a rejection.
Also wires the edit-loop harness into the conformance suite as the [7c] UI-seam
gate: over both fixtures (ten_measure_single_staff and valid_score_rich) every seed
must drive a click->sharpen->re-render cycle whose selection survives the relayout
-- the contract a GUI's correctness rests on.
Full gate green: build, fmt, clippy, 606 tests, conformance scale 1 (incl. [7c]).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
One iteration of an interactive edit, wired end to end so a missing connection
between the crates surfaces here, not in a GUI: render a score to its RenderIR,
resolve a click on a notehead to its graph pitch through the hit-test map, apply a
real operation (sharpen -- a +1-chromatic Transpose) by reducing it onto the score
graph, re-render, and confirm the selection survives the relayout.
- run_edit_loop_with<S: ConstraintSolver>(base, solver) returns an EditLoopReport
(selected_pitch, selection, graph_changed, selection_preserved, render_changed);
run_edit_loop is the stub wrapper. render_with returns None for a
diagnostic-only (non-renderable) solver report, so the loop never hit-tests or
edits a layout the caller must not render.
- The click resolves like a GUI's: aim at each real notehead's centre (a notehead
glyph, not synthesized, pitch-backed) and select whatever the topmost hit there
is, taking the first aim whose topmost hit is a pitch-backed glyph -- faithful
to a click and robust to an occluding unison/chord notehead.
- Selection survival rests on the MUSCLOID layout id, a function of the pitch's
identity (PitchId), not its content: the sharpen changes the pitch's value but
not its id, so its layout object keeps the same stable id and the cursor does
not jump off the edited note. The prepass re-runs inside to_logical, so the
edited pitch is re-spelled and its accidental reflects the new value (why the
re-render reliably differs).
Tests drive the loop on valid_score_rich (one fixture and across 48 seeds, every
seed required), refuse a diagnostic-only solver layout, and -- in epiphany-engrave,
via its existing testkit dev-dep -- run it through the real Engraver across 16
seeds, proving the selection survives even as the Engraver re-spaces every glyph.
Full gate green: build, fmt, clippy, conformance scale 1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Chapter 7 calls provenance "the basis of hit-testing, selection, and
back-reference navigation in the UI" and defers the renderer details to a
companion. An editor needs more than the trace it already has: a structured map
from a rendered primitive to its layout object and its score object, with a
selectable shape, so a click or drag resolves to something to select without the
GUI re-deriving geometry or guessing the chain.
RenderIR::hit_test_map() (new hittest.rs) builds that map: one HitRegion per glyph
primitive and per stroke, each carrying the full chain -- rendered primitive ->
layout object (stable_id) -> score object (source) -- plus the synthesis kind and
a selectable HitShape:
- HitShape::Box: a glyph's IR bounding_box placed by its position and any
transform (corners mapped through the same placement the SVG renderer applies,
so the region aligns with the drawn glyph; I-4a made the IR bbox contain the
ink).
- HitShape::Segment: a stroke's segment with a half-width (half its thickness).
Queries: HitShape::contains (point-in-shape, click) and intersects_rect (an exact
capsule-vs-rectangle test for drag, not just AABB overlap, so a diagonal stroke
whose bounding box clips a corner is not falsely selected); HitTestMap::hit(point)
returns regions topmost-first (paint order: layer, glyph-over-stroke, index) and
within(rect) returns the drag selection in ascending paint order.
Two deliberate boundaries: shapes are in staff-space WORLD coordinates (the same
frame as RenderPrimitive.position, before any renderer's world->screen transform),
so the contract is renderer-independent and a GUI applies the inverse of the same
transform its renderer uses; and a glyph's region is its IR bounding_box, not the
render-only outline.
Tested at the RenderIR boundary (8 tests): box/segment contains + aabb; world-box
placement incl. an affine transform; coverage + provenance-chain preservation over
the real pipeline; click-on-a-notehead resolves to its Pitch; topmost-first point
hits; exact (not AABB-only) drag intersection; paint-ordered drag results. Full
gate green: build, fmt, clippy, 595 tests, conformance scale 1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A status sweep: the I-series Pass-12 candidates are all closed, but the tracker
and two crate DECISIONS.md files still described them as open.
- PASS12_BATCH.md: P12-I1 (structural-placeholder pipeline) is resolved by I-1
-- to_constrained now builds real notation and the Engraver re-spaces it;
P12-I3 (BRAVURA_METRICS approximations) is resolved by I-4a -- metrics
re-extracted from the same pinned 1.392 font, containment-tested. Both rows
struck through and marked done.
- render-svg/DECISIONS.md: fixed the stale P12-I2 bullet, which still said the
MUSCLOID derivation was unwired and the determinism crate exposed no tag (a
miss from the P12-I2 commit); now marked resolved.
- engrave/DECISIONS.md: its P12 section described P12-I1/I3 as open and omitted
I2; rewritten so all three read resolved.
The batch's top-level Status stays OPEN -- the H-series and K-series candidates
remain. Docs-only; no code change.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The spec's domain-tag registry reserves a non-canonical MUSCLOID layout tag for
LayoutObjectId derivation (req:layoutir:object-id-derivation, Pass-11 item 2.6),
but it was never realized in code: layout-ir minted provisional, untagged ids and
synthesized objects borrowed MUSCCONF. This wires the ratified derivation.
- epiphany-determinism: add the reserved built-in DomainTag::LAYOUT_OBJECT_ID
(`MUSCLOID`), non-canonical/layout-namespace like FONT_METRICS (following the
SYSTEM_ANOMALY Pass-11 precedent of adding a reserved tag). The tag-enumeration
tests now derive from BUILTINS so they cannot drift; the spelling is locked and
from_bytes resolves it as a non-system builtin.
- layout-ir provenance.rs: all three LayoutObjectId derivations route through
MUSCLOID exactly per the requirement -- single keyed on source.canonical_bytes(),
multiply-manifested on (source, region), synthesized on (source, synthesis_kind,
instance_key); synthesized no longer borrows MUSCCONF. A reference-lock test
pins the derivation and proves it is genuinely domain-separated; another asserts
the three keying schemes do not collide (safe by the discriminant-led,
fixed-width canonical_bytes).
- layout-ir engraving.rs: EngravingDecisionId borrowed MUSCCONF for the same
reason; moved it onto MUSCLOID too, keeping its `engraving-decision` prefix so it
cannot alias a layout-object id within the namespace.
Layout ids are non-canonical (never document state, in no content hash), so this
changed id *values* but no durable or interchanged artifact: the only golden churn
is the data-prov hex in the four render goldens (every changed line is a data-prov;
geometry/structure byte-identical).
Spec/status sync: core_spec.tex descriptive notes (the requirement tail, the
domain-tag registry row, the registry intro, and the revision-history entry) now
say the reference implementation wires MUSCLOID as of P12-I2; MUSCLOID is moved out
of the "deferred to the companions" (not-ratified/provisional) list and given a
non-canonical anchor paragraph after the reference-implementation-locks table.
PASS12_BATCH.md marks P12-I2 resolved; PASS11_RATIFICATION_LOG.md keeps the
historical row with a "superseded by P12-I2" note; layout-ir/DECISIONS.md updates
the ratified-block note, the id bullet, and the open candidate (now RESOLVED).
Full gate green: build, fmt, clippy, 587 tests, conformance scale 1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A render mode that references each glyph by its SMuFL codepoint via `<text>`,
drawn from an `@font-face`-embedded Bravura subset, alongside the default inline
`<path>` outlines. The SVG stays self-contained (the font travels in it) and the
text is selectable, at a larger file size.
- GlyphMode::EmbeddedFont: the SVG declares the font once via
<defs><style>@font-face{...}</style></defs>, then emits one
<text transform="translate(x y) scale(1 -1)" font-size="4" ...>&#xNNNN;</text>
per glyph. Geometry is consistent with PathOutline by construction: same
origin, a per-glyph counter-flip cancels the outer y-flip, and the SMuFL em is
four staff spaces. A new `text_count` stat; unbundled glyphs still fall through
to the visible bbox rect + diagnostic. The metadata comment declares which mode
produced the SVG (and, on the empty canvas too, via a shared `glyph_note`).
Path mode stays the byte-golden-locked, pixel-verified reference; the embedded
mode is structurally tested (well-formed XML — also under `xmllint`; one
@font-face; one <text>/codepoint per glyph; provenance preserved; determinism).
- The subset is a GENERATED artifact, not a vendored binary: a deterministic
base64 OTF emitted into src/font_subset_generated.rs by
`tools/extract_bravura_outlines.py --font-out` (the same SHA-pinned 1.392 font
the outlines come from). `recalcTimestamp=False` keeps the source font's fixed
head.modified so the bytes are reproducible across runs, not just within one.
- OFL compliance: the subset is a Modified Version, so its PRIMARY font name is
renamed off the Reserved Font Name "Bravura" to "EpiphanyBravuraSubset" in BOTH
naming structures an OTF carries — the SFNT `name` table AND the CFF (Name INDEX
+ top-dict FullName/FamilyName). The copyright/trademark/license records, which
name Bravura as attribution, are kept; the renderer references the renamed
family in @font-face and <text>. The generator reparses the saved bytes and
fails if the reserved name leaks into a primary record, and validates the cmap
covers every glyph.
- Machine-locked payload: the generator emits the decoded length and a BLAKE3-256
(the workspace's sole hash) of the font bytes; a render-svg test base64-decodes
the payload (no new runtime dep) and asserts the length, the OTTO signature, the
BLAKE3, and — parsing the SFNT name table and CFF Name INDEX — that neither
primary name is the reserved name. Adds an epiphany-determinism dev-dep.
The demo example gains `--glyph-mode=path|embedded`; lib/README/DECISIONS document
the two modes, the subset's OFL rename, and the regeneration command (--font-out +
the blake3 dependency). PathOutline goldens are byte-unchanged; the outlines stay
byte-identical. Full gate green: build, fmt, clippy, 585 tests, conformance scale 1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
`RenderOptions::emit_provenance = false` dropped every data-prov trace silently,
so the output was indistinguishable from an archival render even though it no
longer satisfies the renderer's "every element traces to its source" contract.
Now the SVG's metadata comment declares the provenance state, so suppression is
announced rather than silent:
- archival (default): "...; every glyph and stroke carries a data-prov trace
to its score-graph source"
- display-only (false): "...; provenance traces suppressed (display-only
output, not archival)"
A shared `provenance_note(emit_provenance)` helper feeds both the main render and
the empty-canvas path, so an empty trace-free layout is held to the same honesty
contract as a full one (neither can drift). The module doc and the render-svg
DECISIONS.md non-overreach rule now frame data-prov as the default archival
contract plus an explicit, declared display-only mode. Tests assert the suppressed
marker (full and empty layouts) and that the default render declares traces
present.
Goldens regenerated: the default (archival) render's metadata comment now carries
the new "every glyph and stroke carries a data-prov trace" clause, so the four
`.svg` goldens change by that one line (the snapshots, which omit the comment, do
not). Full gate green: build, fmt, clippy, 581 tests, conformance scale 1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The layout metrics table claimed Bravura 1.38.0 while the renderer's outlines are
extracted from 1.392, so the advances/bboxes the engraver reserves space and
evaluates collisions from did not match the ink actually drawn (P12-I3). Both now
come from the SAME SHA-pinned bravura-1.392 font.
- BRAVURA_METRICS is regenerated from 1.392 by tools/extract_bravura_outlines.py
(the same font and script the outlines come from; the regenerated outlines are
byte-identical to the committed ones, and the extracted timeSig rows match the
values bundled earlier -- two integrity checks that the extraction is faithful).
Several old rows were not just stale but wrong: flag8thUp had its ink above the
origin when the glyph hangs below the stem tip; restWhole/restHalf and the
clefs had approximate or mis-signed bounds.
- The metric bbox is rounded OUTWARD from the outline bounds (floor the mins,
ceil the maxes) rather than to the nearest 1/1024, so the integer metric box
always CONTAINS the drawn outline -- the engraver evaluates collisions from
that box, and a containing box keeps a hard no-collision result honest on
paper. A new render-svg test (metric_bboxes_contain_the_drawn_outlines) proves
the containment for every bundled glyph, so a future re-extraction cannot
silently regress it.
- BRAVURA_VERSION is 1.38.0 -> the literal font version: name-table ID 5 reads
"Version 1.392" and head.fontRevision ~= 1.392 (a single decimal), recorded
verbatim as SemVer { major: 1, minor: 392, patch: 0 } so the identifier
round-trips to the font's own string. The canonical mapping rule is documented
on BRAVURA_VERSION; DECISIONS.md is updated to match.
The corrected metrics surfaced a coupled placement bug: the old barlineSingle
metric falsely centred the glyph (+/-2048), so the bottom-origin Bravura barline
(which runs 0..4 staff spaces UP from its origin) was anchored at staff-centre and
floated above the midline. It is now anchored at the staff bottom (yo), so a
barline connects the bottom and top staff lines.
Goldens regenerated (stub + engrave) for the corrected geometry. Full gate green:
build, fmt, clippy, 581 tests, conformance scale 1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Criterion 6 (the Chapter 7 layout round-trip) and the render goldens previously
exercised only the verbatim StubSolver, so a regression in the real Engraver's
geometry could land unseen. I-3 drives both through the Engraver.
- round_trip_with<S: ConstraintSolver> factors the solver-agnostic provenance
contract out of round_trip (now a one-line stub wrapper): coverage, the
complete Provenance surviving constrained -> resolved -> render, the source
surjection, and no duplicate stable ids hold for *any* conformant solver. The
Stub tier's verbatim-geometry clause is gated behind solver.tier() == Stub;
every other tier re-spaces. The status gate accepts any renderable status
(Solved / SolvedWithWarnings / PartialBudgetExhausted), not exactly Solved, so
the helper matches its "arbitrary conformant solver" contract while still
rejecting the diagnostic-only statuses that carry no authoritative layout.
- criterion_six_round_trips_through_the_engravers_respacing (epiphany-engrave)
runs the full graph -> logical -> constrained -> *engraved* -> render round
trip over the criterion-6 hand-off fixtures -- ten_measure_single_staff (the
measured fixture) and valid_score_rich (cross-cutting tuplet/tie/spanner),
plus valid_score for breadth -- and asserts the whole provenance contract
survives the Engraver's re-spacing. A non-vacuity check confirms the Engraver
genuinely moved geometry, so provenance is preserved *through* a real geometry
change -- the statement the verbatim stub can never make. This adds an
epiphany-testkit dev-dep (no cycle: testkit does not depend on this crate).
- The render-svg engraver acceptance test is upgraded from invariant-only to
byte-locked: new .engrave.snapshot.txt / .engrave.svg goldens for both
fixtures capture the Engraver's re-spaced output (e.g. ten_measure view_box
width 82.26 vs the stub's 88.88, same glyph/stroke/class counts), so an
Engraver geometry regression is caught at the byte level. A companion test
asserts the engrave goldens genuinely differ from the stub goldens, catching
the degeneracy where the Engraver echoes the stub (which would otherwise pass
both golden checks independently).
Also corrects the epiphany-engrave package description, which still claimed it
reports SolverTier::Stub until it earns Minimal (it earned Minimal in I-2).
Full gate green: build, fmt, clippy, 580 tests, conformance scale 1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Engraver now evaluates the IR's declared hard constraints against the
resolved geometry and reports honestly, so it reports SolverTier::Minimal --
"hard constraints satisfied, no claim about optimality" (Chapter 9) -- instead
of the interface-only Stub.
- evaluate_constraints checks each LayoutConstraint against the resolved glyph
boxes: NoCollision (boxes do not overlap), Align (equal baseline y for the
Horizontal axis, equal x for Vertical), PositionWithin (box inside the
region). A hard SystemBreakAt/PageBreakAt is reported unsatisfied -- a
single-system, single-page Minimal solve casts off nothing (a soft break
imposes no obligation); an unverifiable Registered extension constraint is
conservatively not claimed satisfied.
- Honest report status: a malformed input (invalid structure or forged/unknown
catalog) is InternalError; a valid problem whose hard constraints cannot all
be satisfied is Unsatisfiable, naming the offenders in unsatisfied_constraints;
all satisfied is Solved. satisfied_hard_constraints and
budget_used.constraint_evaluations reflect real work (0 when evaluation is
skipped on a malformed input).
- Minimal makes no normalized-metric claim, so the metric vector stays the
conservative all-worst "no claim" placeholder (the Quality Metric Catalog is
Phase 3 / Standard). Still deferred to a later tier: the vertical spring pass
(glyph y is the constrained natural staff layout, preserved verbatim) and
casting-off.
Tests pin the tier, an empty (vacuously satisfied) constraint set, satisfied vs
violated NoCollision, hard-vs-soft breaks, and the skipped-evaluation count.
Full gate green: build, fmt, clippy, 578 tests, conformance scale 1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Turn the Score -> layout-IR -> SVG pipeline from placeholder glyphs into real
music notation, rendered through the stub solver and the real Engraver alike.
to_constrained (the spacing pass) now dispatches each layout object to the
notation primitive that represents it, on a column-based spring model:
- Pitch -> notehead at its clef-relative staff position; chord pitches share
one column slot. StaffInstance -> clef glyph. Staff -> five staff-line
strokes (the bottom line its anchor, four synthesized). Pitched Event ->
stem stroke; Measure -> barline glyph; rest Event -> rest glyph.
- Phase 3 ornaments as synthesized glyphs: a spelling's full accidental stack
left of its notehead, a key signature's clef-relative sharp/flat zigzag in
the lead, and a measure's numerator/denominator time-signature digit pair.
- A tied decomposition draws one notehead/stem/rest per component (offsets
honored, not collapsed). Active clef and key resolve by time, not vector
order. The lead area reserves clef + key-signature width.
- Coverage/surjection preserved so the round-trip holds: each laid-out object
is covered by exactly one exact-provenance primitive, and derived primitives
(staff lines, components, accidentals, key/time glyphs) are synthesized from
a laid-out source. Engraving-coverage gaps (missing spelling, unbundled
glyph) are surfaced as ConstrainedLayoutIR diagnostics, not silently
defaulted. A measure depends on the time signature it displays.
The Engraver re-spaces glyphs AND the strokes that track them through one
collision-aware coordinate map (per-slot left/right bearings, pairwise
advances), so stems / barlines / staff lines stay attached and a note's
accidental never overlaps the previous note. validate() now rejects empty
spring slots -- the contract that map relies on -- and to_constrained never
emits one (slots are realized by glyph occupancy).
Bundle the genuine Bravura outlines and metrics for time-signature digits 0-9
(regenerated from the SHA-pinned font via tools/extract_bravura_outlines.py),
replacing an inconsistent hand-written placeholder.
Regions tile left-to-right (no page casting-off yet). Goldens regenerated into
recognizable notation. Full gate green: build, fmt, clippy, 574 tests,
conformance scale 1.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Prerequisite for the visible-slice engraving milestones (I-1+): give the
score graph the clef/key data the engrave pipeline needs for clef-relative
staff positioning and key-signature rendering, replacing the Chapter-7
TimeAnchor-only placeholders.
epiphany-core:
- ClefShape { G, F, C, Percussion } — each family's reference pitch (G4 / F3 /
middle C4) pins the staff-position mapping.
- Clef { shape, line, octave_shift } — the SMuFL family, the staff line its
reference pitch sits on (1 = bottom line), and an octave transposition; with
treble/bass/alto/tenor constructors and a treble Default. Generalizes to any
C-clef line and octave-transposing clefs.
- KeySignature — circle-of-fifths position, validated -7..=7: a private field
behind KeySignature::new (Option) + fifths(), and a custom Codec that rejects
an out-of-range count on decode (Reconstruct, as PowerOfTwo / Tempo do).
- ClefChange now carries clef: Clef; KeySignatureChange carries key: KeySignature.
- Codec (cstyle_enum_codec / struct_codec / the validating KeySignature impl),
lib.rs re-exports, a round-trip test over every shape x line x octave and every
fifths value, and an out-of-range decode-rejection regression test.
Zero blast radius: nothing constructed these with data (clef/key sequences are
always empty) and invariants only read .anchor, so existing Score bytes, hashes,
and goldens are unchanged. Fixtures get populated and the engrave pipeline reads
the data in I-1.
Gates: build/fmt/clippy -D warnings clean; cargo test --workspace green;
conformance_suite scale 1 passes. Stages only epiphany-core; the pre-existing
Agent-I working tree (engrave/layout-ir/render-svg + .gitignore) stays unstaged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address the two-pass review of the M2e catalog expansion (ca07c28). All gates
green: cargo test --workspace 535/0, conformance_suite scale 1, fmt + clippy
-D warnings clean, catalog PDF rebuilt (no undefined refs).
Pass 1 (doc-vs-implementation accuracy):
- Undo semantics narrowed to the prototype minted-object model (the catalog's
own UndoTransaction model + P11-C8): the new K0 sections' minting members
(insert/create) keep tombstone-the-mint undo; the non-minting ops (modify /
transpose / deletes / settings) now state they synthesize no inverse, rather
than promising rich restore/reintroduction the reducer does not implement.
- Spanner migration corrected: Tie/Slur/Beam reconstruct self-containedly while
a Spanner remains read-only/unmigratable until the v0 projection carries its
TimeAnchors (a Phase-3/Pass-12 extension); the "joins in M2" claim is removed,
in both the CreateCrossCutting section and the migration chapter.
- The reduce()/reduce_onto() agreement claim (DECISIONS + the staff_based_regions
comment) narrowed to regions represented in reducer state: op-created/deleted
regions agree, but reduce_onto additionally seeds base regions a base-free
reduce() never sees.
Pass 2 (empty-container enforcement, made real and complete):
- create_region / create_staff_instance / create_voice reject (ContainerNotEmpty)
a carried value bearing ANY typed child object — not just the structural
hierarchy. A region: no staff instances, barline-alignment groups, or graphic
objects; a staff instance: no voices or measures; a voice: no events. Each is a
distinct TypedObjectId the reducer mints separately, so a carried child would
otherwise materialize an unminted object into the graph (a graph/ledger
faithfulness gap). ClefChange/KeySignatureChange/metric-grid carry no
TypedObjectId and are values, so they are correctly not gated. The check reads
the carried value only, so reduce() and reduce_onto() agree.
- Catalog §Structural Containers states the precondition as "no typed child
object" with the per-container enumeration, matching enforcement exactly.
Coverage: new graph_reduction tests create_rejects_a_non_empty_carried_container
(hierarchy children) and create_rejects_carried_non_hierarchy_children (barline
group / graphic object / measure). DECISIONS M2c gains the create-emptiness bullet.
Stages only ops + spec; the unrelated Agent-I working tree is left untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The documentation milestone deferred through M2a–M2d. Documentation only —
no Rust changes; the d93baac code gates (cargo test --workspace 533/0,
conformance_suite scale 1) are unchanged.
operation_catalog (v0.1.0 -> v0.2.0):
- Chapter K0 gains full six-part schema sections for every M2-implemented op:
ModifyEvent; Identified-Pitch Operations (insert/delete/modify, with the
note<->rest equivalence stated normatively); Transpose; DeleteCrossCutting;
ModifyCrossCutting; Structural Containers (region/staff-instance/voice
set-union mint + empty-only delete); Score Settings (advisory metadata,
structural metric grid with the staff-based + live-time-signature
preconditions, advisory page break under the resolved-position LWW key).
- Chapter K1 cleanup: the implemented groups now cross-reference their K0
sections rather than sit in "MUST reject"; the stale Phase-3 listing of
SetMetadata / SetMetricGrid / page-break advisory (implemented in M2d) is
removed, and the remaining slots are split to the genuinely-unimplemented
finer metric ops (time signature / tempo segment) and non-break layout.
- Intro, conformance-profile, and version strings updated for the expansion.
- PDF rebuilt with xelatex (18 pp, no undefined refs); .xdv removed, only
tex+pdf tracked.
epiphany-ops/DECISIONS.md: new M2c (Group 3 — empty-only structural-container
delete; live-child indices; staff-extent maintenance) and M2d (Group 4 — the
per-op disciplines as review-hardened in d93baac: advisory metadata, metric
grid with both preconditions, resolved-position break key) entries, and a note
that the dedicated 10K-envelope reducer micro-bench (criterion 5) is Agent F's
worklist F1 — the M2 value-typed ops are already exercised at 10K*scale by the
conformance reduction-determinism / convergence gates.
The unrelated Agent-I working tree is left untouched; this commit stages only
spec/ + ops DECISIONS.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address the five-finding review of M2d (1e4ab24) plus the two-finding
follow-up review; all fixes are code/test/doc only, no spec change needed
(the catalog/core-spec already classify metadata as advisory LWW).
- SetMetadata is now a true advisory LWW: it silently last-writer-wins in
canonical order and records no conflict, matching operation_catalog
§set-user-system-break. Dropped the StructuralFieldCollision, the
`last_metadata` working slot, and the `env` parameter; rewrote the
conflict test as `concurrent_differing_set_metadata_is_advisory_lww`
(no conflict, state stays clean, permutation-independent resolution).
- SetMetricGrid / SetUserPageBreak / SetUserSystemBreak share a new
`layout_region_slot` precondition backed by a `staff_based_regions`
index: the target must be live and staff-based (FreeGraphic regions have
neither a metric-grid nor a break slot). The index is read from base-free
state, so reduce() and reduce_onto() reach the same verdict for missing,
tombstoned, and FreeGraphic regions.
- SetMetricGrid now rejects a grid whose meter_sequence names an undeclared
time signature, rather than installing an invariant-violating grid.
- User breaks materialize under the canonical LWW key: `apply_break_lww`
drops any existing anchor resolving to the same position before adding,
so the graph break list stays in lockstep with the resolved-position
ledger map (shared `resolved_anchor_position`). Applied to page and
system breaks alike.
- Coverage: SetMetadata/SetMetricGrid/CreateVoice/DeleteVoice added to the
tag-distinctness test; layout_stub `gen_operation_kind_tag` extended to
every normative tag; the MaterializedState decode test populates
page_breaks; four direct regression tests pin each fixed bug; the stale
SetMetadata/score_metadata doc comments now say advisory LWW.
Gates: build/fmt/clippy -D warnings clean; cargo test --workspace green
(533); conformance_suite scale 1 passes. Stages only core/ops/testkit; the
unrelated Agent-I working tree is left untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>